From 5bb306c4117122dd44e96e7a5fca804ff636b684 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:53:03 +0200 Subject: [PATCH 001/138] Add optional GBC display colour correction --- docs/gbc-color-correction.md | 49 +++++ docs/new-features.md | 9 + licenses/SameBoy.txt | 21 +++ src/core/Game.lua | 1 + src/core/SaveData.lua | 2 + src/import/RomImporter.lua | 3 + src/render/GbcColorCorrection.lua | 162 +++++++++++++++++ src/render/Renderer.lua | 8 +- src/ui/OptionsMenu.lua | 20 ++ tests/engine/gbc_color_correction.lua | 111 ++++++++++++ tools/gbc-color-check/conf.lua | 7 + tools/gbc-color-check/main.lua | 252 ++++++++++++++++++++++++++ 12 files changed, 642 insertions(+), 3 deletions(-) create mode 100644 docs/gbc-color-correction.md create mode 100644 licenses/SameBoy.txt create mode 100644 src/render/GbcColorCorrection.lua create mode 100644 tests/engine/gbc_color_correction.lua create mode 100644 tools/gbc-color-check/conf.lua create mode 100644 tools/gbc-color-check/main.lua diff --git a/docs/gbc-color-correction.md b/docs/gbc-color-correction.md new file mode 100644 index 000000000..10f7851b1 --- /dev/null +++ b/docs/gbc-color-correction.md @@ -0,0 +1,49 @@ +# GBC colour correction + +Gold, Silver and Crystal can display cartridge RGB values through an optional +GBC display-response model. In **OPTIONS -> GBC COLOR**, choose **OFF**, +**ACCURATE** (SameBoy's Modern Accurate) or **HARSH** (Harsh Reality). The +launcher also offers **GBC COLOR (GEN 2)** under game defaults, including +per-generation overrides. The selection takes effect immediately in game. + +OFF is the default and leaves the original render path unchanged. The option +does nothing on other generations or in OG, OG INV and CLASSIC monochrome +modes. The saved preference is retained when switching those modes. + +ACCURATE corrects the GBC brightness curve and green/blue mixing while keeping +black and white. HARSH additionally models the original LCD's reduced contrast +and tinted black/white points. Neither is a calibration for a particular +physical screen or its lighting. Both use the CGB model with neutral light +temperature; this does not emulate an AGB panel or SameBoy's light-temperature +slider. + +The colour mapping is adapted from +[SameBoy Core/display.c](https://github.com/LIJI32/SameBoy/blob/26a56bd6a122a6318a456abd80558408742d5adf/Core/display.c#L339), +`GB_convert_rgb15`, revision `26a56bd6a122a6318a456abd80558408742d5adf`. +Its Expat notice is retained in [licenses/SameBoy.txt](../licenses/SameBoy.txt). +See also [SameBoy's mode documentation](https://github.com/LIJI32/SameBoy/wiki/GB_set_color_correction_mode). + +The renderer uses a cached 256x128 lookup texture per profile and a simple +two-sample shader. Interpolation between RGB555 entries keeps true-colour mod +gradients smooth. It processes the native world/UI composite before mod +post-processing and the independent GBC FX grid/glare. Launcher chrome and +touch controls are outside that composite. Mods taking over `render.compose` +own their presentation and bypass this native pass. + +Enabling correction adds one screen-sized colour pass. Shader failure falls +back to the original image. Changing it does not modify caches, ROM-derived +palettes or gameplay state and needs no reimport. + +## Verification + +`luajit tests/engine/gbc_color_correction.lua` compares all 32768 RGB555 inputs +per profile with digests produced by compiling the pinned upstream conversion +function (CGB_E, neutral temperature). The hash starts at zero and processes +RGB bytes in increasing RGB555 order, `hash = (hash * 33 + byte) mod 2^32`. + +`love tools/gbc-color-check` runs GPU checks with synthetic colours: desktop +and GLES shader validation, the entire RGB555 gamut, interpolated colours, +alpha/state preservation, DPI/resize handling and the native renderer path. +It uses a separate save-directory identity and no cartridge data. These +checks do not substitute for a real-ROM visual comparison or mobile hardware +testing. diff --git a/docs/new-features.md b/docs/new-features.md index 532cbf3ae..d4612474d 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -143,6 +143,15 @@ effect, `=1` forces it available. The Anbernic handheld pack exports `0` from its launcher because the device reports `"Linux"` while its GPU is in the phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)). +## GBC colour correction + +Gold, Silver and Crystal offer **OPTIONS -> GBC COLOR -> OFF / ACCURATE / +HARSH**, independently of GBC FX. The profiles adapt SameBoy's Modern Accurate +and Harsh Reality GBC display response. OFF preserves the previous rendering; +the selection is saved and does not require a ROM reimport. Details, scope, +source attribution and verification are in +[GBC colour correction](gbc-color-correction.md). + ## Performance tier (low-end devices) The Options **PERFORMANCE** row scales the port's optional presentation diff --git a/licenses/SameBoy.txt b/licenses/SameBoy.txt new file mode 100644 index 000000000..41c3bc963 --- /dev/null +++ b/licenses/SameBoy.txt @@ -0,0 +1,21 @@ +Expat License + +Copyright (c) 2015-2026 Lior Halphon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/core/Game.lua b/src/core/Game.lua index 9dc07ce9d..770e861d0 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1855,6 +1855,7 @@ function Game:applyOptions(opts) self.input:setButtonMode(opts.gen3ButtonMode or 1) end require("src.render.PaletteFX").applyOptions(opts) + require("src.render.GbcColorCorrection").applyOptions(opts) require("src.render.Tilt").applyOptions(opts) -- after Tilt, so a persisted world pipeline can switch the tilt level it -- just restored back off (the two are mutually exclusive) diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 2bfe53530..1d2a30e41 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -459,6 +459,8 @@ function SaveData.defaultOptions() colors = "gbc", tilt = 0, gbcfx = 0, + -- Optional GBC display response, separate from the LCD grid/glare effects. + gbcColorCorrection = "off", -- survey zoom offset from window fit scale (0 = FIT); see Zoom.lua zoom = 0, -- OVERWORLD beyond-edge fill: trees | water | black diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 858974555..eb28917d5 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -8564,6 +8564,9 @@ RomImporter.SETTINGS_ROWS = { labels = { gbc = "GBC", sgb = "SGB", dmg = "DMG", classic = "CLASSIC" } }, { id = "gbcfx", label = "GBC FX", values = { false, true }, labels = { [false] = "OFF", [true] = "ON" } }, + { id = "gbcColorCorrection", label = "GBC COLOR (GEN 2)", + values = { "off", "accurate", "harsh" }, + labels = { off = "OFF", accurate = "MODERN ACCURATE", harsh = "HARSH REALITY" } }, { id = "videoMode", label = "VIDEO MODE", values = { "windowed", "borderless", "fullscreen" }, labels = { windowed = "WINDOWED", borderless = "BORDERLESS", diff --git a/src/render/GbcColorCorrection.lua b/src/render/GbcColorCorrection.lua new file mode 100644 index 000000000..9250fbfb7 --- /dev/null +++ b/src/render/GbcColorCorrection.lua @@ -0,0 +1,162 @@ +-- GBC display response adapted from SameBoy's GB_convert_rgb15 (CGB model, +-- neutral light temperature), revision 26a56bd6a122a6318a456abd80558408742d5adf. +-- Copyright (c) 2015-2026 Lior Halphon. Expat license: licenses/SameBoy.txt. +-- This is separate from GBCFX's grid/glare and never alters imported palettes. +local GameVersion = require("src.core.GameVersion") +local PaletteFX = require("src.render.PaletteFX") +local Logger = require("src.core.Logger") + +local Correction = { mode = "off" } +Correction.MODES = { "off", "accurate", "harsh" } +Correction.LABELS = { off = "OFF", accurate = "ACCURATE", harsh = "HARSH" } + +local CURVE = { 0,6,12,20,28,36,45,56,66,76,88,100,113,125,137,149, + 161,172,182,192,202,210,218,225,232,238,243,247,250,252,254,255 } +local floor = math.floor +local shader, output, failedTarget +local textures = {} + +function Correction.normalize(mode) + return (mode == "accurate" or mode == "harsh") and mode or "off" +end + +function Correction.setMode(mode) + Correction.mode = Correction.normalize(mode) + failedTarget = nil + if Correction.mode == "off" and output then + output:release() + output = nil + end +end + +function Correction.applyOptions(opts) + Correction.setMode(opts and opts.gbcColorCorrection) +end + +function Correction.cycle(mode, dir) + mode = Correction.normalize(mode) + for i, value in ipairs(Correction.MODES) do + if value == mode then return Correction.MODES[(i - 1 + dir) % #Correction.MODES + 1] end + end +end + +-- Pure RGB555 -> RGB888 reference. Integer truncations in HARSH are deliberate +-- (SameBoy's uint8_t arithmetic); changing their order changes the palette. +function Correction.convert5(r, g, b, mode) + mode = Correction.normalize(mode) + if mode == "off" then + return r * 8 + floor(r / 4), g * 8 + floor(g / 4), b * 8 + floor(b / 4) + end + r, g, b = CURVE[r + 1], CURVE[g + 1], CURVE[b + 1] + if g ~= b then + g = floor((((g / 255)^2.2 * 3 + (b / 255)^2.2) / 4)^(1 / 2.2) * 255 + 0.5) + end + if mode == "harsh" then + local nr = floor(r * 15 / 16) + floor((g + b) / 32) + local ng = floor(g * 15 / 16) + floor((r + b) / 32) + local nb = floor(b * 15 / 16) + floor((r + g) / 32) + return floor(nr * 117 / 255) + 45, floor(ng * 126 / 255) + 41, + floor(nb * 119 / 255) + 38 + end + return r, g, b +end + +-- 32 blue slices, eight columns of 32x32 red/green tiles. Bilinear red/green +-- sampling plus blue interpolation preserves smooth gradients in mod art; +-- there is no per-frame quantization of the scene back down to five bits. +Correction.SHADER_SRC = [[ +extern Image correctionLut; +vec2 lutCoord(vec2 rg, float blue) { + vec2 tile = vec2(mod(blue, 8.0), floor(blue / 8.0)); + return (tile * 32.0 + rg + vec2(0.5)) / vec2(256.0, 128.0); +} +vec4 effect(vec4 color, Image texture, vec2 uv, vec2 screen) { + vec4 pixel = Texel(texture, uv); + vec3 rgb = clamp(pixel.rgb, 0.0, 1.0) * 31.0; + float low = floor(rgb.b); + vec3 a = Texel(correctionLut, lutCoord(rgb.rg, low)).rgb; + vec3 b = Texel(correctionLut, lutCoord(rgb.rg, min(low + 1.0, 31.0))).rgb; + return vec4(mix(a, b, fract(rgb.b)), pixel.a) * color; +} +]] + +local function resources() + if shader == false or textures[Correction.mode] == false then return false end + local pixels + local ok, err = pcall(function() + if not shader then shader = love.graphics.newShader(Correction.SHADER_SRC) end + if not textures[Correction.mode] then + pixels = love.image.newImageData(256, 128) + for b = 0, 31 do + for g = 0, 31 do + for r = 0, 31 do + local nr, ng, nb = Correction.convert5(r, g, b, Correction.mode) + pixels:setPixel((b % 8) * 32 + r, floor(b / 8) * 32 + g, + nr / 255, ng / 255, nb / 255, 1) + end + end + end + local texture = love.graphics.newImage(pixels) + texture:setFilter("linear", "linear") + texture:setWrap("clamp", "clamp") + textures[Correction.mode] = texture + end + end) + if pixels then pixels:release() end + if not ok then + if not shader then shader = false end + textures[Correction.mode] = false + Logger.warn("GBC color correction unavailable: %s", tostring(err)) + end + return ok +end + +function Correction.active() + if failedTarget or Correction.mode == "off" or not GameVersion.isGen2() + or PaletteFX.monoMode() then return false end + return resources() +end + +-- Colour the native finished frame before mod post-processing and LCD FX. +-- OFF/unsupported returns the original surface without allocating a canvas. +function Correction.apply(canvas) + if not Correction.active() then return canvas end + local width, height = canvas:getDimensions() + local dpi = canvas.getDPIScale and canvas:getDPIScale() or 1 + local ok, err = pcall(function() + if not output or output:getWidth() ~= width or output:getHeight() ~= height + or output:getDPIScale() ~= dpi then + if output then output:release(); output = nil end + output = love.graphics.newCanvas(width, height, { dpiscale = dpi }) + output:setFilter("nearest", "nearest") + end + end) + if not ok then + failedTarget = true + Logger.warn("GBC color correction target unavailable: %s", tostring(err)) + return canvas + end + love.graphics.push("all") + local rendered, drawError = pcall(function() + love.graphics.setCanvas(output) + love.graphics.origin() + love.graphics.setScissor() + love.graphics.setStencilTest() + love.graphics.setDepthMode() + love.graphics.setColorMask(true, true, true, true) + love.graphics.setBlendMode("replace", "premultiplied") + love.graphics.setColor(1, 1, 1, 1) + shader:send("correctionLut", textures[Correction.mode]) + love.graphics.setShader(shader) + love.graphics.draw(canvas, 0, 0) + end) + love.graphics.pop() + if not rendered then + failedTarget = true + Logger.warn("GBC color correction draw failed: %s", tostring(drawError)) + return canvas + end + return output +end + +return Correction diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 7cff87285..939b75c13 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -12,6 +12,7 @@ local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") local PixelCanvas = require("src.render.PixelCanvas") local Runtime = require("src.mods.Runtime") +local GbcColorCorrection = require("src.render.GbcColorCorrection") -- RESOLVED ONCE, NOT EVERY FRAME. These three were `require`d from inside -- beginFrame and endFrame -- three string hashes into package.loaded on every @@ -929,7 +930,7 @@ function Renderer:endFrame(zones, worldZones) and (self.battleWipe.style == "frlg_swirl" or self.battleWipe.needsSource) and self.battleWipe.screenDraw - local needPresent = GBCFX.active() or Pipelines.wantsPresent() + local needPresent = GBCFX.active() or GbcColorCorrection.active() or Pipelines.wantsPresent() or battleNeedsSource local present = nil if needPresent then @@ -1399,8 +1400,9 @@ function Renderer:endFrame(zones, worldZones) -- LCD grid is then drawn over rather than something that smears the -- grid itself. Each pass hands back a canvas; with none registered -- this returns `present` unchanged and the frame is byte-identical. - local composed = Pipelines.present(present, - { width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present + local corrected = GbcColorCorrection.apply(present) + local composed = Pipelines.present(corrected, + { width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or corrected if GBCFX.active() then -- shader grid/shadow math is in framebuffer pixels GBCFX.present(composed, Sp) diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index d6d1f500d..3deb3a4c9 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -14,6 +14,7 @@ local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") local Tilt = require("src.render.Tilt") local GBCFX = require("src.render.GBCFX") +local GbcColorCorrection = require("src.render.GbcColorCorrection") local Zoom = require("src.render.Zoom") local TileRenderer = require("src.render.TileRenderer") local GameSpeed = require("src.core.GameSpeed") @@ -380,6 +381,17 @@ local function buildRows(game) PaletteFX.setMode(o.colors) return true end }, + { id = "gbcColorCorrection", label = Strings("GBC COLOR"), + value = function(g) + return Strings(GbcColorCorrection.LABELS[ + GbcColorCorrection.normalize(g.save.options.gbcColorCorrection)]) + end, + step = function(g, dir) + local o = g.save.options + o.gbcColorCorrection = GbcColorCorrection.cycle(o.gbcColorCorrection, dir) + GbcColorCorrection.applyOptions(o) + return true + end }, { id = "tilt", label = Strings("TILT"), value = function(g) return Tilt.levelLabel(g.save.options.tilt or 0) end, step = function(g, dir) @@ -522,6 +534,14 @@ local function buildRows(game) return true end }, } + -- GBC response curves do not describe the other generations' displays. + if not GameVersion.isGen2() then + local filtered = {} + for _, row in ipairs(rows) do + if row.id ~= "gbcColorCorrection" then filtered[#filtered + 1] = row end + end + rows = filtered + end -- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks if not GBCFX.isSupported() then local filtered = {} diff --git a/tests/engine/gbc_color_correction.lua b/tests/engine/gbc_color_correction.lua new file mode 100644 index 000000000..532fe449a --- /dev/null +++ b/tests/engine/gbc_color_correction.lua @@ -0,0 +1,111 @@ +-- No ROM/GPU needed. Reference digests were generated by compiling SameBoy's +-- unmodified GB_convert_rgb15 at 26a56bd6a122a6318a456abd80558408742d5adf, +-- CGB_E, neutral temperature, RGB byte order, RGB555 values 0..32767. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local C = require("src.render.GbcColorCorrection") +local SaveData = require("src.core.SaveData") +local GenOptions = require("src.core.GenOptions") +local Version = require("src.core.GameVersion") +local Palette = require("src.render.PaletteFX") + +T.eq(SaveData.defaultOptions().gbcColorCorrection, "off", "existing installations keep their colours") +T.eq(SaveData.mergeOptions({}).gbcColorCorrection, "off", "old options default to OFF") +T.eq(C.normalize("invalid"), "off", "unknown profile falls back to OFF") +T.eq(C.normalize(true), "off", "invalid option type falls back to OFF") +T.eq(C.cycle(nil, 1), "accurate", "first step selects Modern Accurate") +T.eq(C.cycle("accurate", 1), "harsh", "next step selects Harsh Reality") +T.eq(C.cycle("harsh", 1), "off", "forward wraps to OFF") +T.eq(C.cycle("off", -1), "harsh", "left cycles backwards") +local opts = { gbcColorCorrection = "invalid", untouched = true } +C.applyOptions(opts) +T.eq(C.mode, "off", "applyOptions sanitizes the live mode") +T.eq(opts.gbcColorCorrection, "invalid", "presentation never rewrites saved settings") +T.check(opts.untouched, "unrelated options survive") + +local expected = { accurate = 37176544, harsh = 1187375320 } +for _, mode in ipairs({ "accurate", "harsh" }) do + local hash, bounded = 0, true + for b = 0, 31 do for g = 0, 31 do for r = 0, 31 do + local nr, ng, nb = C.convert5(r, g, b, mode) + for _, v in ipairs({ nr, ng, nb }) do + bounded = bounded and v >= 0 and v <= 255 and v == math.floor(v) + hash = (hash * 33 + v) % 4294967296 + end + end end end + T.eq(hash, expected[mode], mode .. " matches all 32768 upstream RGB555 colours") + T.check(bounded, mode .. " always returns valid integer RGB888") +end +T.same({ C.convert5(0, 0, 0, "accurate") }, { 0, 0, 0 }, "accurate keeps black") +T.same({ C.convert5(31, 31, 31, "accurate") }, { 255, 255, 255 }, "accurate keeps white") +T.same({ C.convert5(0, 0, 0, "harsh") }, { 45, 41, 38 }, "LCD profile has the upstream black point") +T.same({ C.convert5(31, 31, 31, "harsh") }, { 161, 166, 156 }, "LCD profile has the upstream white point") +T.same({ C.convert5(31, 0, 16, "off") }, { 255, 0, 132 }, "OFF reference uses uncorrected RGB555 expansion") + +local wire = SaveData.encode({ gbcColorCorrection = "harsh", speed = 2 }) +T.eq(SaveData.decode(wire).gbcColorCorrection, "harsh", "selection survives serialization") +local scoped = { gbcColorCorrection = "off", perGeneration = { [2] = { gbcColorCorrection = "accurate" } } } +T.eq(GenOptions.resolve(scoped, 1).gbcColorCorrection, "off", "Gen 1 keeps its shared value") +T.eq(GenOptions.resolve(scoped, 2).gbcColorCorrection, "accurate", "Gen 2 can override its correction") +T.eq(GenOptions.resolve(scoped, 3).gbcColorCorrection, "off", "Gen 3 keeps its shared value") + +_G.love = require("tests.love_stub") +local Options = require("src.ui.OptionsMenu") +local function rowFor(version) + Version.set(version) + local game = { save = { options = SaveData.defaultOptions() }, data = {} } + for _, row in ipairs(Options.buildRows(game)) do + if row.id == "gbcColorCorrection" then return row, game end + end +end +T.eq(rowFor("red"), nil, "correction is not offered on Gen 1") +T.eq(rowFor("emerald"), nil, "correction is not offered on Gen 3") +local row, game = rowFor("gold") +T.check(row ~= nil, "Gold offers the correction row") +T.eq(row.value(game), "OFF", "in-game row initially shows OFF") +row.step(game, 1) +T.eq(game.save.options.gbcColorCorrection, "accurate", "in-game row stores the selected profile") +T.eq(C.mode, "accurate", "in-game row applies live") +row.step(game, -1) +T.eq(C.mode, "off", "in-game left restores OFF") +local writes = 0 +game.input = { wasPressed = function(_, key) return key == "right" end } +game.writeOptions = function() writes = writes + 1 end +local menu = Options.new(game) +for i, entry in ipairs(menu.rows) do + if entry.id == "gbcColorCorrection" then menu.index = i end +end +menu:update(1 / 60) +T.eq(C.mode, "accurate", "mapped menu input changes the live profile") +T.eq(writes, 1, "mapped menu input persists through the normal options writer") +T.check(rowFor("silver") ~= nil, "Silver offers the correction row") +T.check(rowFor("crystal") ~= nil, "Crystal offers the correction row") +local RI = require("src.import.RomImporter") +local defaults +for _, entry in ipairs(RI.SETTINGS_ROWS) do + if entry.id == "gbcColorCorrection" then defaults = entry end +end +T.same(defaults.values, { "off", "accurate", "harsh" }, "launcher offers the same profiles") + +-- No GPU use at all on OFF, other generations or intentionally mono modes. +local allocations = 0 +love.graphics.newShader = function() allocations = allocations + 1; error("no shaders") end +Version.set("gold") +Palette.setMode("gbc") +C.setMode("off") +local input = {} +T.eq(C.apply(input), input, "OFF returns the original surface") +C.setMode("accurate") +Version.set("red") +T.eq(C.apply(input), input, "other games return the original surface") +Version.set("gold") +Palette.setMode("classic") +T.eq(C.apply(input), input, "mono palettes return the original surface") +T.eq(allocations, 0, "inert paths allocate no shaders or canvases") +Palette.setMode("gbc") +T.eq(C.apply(input), input, "shader compilation failure preserves the frame") +T.eq(C.apply(input), input, "unsupported GPU remains a safe pass-through") +T.eq(allocations, 1, "unsupported shader is not retried each frame") +C.setMode("off") +Version.set("red") +T.finish("GBC colour correction") diff --git a/tools/gbc-color-check/conf.lua b/tools/gbc-color-check/conf.lua new file mode 100644 index 000000000..c51c84b19 --- /dev/null +++ b/tools/gbc-color-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + t.identity = "gen2-gbc-color-check" + t.window.title = "Gen2Recomped GBC colour checks" + t.window.width, t.window.height = 640, 576 + t.window.resizable = true + t.modules.audio = false +end diff --git a/tools/gbc-color-check/main.lua b/tools/gbc-color-check/main.lua new file mode 100644 index 000000000..30ac6eb67 --- /dev/null +++ b/tools/gbc-color-check/main.lua @@ -0,0 +1,252 @@ +-- Run from the repo root: love tools/gbc-color-check +-- Synthetic RGB data only. Optional GBC_COLOR_CHECK_SHOT=/absolute/path.png +-- writes the final three-panel comparison; no game assets or saves are read. +local root = love.filesystem.getSource():match("^(.*)/tools/gbc%-color%-check$") + or love.filesystem.getWorkingDirectory() +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path + +local C = require("src.render.GbcColorCorrection") +local Version = require("src.core.GameVersion") +local Palette = require("src.render.PaletteFX") +local Renderer = require("src.render.Renderer") +local Pipelines = require("src.render.Pipelines") +local FX = require("src.render.GBCFX") +local checks, failed, phase, capturing = 0, false, 1, false +local pictures = {} +local function check(ok, message) + checks = checks + 1 + assert(ok, message) +end + +local function canvasOf(data, dpi) + local image = love.graphics.newImage(data) + image:setFilter("nearest", "nearest") + local target = love.graphics.newCanvas(data:getWidth(), data:getHeight(), { dpiscale = dpi or 1 }) + love.graphics.push("all") + love.graphics.setCanvas(target) + love.graphics.setBlendMode("replace", "premultiplied") + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(image) + love.graphics.pop() + image:release() + return target +end + +local function interpolate(r, g, b, mode) + local p, base, f = { r * 31, g * 31, b * 31 }, {}, {} + for i = 1, 3 do base[i] = math.floor(p[i]); f[i] = p[i] - base[i] end + local out = { 0, 0, 0 } + for dz = 0, 1 do for dy = 0, 1 do for dx = 0, 1 do + local value = { C.convert5(math.min(31, base[1] + dx), + math.min(31, base[2] + dy), math.min(31, base[3] + dz), mode) } + local weight = (dx == 0 and 1 - f[1] or f[1]) + * (dy == 0 and 1 - f[2] or f[2]) * (dz == 0 and 1 - f[3] or f[3]) + for i = 1, 3 do out[i] = out[i] + value[i] * weight / 255 end + end end end + return out +end + +local function near(actual, expected, tolerance) + return math.abs(actual - expected) <= tolerance +end + +local function run() + for _, gles in ipairs({ false, true }) do + local ok, reason = love.graphics.validateShader(gles, C.SHADER_SRC) + check(ok, "shader validation: " .. tostring(reason)) + end + Version.set("gold") + Palette.setMode("gbc") + local source = love.image.newImageData(256, 128) + for n = 0, 32767 do + local r, g, b = n % 32, math.floor(n / 32) % 32, math.floor(n / 1024) + source:setPixel(n % 256, math.floor(n / 256), + (r * 8 + math.floor(r / 4)) / 255, + (g * 8 + math.floor(g / 4)) / 255, + (b * 8 + math.floor(b / 4)) / 255, 1) + end + local input = canvasOf(source) + source:release() + C.setMode("off") + check(C.apply(input) == input, "OFF must preserve the exact canvas") + for _, mode in ipairs({ "accurate", "harsh" }) do + C.setMode(mode) + local corrected = C.apply(input) + check(corrected ~= input, mode .. " did not run on the GPU") + local pixels = corrected:newImageData() + local maxError = 0 + for n = 0, 32767 do + local expected = { C.convert5(n % 32, math.floor(n / 32) % 32, math.floor(n / 1024), mode) } + local actual = { pixels:getPixel(n % 256, math.floor(n / 256)) } + for i = 1, 3 do maxError = math.max(maxError, math.abs(actual[i] * 255 - expected[i])) end + end + pixels:release() + check(maxError < 1.01, mode .. " RGB555 GPU error exceeds one RGB888 unit: " .. maxError) + print(mode .. " GPU gamut max error: " .. maxError) + end + input:release() + + local gradient = love.image.newImageData(32, 1) + for i = 0, 31 do gradient:setPixel(i, 0, (80 + i) / 255, 63 / 255, 143 / 255, 128 / 255) end + input = canvasOf(gradient) + gradient:release() + C.setMode("accurate") + local previous = love.graphics.newCanvas(4, 4) + local oldShader = love.graphics.newShader("vec4 effect(vec4 c, Image t, vec2 u, vec2 p) { return Texel(t,u)*c; }") + love.graphics.setCanvas(previous) + love.graphics.setShader(oldShader) + love.graphics.setColor(0.2, 0.3, 0.4, 0.5) + love.graphics.setScissor(1, 2, 3, 4) + love.graphics.setBlendMode("add", "premultiplied") + love.graphics.setColorMask(false, true, false, true) + love.graphics.translate(7, 9) + local corrected = C.apply(input) + check(love.graphics.getCanvas() == previous, "caller canvas was not restored") + check(love.graphics.getShader() == oldShader, "caller shader was not restored") + check(near(select(1, love.graphics.getColor()), 0.2, 1e-6), "caller colour was not restored") + check(love.graphics.getScissor() == 1, "caller scissor was not restored") + check(love.graphics.getBlendMode() == "add", "caller blend mode was not restored") + check(love.graphics.getColorMask() == false, "caller colour mask was not restored") + local x, y = love.graphics.transformPoint(0, 0) + check(x == 7 and y == 9, "caller transform was not restored") + love.graphics.setCanvas() + love.graphics.setShader() + love.graphics.setScissor() + love.graphics.setBlendMode("alpha") + love.graphics.setColorMask(true, true, true, true) + love.graphics.origin() + love.graphics.setColor(1, 1, 1, 1) + local pixels = corrected:newImageData() + local first, last + for i = 0, 31 do + local r, g, b, a = pixels:getPixel(i, 0) + local expected = interpolate((80 + i) / 255, 63 / 255, 143 / 255, "accurate") + check(near(r, expected[1], 1.01 / 255) and near(g, expected[2], 1.01 / 255) + and near(b, expected[3], 1.01 / 255), "fractional RGB interpolation mismatch") + check(near(a, 128 / 255, 0.01 / 255), "alpha changed") + first, last = first or r, r + end + check(last > first, "true-colour gradient collapsed") + pixels:release() + input:release() + oldShader:release() + previous:release() + for _, size in ipairs({ { 4, 4, 2 }, { 9, 7, 1 } }) do + input = love.graphics.newCanvas(size[1], size[2], { dpiscale = size[3] }) + corrected = C.apply(input) + check(corrected:getWidth() == size[1] and corrected:getHeight() == size[2], "resize lost dimensions") + check(corrected:getDPIScale() == size[3], "DPI scale changed") + input:release() + end + + input = love.graphics.newCanvas(7, 3) + local draw = love.graphics.draw + love.graphics.draw = function() error("injected GPU draw failure") end + check(C.apply(input) == input, "draw failure did not fall back to the source") + love.graphics.draw = draw + check(love.graphics.getCanvas() == nil and love.graphics.getShader() == nil, + "draw failure leaked render state") + check(not C.active(), "failed target should not retry every frame") + C.setMode("accurate") + check(C.active(), "changing the setting must allow a target retry") + input:release() + input = love.graphics.newCanvas(7, 5) + local allocate = love.graphics.newCanvas + love.graphics.newCanvas = function() error("injected allocation failure") end + check(C.apply(input) == input, "allocation failure did not preserve the source") + love.graphics.newCanvas = allocate + check(not C.active(), "failed allocation should not retry every frame") + input:release() + C.setMode("accurate") + + -- Immutable copies for the synthetic, ROM-free visual comparison. + for _, mode in ipairs({ "off", "accurate", "harsh" }) do + source = love.image.newImageData(160, 144) + for yy = 0, 143 do for xx = 0, 159 do + local r, g, b + if yy < 72 then + r, g, b = xx / 159, yy / 71, 0.65 + else + local shade = math.floor(xx / 20) / 7 + r, g, b = 0.35 * shade, 0.25 * shade, 0.7 * shade + end + source:setPixel(xx, yy, r, g, b, 1) + end end + input = canvasOf(source) + source:release() + C.setMode(mode) + pixels = C.apply(input):newImageData() + pictures[mode] = love.graphics.newImage(pixels) + pictures[mode]:setFilter("nearest", "nearest") + pixels:release() + input:release() + end + C.setMode("off") + Renderer:init() +end + +function love.load() + local ok, err = xpcall(run, debug.traceback) + if not ok then failed = true; io.stderr:write(err .. "\n"); love.event.quit(1) end +end + +-- Exercise the real endFrame path, not an isolated shader canvas: correction +-- alone, with LCD FX, and the original OFF path on separate rendered frames. +function love.draw() + if failed or capturing then return end + if phase <= 3 then + local ok, err = xpcall(function() + local trace = {} + local oldApply, oldPresent, oldFX = C.apply, Pipelines.present, FX.present + C.apply = function(canvas) trace[#trace + 1] = "correction"; return oldApply(canvas) end + Pipelines.present = function(canvas, ctx) trace[#trace + 1] = "pipeline"; return oldPresent(canvas, ctx) end + FX.present = function(canvas, scale) trace[#trace + 1] = "lcd"; return oldFX(canvas, scale) end + C.setMode(phase < 3 and "accurate" or "off") + FX.setLevel(phase == 2 and 4 or 0) + Renderer:beginFrame() + love.graphics.clear(0.35, 0.2, 0.7, 1) + Renderer:endFrame() + C.apply, Pipelines.present, FX.present = oldApply, oldPresent, oldFX + local expected = phase == 1 and "correction,pipeline" + or (phase == 2 and "correction,pipeline,lcd" or "") + check(table.concat(trace, ",") == expected, "native pass order differs: " .. table.concat(trace, ",")) + end, debug.traceback) + if not ok then failed = true; io.stderr:write(err .. "\n"); love.event.quit(1); return end + local renderedPhase = phase + capturing = true + love.graphics.captureScreenshot(function(pixels) + local verified, verifyError = xpcall(function() + local r, g, b, a = pixels:getPixel(320, 288) + check(near(a, 1, 1 / 255), "native frame alpha changed") + if renderedPhase == 2 then + check(math.max(r, g, b) > 0.02, "LCD combination presented a black frame") + else + local expected = renderedPhase == 1 and interpolate(0.35, 0.2, 0.7, "accurate") + or { 0.35, 0.2, 0.7 } + check(near(r, expected[1], 2.01 / 255) and near(g, expected[2], 2.01 / 255) + and near(b, expected[3], 2.01 / 255), "native screen pixels do not match the selected profile") + end + end, debug.traceback) + if not verified then failed = true; io.stderr:write(verifyError .. "\n"); love.event.quit(1) end + phase, capturing = phase + 1, false + end) + return + end + love.graphics.clear(0.05, 0.05, 0.05, 1) + love.graphics.setColor(1, 1, 1, 1) + for i, mode in ipairs({ "off", "accurate", "harsh" }) do + love.graphics.print(mode, 10 + (i - 1) * 210, 20) + love.graphics.draw(pictures[mode], 10 + (i - 1) * 210, 50, 0, 1.25, 1.25) + end + print(checks .. " GPU checks passed (synthetic data)") + capturing = true + local path = os.getenv("GBC_COLOR_CHECK_SHOT") + if path then + love.graphics.captureScreenshot(function(data) + local encoded = data:encode("png") + local file = assert(io.open(path, "wb")) + file:write(encoded:getString()); file:close() + love.event.quit(0) + end) + else love.event.quit(0) end +end From 644aa8364b83438edc98e652cb1e5cf04e1bf1ff Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:07:49 +0200 Subject: [PATCH 002/138] Add version-aware capability queries to the mod API --- docs/modding.md | 50 +++++++ src/mods/Capabilities.lua | 108 +++++++++++++++ src/mods/Loader.lua | 6 + tests/engine/mod_supports.lua | 248 ++++++++++++++++++++++++++++++++++ 4 files changed, 412 insertions(+) create mode 100644 src/mods/Capabilities.lua create mode 100644 tests/engine/mod_supports.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..39892ef6a 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -18,6 +18,56 @@ Regenerate the reference straight into a wiki checkout: luajit tools/gen_registry_docs.lua ../gen1recomp.wiki ``` +## Feature queries (`mod:supports`) + +`mod:supports(surface, name)` returns `true` when the selected host exposes an +existing API-2 capability, or `false, reason` when it does not. Unknown names, +unknown surfaces and invalid names return `false` with an explanation. Check +for the method itself when supporting older engine builds: + +```lua +if mod.supports and mod:supports("hooks", "battle.damage") then + mod.hooks:wrap("battle.damage", function(next, ctx) + return next(ctx) + end) +end + +local canFly = mod.supports and mod:supports("world", "flyTo") -- false here +``` + +The four surfaces are: + +| Surface | Meaning of `true` | +| --- | --- | +| `content` | An API-2 registry exists, e.g. `pokemon` or `render_pipelines`. | +| `world` | A public `mod.world` method exists, e.g. `warpTo` or `terrainAt`. | +| `hooks` | The engine has a native hook call site for this version. | +| `events` | The engine has a native event call site for this version, or the name is a non-empty `mod..` namespace. | + +Queries work in the entry chunk without an active overworld, save, graphics +context or imported ROM. They do not subscribe, wrap hooks, load the Game or +materialize `mod.world`. Answers are bound to the host selected when that mod +API was built; changing the mod's copy of `gameVersion` or `host` cannot change +them. Availability does not imply the current game state permits the action: +`warpTo` can still return `nil, "no overworld"`, and elevation readers can +return `nil, "map carries no elevation"`. + +Callback availability follows Gen2Recomped's actual implementation, rather +than Gen1Recomp's newer catalog. For example, `core.logic_speed`, `item.use` +and `egg.hatched` have no native call sites here; the hatch event here is +`pokemon.egg_hatched` (Gen 2–5). `battle.status_hud_visible` is available on +Gen 4–5, `battle.overlay` on Gen 1–3, and the normal start-menu, party-submenu +and naming-grid hooks on Gen 1–3. `ui.pc.items` is available on Gen 1–2. +Oak-speech callbacks apply to Gen 1–2 except Prism; Birch-speech events apply +to Emerald. Other versions use their own intros. An event still needs its +trigger to occur; custom events also need another mod to emit them. + +This is additive to Mod API 2: it neither normalizes content records, IDs or +callback payloads across generations nor provides Gen1Recomp's unified API 3. +Existing registration/subscription behavior remains unchanged. When adding a +native callback or changing screen routing, update `src/mods/Capabilities.lua` +and the capability tests in the same change. + ## Bulk storage (`mod.storage`) `mod.save` is the right home for a handful of per-playthrough values: it lives diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua new file mode 100644 index 000000000..32da142d7 --- /dev/null +++ b/src/mods/Capabilities.lua @@ -0,0 +1,108 @@ +-- Availability of the existing API-2 surfaces, before a Game/overworld exists. +-- Keep callback names tied to native call sites, not to the permissive buses: +-- successfully subscribing to a name does not mean the engine ever fires it. +local GameVersion = require("src.core.GameVersion") +local Schemas = require("src.mods.Schemas") + +local Capabilities = {} +local callbacks = { hooks = {}, events = {} } +local function add(surface, generations, names) + for name in names:gmatch("%S+") do + callbacks[surface][name] = generations + end +end + +-- Shared simulation, world, input, rendering and lifecycle code. A callback +-- need not fire on every action (e.g. an always-hit move skips accuracy). +add("hooks", "12345", [[ + battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action + battle.exp_award battle.low_health_alarm battle.run battle.turn_order catch.rate + encounter.fishing encounter.roll encounter.species evolution.check exp.gain + fieldmove.eligibility input.step link.fingerprint map.palette movement.collision + movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite + render.compose render.hud render.letterbox render.zones save.new_game save.write + script.command trainer.party transition.style ui.list_menu ui.options.rows + ui.title_menu.items warp.destination world.tod zoom.range +]]) +-- Screens have distinct implementations: Screens.lua aliases the DS party, +-- naming and start menus; those implementations do not call the GB/GBA hooks. +-- OverworldController:openPC bypasses ui.pc.items on Gen 3/4 (and Gen 5 uses +-- its own script PC). Gen4Battle paints its HUD through a separate seam and +-- does not dispatch battle.overlay, unlike BattleState/Gen3Battle/WideBattle. +add("hooks", "123", "battle.overlay ui.naming.grid ui.party.submenu ui.start_menu.items") +add("hooks", "12", "ui.pc.items") +add("hooks", "45", "battle.status_hud_visible") +add("hooks", "12", "intro.oak_speech.build") + +add("events", "12345", [[ + assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt + battle.ended battle.exp_gained battle.fainted battle.move_used battle.started + battle.status_inflicted battle.turn_ended battle.turn_started discord.join_requested + flag.changed game.ready link.connected link.desync link.ended map.entered map.exited + map.reloaded mod.option_action mod.options_changed mods.loaded music.started + music.stopped player.warped pokemon.before_give pokemon.caught pokemon.evolved + pokemon.level_up pokemon.move_learned pokemon.received save.created save.loaded + save.loading save.writing screen.popped screen.pushed script.ended script.started + sound.played trade.completed world.blacked_out world.block_replaced + world.blocks_replaced world.boulder_moved world.interacted world.npc_spawned + world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged +]]) +add("events", "2345", "pokemon.egg_hatched") +add("events", "12", [[ + intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started + intro.oak_speech.step +]]) +add("events", "3", "intro.birch_speech.started intro.birch_speech.finished") + +-- Data:applyDefaults selects BirchSpeech on Emerald, Gen3OakSpeechFRLG on +-- FireRed, and no OakSpeech on Prism. DS imports select their own intros. +local function nativeVersion(name, version) + if name:match("^intro%.birch_speech%.") then return version == "emerald" end + if name:match("^intro%.oak_speech%.") then return version ~= "prism" end + return true +end + +-- Public WorldAPI methods only; constructors, internal helpers and methods +-- on returned NPC handles are not mod.world methods. These remain callable +-- at the title screen, returning their normal "no overworld"/"no save" error. +-- Elevation readers can return "map carries no elevation" on any dataset. +local world = {} +for name in ([[ + overworld current warpTo toggleObject setFlag getFlag replaceBlock spawnNpc + removeNpc loadedMaps elevationInfo elevationAt terrainAt elevationRanks heightAt + elevationGrid npc queueScript invalidateMap +]]):gmatch("%S+") do world[name] = true end + +function Capabilities.supports(surface, name, version) + if surface ~= "content" and surface ~= "world" + and surface ~= "hooks" and surface ~= "events" then + return false, "unsupported capability surface: " .. tostring(surface) + end + if type(name) ~= "string" or name == "" then + return false, "capability name must be a non-empty string" + end + local info = GameVersion.VERSIONS[version] + if not info then return false, "unknown game version: " .. tostring(version) end + if surface == "content" then + -- Every loader builds the same API-2 registries from Schemas. This says + -- the registration API exists, not that record shapes/IDs are portable. + if Schemas.REGISTRIES[name] then return true end + elseif surface == "world" then + if world[name] then return true end + else + -- A mod may listen to another mod's namespace without that mod installed; + -- this only promises the bus supports it, never that a producer is present. + if surface == "events" and name:match("^mod%.[%w_%-]+%..+") then return true end + local gens = callbacks[surface][name] + if gens then + if gens:find(tostring(info.generation), 1, true) and nativeVersion(name, version) then + return true + end + return false, ("%s capability %s has no native call site in %s") + :format(surface, name, version) + end + end + return false, ("unknown %s capability: %s"):format(surface, name) +end + +return Capabilities diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..536e2de97 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -16,6 +16,7 @@ local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") local Registry = require("src.mods.Registry") local Schemas = require("src.mods.Schemas") +local Capabilities = require("src.mods.Capabilities") local Semver = require("src.mods.Semver") local Events = require("src.mods.Events") local Hooks = require("src.mods.Hooks") @@ -1119,6 +1120,11 @@ function Loader:_api(mod) end return data end) + -- Capture the host selected for this API, as with generation/gameVersion. + -- Probing must not instantiate mod.world/mod.game or register callbacks. + function api:supports(surface, name) + return Capabilities.supports(surface, name, hostId) + end -- mod.world / mod.game / mod.storage all materialize on first touch, for -- the same reason: a headless load must not drag the world stack in, and -- the Game the facade acts on is still being wired when the entry chunk diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua new file mode 100644 index 000000000..7e3bf96f5 --- /dev/null +++ b/tests/engine/mod_supports.lua @@ -0,0 +1,248 @@ +-- API availability is observable through the real loader, before Game/world +-- construction. No imported content, graphics, or network is required. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.harness') +local GameVersion = require('src.core.GameVersion') +local Loader = require('src.mods.Loader') +local Runtime = require('src.mods.Runtime') +local Schemas = require('src.mods.Schemas') +local Catalog = require('tests.modkit.catalog') +local originalEvents, originalHooks, originalErrors = Runtime.events, Runtime.hooks, Runtime.errors +local originalVersion = GameVersion.get() +local savedLove = love +love = nil + +local files = { + ['mods/probe/manifest.json'] = [[{"id":"probe","name":"Probe","version":"1.0.0","api":2,"entry":"main.lua"}]], + ['mods/probe/main.lua'] = [[ +return function(mod) + -- Runs inside the entry window, not after the loader has finished. + mod.exports.early = mod:supports('world', 'warpTo') + mod.exports.api = mod +end +]], +} +local fs = { + read = function(path) return files[path] end, + getDirectoryItems = function(path) + if path == 'mods' then return { 'probe' } end + return {} + end, + getInfo = function(path) + if path == 'mods' or path == 'mods/probe' then return { type = 'directory' } end + if files[path] then return { type = 'file' } end + end, + load = function(path) return loadstring(files[path], path) end, +} +local function probe(version) + GameVersion.set(version) + local data = { pokemon = {} } + local loader = Loader.new({ fs = fs, dev = false }) + loader._game = function() error('supports must not construct Game') end + assert(loader:load(data)) + assert(loader.apis.probe, tostring(loader.errors.probe)) + return loader.apis.probe, loader, data +end +local function yes(api, surface, name) + local ok, reason = api:supports(surface, name) + return ok == true and reason == nil +end +local function no(api, surface, name) + local ok, reason = api:supports(surface, name) + return ok == false and type(reason) == 'string' and #reason > 0 +end + +-- Builtin registrants can allocate fresh closures on each load. Compare +-- record structure and values while treating function identity as opaque, +-- as the registry parity gate does. +local function record(value) + if type(value) == 'function' then return '' end + if type(value) ~= 'table' then return value end + local out = {}; for k, v in pairs(value) do out[k] = record(v) end; return out +end + +local allHooks, allEvents = {}, {} +for _, version in ipairs(GameVersion.ORDER) do + local api, loader, data = probe(version) + T.check(api.exports.early, version .. ': query succeeds inside entry chunk') + T.check(yes(api, 'hooks', 'battle.damage') and yes(api, 'events', 'world.stepped'), + version .. ': shared simulation/world call sites available') + local registries = true + for name in pairs(Schemas.REGISTRIES) do + registries = registries and yes(api, 'content', name) + end + T.check(registries, version .. ': all API-2 registration surfaces available') + T.check(no(api, 'world', 'flyTo') and no(api, 'hooks', 'core.logic_speed') + and no(api, 'hooks', 'item.use') and no(api, 'events', 'egg.hatched'), + version .. ': absent Gen1Recomp API-3 features are not advertised') + T.check(yes(api, 'events', 'mod.probe.ready') and yes(api, 'events', 'mod.other.ready'), + version .. ': namespaced user events do not need a producer installed') + for _, name in ipairs(Catalog.hooks()) do + if yes(api, 'hooks', name) then allHooks[name] = true end + end + for _, name in ipairs(Catalog.events()) do + if yes(api, 'events', name) then allEvents[name] = true end + end + -- Mutation of the handed-out metadata cannot change a capability answer. + local statusHud = api:supports('hooks', 'battle.status_hud_visible') + api.gameVersion, api.generation, api.host.version = 'red', 1, 'red' + GameVersion.set('red') + T.eq(api:supports('hooks', 'battle.status_hud_visible'), statusHud, + version .. ': query retains captured host even after metadata/global changes') + T.check(next(loader.events.listeners) == nil and next(loader.hooks.chains) == nil, + version .. ': probing installs no listeners or wrappers') + local pristine = { pokemon = {} } + GameVersion.set(version) + local vanilla = Loader.new({ fs = fs, noMods = true, dev = false }) + assert(vanilla:load(pristine)) + T.same(record(data), record(pristine), version .. ': querying mod leaves merged data equal to no-mod boot') +end + +-- The union covers every existing native callback; stale catalog entries +-- also fail. This gate reads source rather than a second hand-kept name list. +T.same(allHooks, (function() + local out = {}; for _, name in ipairs(Catalog.hooks()) do out[name] = true end; return out +end)(), 'all native hook names have a queryable implementation') +T.same(allEvents, (function() + local out = {}; for _, name in ipairs(Catalog.events()) do out[name] = true end; return out +end)(), 'all native event names have a queryable implementation') + +-- Also reject advertised names whose native call site was removed. Read +-- the simple declaration lists without exporting mutable production state. +local h = assert(io.open('src/mods/Capabilities.lua', 'r')) +local body = h:read('*a'); h:close() +local declared = { hooks = {}, events = {} } +for surface, list in body:gmatch('add%("(%w+)", "%d+", ([^%)]+)%)') do + local names = list:match('^%[%[(.-)%]%]$') or list:match('^"(.-)"$') + assert(names, 'unrecognized capability declaration') + for name in names:gmatch('%S+') do declared[surface][name] = true end +end +T.same(declared.hooks, allHooks, 'advertised hook catalog has no stale call sites') +T.same(declared.events, allEvents, 'advertised event catalog has no stale call sites') + +local api, loader = probe('crystal') +local noWorldBefore = package.loaded['src.world.WorldAPI'] +T.check(yes(api, 'world', 'warpTo') and yes(api, 'world', 'heightAt'), + 'world methods available independently of save/map state') +T.eq(package.loaded['src.world.WorldAPI'], noWorldBefore, 'queries never require WorldAPI') +for _, surface in ipairs({ 'content', 'world', 'hooks', 'events' }) do + for _, invalid in ipairs({ '', 1, false, {}, function() end }) do + T.check(no(api, surface, invalid), surface .. ': malformed name declines with reason') + end + T.check(no(api, surface, nil), surface .. ': missing name declines with reason') + T.check(no(api, surface, 'does.not.exist'), surface .. ': unknown name declines with reason') +end +for _, surface in ipairs({ '', 'event', 'hook', 'storage', 1, false, {} }) do + T.check(no(api, surface, 'anything'), 'unknown/malformed surface declines with reason') +end +T.check(no(api, nil, 'anything'), 'missing surface declines with reason') +T.check(no(api, 'events', 'mod.probe.') and no(api, 'events', 'mod..ready'), + 'empty user-event namespace/name is unavailable') +T.check(no(api, 'world', 'new') and no(api, 'world', '_mapNamed') + and no(api, 'world', 'face') and no(api, 'world', '__index'), + 'constructors, internals and NPC-handle methods are not world capabilities') +T.check(no(api, 'content', 'Pokemon') and no(api, 'hooks', 'BATTLE.DAMAGE'), + 'names are case-sensitive') + +-- Verify world catalog against the real public method table, not just names +-- duplicated in a test. A supported method may still decline without a map. +love = savedLove or require('tests.love_stub') +local WorldAPI = require('src.world.WorldAPI') +for name, method in pairs(WorldAPI) do + if type(method) == 'function' and name ~= 'new' and name:sub(1, 1) ~= '_' then + T.check(yes(api, 'world', name), 'public WorldAPI method query: ' .. name) + end +end +local worldNames = assert(body:match('for name in %(%[%[(.-)%]%]%):gmatch')) +for name in worldNames:gmatch('%S+') do + T.eq(type(WorldAPI[name]), 'function', 'advertised world method exists: ' .. name) +end +local world = WorldAPI.new({}, 'probe') +local result, reason = world:warpTo('missing', 0, 0) +T.check(result == nil and reason == 'no overworld' and yes(api, 'world', 'warpTo'), + 'availability is distinct from current-state eligibility') + +-- Version-specific routing is deliberately different from Gen1Recomp. +for _, version in ipairs(GameVersion.ORDER) do + local mod = probe(version) + local gen = GameVersion.generation(version) + T.eq(mod:supports('hooks', 'battle.status_hud_visible'), gen >= 4, + version .. ': DS-only HUD seam') + T.eq(mod:supports('hooks', 'battle.overlay'), gen <= 3, + version .. ': GB/GBA-only overlay seam') + T.eq(mod:supports('hooks', 'ui.party.submenu'), gen <= 3, + version .. ': native party-menu seam') + T.eq(mod:supports('hooks', 'ui.pc.items'), gen <= 2, + version .. ': native PC seam') + T.eq(mod:supports('events', 'intro.birch_speech.started'), version == 'emerald', + version .. ': Emerald-only Birch intro') + T.eq(mod:supports('hooks', 'intro.oak_speech.build'), gen <= 2 and version ~= 'prism', + version .. ': Oak route excludes Prism and newer intros') +end + +-- A content query gates an actual registration through the entry API. +local entry = files['mods/probe/main.lua'] +files['mods/probe/main.lua'] = [[ +return function(mod) + if mod:supports('content', 'strings') then + mod.content.strings:register('QUERY_TEXT', 'Query succeeded') + end + if mod:supports('content', 'not_a_registry') then + error('unavailable registration must be skipped') + end +end +]] +local _, contentLoader, contentData = probe('crystal') +T.eq(contentData.strings.QUERY_TEXT, 'Query succeeded', 'supported registry accepts real registration') +T.eq(#contentLoader.errors, 0, 'unavailable content fallback loads without errors') +files['mods/probe/main.lua'] = entry + +-- A generation-specific query gates the real DS HUD call site. Returning +-- false avoids drawing and lets this run without a graphics context. +local Gen4Battle = require('src.battle.Gen4Battle') +for _, version in ipairs({ 'platinum', 'black' }) do + local mod = probe(version) + local calls = 0 + if mod:supports('hooks', 'battle.status_hud_visible') then + mod.hooks:wrap('battle.status_hud_visible', function() + calls = calls + 1 + return false + end) + end + T.eq(Gen4Battle.drawHealthboxes({}), true, version .. ': supported HUD hook suppresses native boxes') + T.eq(calls, 1, version .. ': native HUD call reaches mod once') +end + +-- A successful probe gates an actual native call: world.timeOfDay dispatches +-- the hook and resulting event. Queries themselves have no bus side effects. +api, loader = probe('crystal') +local calls, changes = 0, 0 +if api:supports('hooks', 'world.tod') then + api.hooks:wrap('world.tod', function(next, tod, ctx) + calls = calls + 1 + return 'TEST_NIGHT' + end) +end +if api:supports('events', 'world.tod_changed') then + api.events:on('world.tod_changed', function(payload) + changes = changes + 1 + T.eq(payload.tod, 'TEST_NIGHT', 'native event carries hook result') + end) +end +local Overworld = require('src.world.OverworldController') +local ow = setmetatable({ tod = 'DAY', map = { id = 'PROBE' }, player = {} }, { __index = Overworld }) +ow.syncGen2Tod = function() end +T.eq(ow:timeOfDay(), 'TEST_NIGHT', 'queried native hook executes through actual world method') +T.check(calls == 1 and changes == 1, 'native hook/event each dispatched once') +-- Unsubscribe and use the same native path: original time-of-day behavior. +loader.events:removeOwner('probe') +loader.hooks:removeOwner('probe') +GameVersion.set('red') +ow.tod = 'DAY' +T.eq(ow:timeOfDay(), 'DAY', 'unmodified native path retains vanilla result') +T.check(calls == 1 and changes == 1, 'no-mod path invokes no mod callbacks') + +GameVersion.set(originalVersion) +love = savedLove +Runtime.install(originalEvents, originalHooks, originalErrors) +T.finish('mod supports') From 044ff9205ef332b9d54b3ce7dc2ebad7e5312bb8 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:39:17 +0200 Subject: [PATCH 003/138] Add opt-in unified mod content records across generations --- docs/modding.md | 7 + docs/unified-mod-content.md | 164 ++++++++++ src/mods/Loader.lua | 14 + src/mods/Manifest.lua | 5 + src/mods/Registry.lua | 23 +- src/mods/Schemas.lua | 5 +- src/mods/UnifiedContent.lua | 518 +++++++++++++++++++++++++++++++ src/mods/UnifiedIds.lua | 85 +++++ src/mods/UnifiedSchemas.lua | 60 ++++ tests/engine/unified_content.lua | 470 ++++++++++++++++++++++++++++ 10 files changed, 1345 insertions(+), 6 deletions(-) create mode 100644 docs/unified-mod-content.md create mode 100644 src/mods/UnifiedContent.lua create mode 100644 src/mods/UnifiedIds.lua create mode 100644 src/mods/UnifiedSchemas.lua create mode 100644 tests/engine/unified_content.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..2ea5c7f7e 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -277,3 +277,10 @@ The launcher hands the link to the operating system to open, so only `http://` and `https://` links are accepted. Any other value, such as a `file:` link or a custom scheme, is dropped with a warning. The mod itself still loads; it just gets no button. + +## Unified content records (opt-in) + +API-2 mods can select `"content_format": "unified"` to read and edit common +Pokémon, move, item, trainer and encounter definition shapes. Existing mods +keep native formats. See [the format, supported operations and limitations](unified-mod-content.md) +for the manifest guard, generation coverage, symbolic IDs and failure behavior. diff --git a/docs/unified-mod-content.md b/docs/unified-mod-content.md new file mode 100644 index 000000000..64d83da74 --- /dev/null +++ b/docs/unified-mod-content.md @@ -0,0 +1,164 @@ +# Unified definition records (opt-in) + +A mod can use the same core content records when modifying different game +versions. Enable the format explicitly in an API-2 manifest: + +```json +{ + "id": "portable_balance", + "name": "Portable Balance", + "version": "1.0.0", + "api": 2, + "content_format": "unified", + "generations": [1, 2, 3, 4, 5], + "entry": "main.lua" +} +``` + +```lua +return function(mod) + -- Required when distributing to older engine builds: those builds do not + -- understand content_format and would otherwise supply native records. + assert(mod.contentFormat == "unified", "This mod needs unified content support") + if mod.content.moves:has("TACKLE") then + local ok, reason = mod.content.moves:patch("TACKLE", { power = 45 }) + assert(ok, reason) + end +end +``` + +Omitting the field (or using `"native"`) preserves existing mod behavior. +API 1 cannot opt in. Mod API stays at **2**: this is the content-record part of +portability, not Gen1Recomp's complete API 3. Callback payloads, saved Pokémon, +world/battle services, scripts and other registries retain their native forms. +A mod still needs testing on every game it claims. + +## Available records + +The five handles retain `register`, `override`, `patch`, `remove`, `get` and +`each`, and add `has`, `supports()` and `available`. Other content handles pass +through unchanged. `mod.assets` retains its existing content-handle aliases. +On engines that provide `mod:supports`, content queries follow the selected +format's availability; other capability surfaces retain their existing answers. + +| Handle | Unified fields | Supported versions | +| --- | --- | --- | +| `pokemon` | `id`, `name`, `dex`, `types`, `baseStats`, `catchRate`, `baseExp`, `growthRate`, `level1Moves`, `learnset`, `evolutions`, `tmhm`; optional breeding/art fields | Existing definitions, Gen 1–5 | +| `moves` | `id`, `name`, `type`, `power`, `accuracy`, `pp`, `effect`, `category`, `priority`, `description` | Existing definitions, Gen 1–5 | +| `items` | `id`, `name`, `price`, `machine`, `effect`, `ball`, `tossable`, `needsTarget`, `description` | Existing definitions, Gen 1–5 | +| `trainers` | `id`, `name`, `pic`, `baseMoney`, `battleTheme`, `partyNames`, `parties` | Existing definitions, Gen 1–5 | +| `encounters` | `id`, `grass`, `water` | Gen 1–3 grass/water tables | + +Reads and iteration return detached records. Editing a returned table does +nothing until a write method is called. Each record also contains a detached, +**read-only** `native` snapshot for diagnostics. Unexposed native fields are +preserved by edits; changing that snapshot is refused. `override` requires a +complete unified record and preserves omitted optional/native extension fields; +use `mod.DELETE` to clear a represented optional field. For normal +edits use `patch`; this keeps earlier and later mods' independent fields in the +registry's normal operation order. Writes return `true` on success. + +`register` supports string-key definitions on Gen 1–3, except new Gen 3 species: +their EV/ability/storage metadata has no canonical constructor here. Numeric +DS allocations are refused. This layer does not invent storage slots or +metadata. Use native content for those additions. Existing records can be +patched, overridden and removed across all five generations. + +### IDs and references + +Species, move and item IDs are uppercase symbolic strings, such as `PIKACHU`, +`TACKLE` and `POTION`. Resolution uses the selected dataset's native keys and +names, not fixed cartridge numbers. `FARFETCHD`, `MR_MIME`, `NIDORAN_F` and +`NIDORAN_M` retain distinct names. `TM01`/`HM01` names normalize to +`TM_01`/`HM_01`. Types use Gen 1 spellings, including `PSYCHIC_TYPE`; the native GBA abbreviations `PSYCHC`, +`FIGHT` and `ELECTR` map to their common names. + +A numeric table or a generated key such as `SPECIES_025` is mapped through its +name in that ROM. Renaming a definition through a mod does not change its ID +within that load. Numeric/string aliases of one record are read once and +written or removed together. Multiple distinct records with one canonical +name are ambiguous: the adapter refuses to choose one. Missing or ambiguous +`get` returns `nil, reason`; `has` returns false; ambiguous iteration raises. + +Trainer and encounter IDs remain game-specific; no cross-game equivalence +between a trainer class, a numbered individual or two maps is implied. +Numeric trainer IDs are exposed as decimal strings. References must resolve +when a write is made: define a referenced record first or declare a mod +dependency that provides it. Missing references return `nil, reason` before +an operation is appended. Removing a referenced definition retains the +loader's post-merge, owner-attributed reference diagnostics, including DS +numeric targets and Gen 2 time tables. + +### Pokémon and trainers + +`baseStats` uses `hp`, `attack`, `defense`, `speed`, `special`, with optional +`specialAttack` and `specialDefense` for the later split. A patch to `special` +sets both halves on Gen 2–5; explicit split fields can change them separately. +Conflicting values are rejected. Gen 1 refuses unequal Special stats. + +`level1Moves` is a list of move IDs. `learnset` is +`{ { level = 9, move = "TACKLE" } }`. Evolutions use +`{ method = "LEVEL", level = 20, species = "RAICHU" }`, or `ITEM`, `TRADE` +and `FRIENDSHIP` with `item`, `time` (`ANYTIME`, `MORNDAY`, `NITE`) or +`comparison` (`ATK_GT_DEF`, `ATK_LT_DEF`, `ATK_EQ_DEF`) where representable. +Held trade items and friendship/stat conditions are translated for Gen 2–3. +DS level evolutions are translated to their numeric target and parameter. +Other DS evolution writes and unknown method changes are refused; unchanged +native methods survive a detached read/modify/write. Gen 1 refuses eggs, +breeding fields, held items and later evolution conditions. + +Trainer `parties` always has the form +`{ { { species = "PIKACHU", level = 18, item = "POTION", moves = { "TACKLE" } } } }`. +Items and moves are optional, and held items require Gen 2 or later. Native +per-slot IVs, flags and AI metadata are retained. A DS trainer's single `party` +and `parties[1]` aliases stay synchronized. + +Move accuracy is percent, as in the engine's existing normalized definitions. +Native effect selectors and item behavior remain opaque: changing a move's +effect on Gen 2–5, changing later item behavior, or changing a DS move type +without a native type adapter returns `nil, reason`. This avoids changing a +symbolic field while leaving an incompatible native selector behind. Power, +accuracy, PP, category, price and other represented fields can be edited. + +### Encounters + +An area is `{ rate, slots = { { species = "PIKACHU", level = 5 } } }`. +A range uses `minLevel` and `maxLevel` instead of `level`. Rates are fractions +out of **256**, including fractional values needed to represent GBA rates +exactly. Conversion refuses a rate that cannot be represented in native units; +it never silently rounds. Slot edits retain the native count and probability +buckets. Changing slot counts or weights requires native content. + +Gen 2 also exposes `grass.times.MORN`, `DAY` and `NITE` in the same area shape. +A shared rate/slot patch applies to every native time. An explicitly changed +time can override that patch. Unchanged inherited times in a detached record +do not undo a shared edit. + +DS encounter tables carry daily, seasonal, swarm and other replacements. +This adapter reports `available = false` and `supports()` returns `false, +reason` for them; reads/writes return `nil, reason` and iteration is empty. +The native encounter API remains available to mods using the native format. + +### Lists, failures and lifecycle + +Lists replace; `__append` and `__prepend` extend them. `{}` and `mod.DELETE` +clear represented lists, including inherited learnsets and TM lists. Required +scalar fields, base stats and species types cannot be deleted. Other optional +fields support `mod.DELETE` where their native representation permits it. + +Malformed records raise schema errors. Non-representable operations return +`nil, reason` before recording anything. Payloads, returned records and native +snapshots never share writable tables with the operation log. Frozen content +still rejects writes. An entry failure rolls back every operation owned by +that mod, including all aliases. No Game, save, graphics or imported ROM is +needed to query or construct the content facade. + +## Validation + +Run `luajit tests/engine/unified_content.lua`. The tests load synthetic datasets +through the real loader, exercise all registered host versions and compare +native and unified round trips. Behavioral checks use native Pokémon/trainer +construction, encounter selection, DS move parsing and evolution dispatch. +This proves the exercised formats and consumers, not real-ROM playthroughs or +visual correctness. The complete local suite and its base comparison are +reported in the pull request. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..107f6041b 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1020,6 +1020,10 @@ function Loader:_api(mod) api.content[alias] = self:_contentApi(mod, self.content[canonical], ("the %s registry is deprecated; use %s"):format(alias, canonical)) end + api.contentFormat = mod.manifest.content_format or "native" + if api.contentFormat == "unified" then + api.content = engineRequire("src.mods.UnifiedContent").new(loader, mod, api.content, hostGen) + end -- assets keeps the v1 alias to the content accessors and adds the file -- helpers on top, so mod.assets.pokemon and mod.assets:image both resolve -- A RELATIVE PATH STAYS RELATIVE. @@ -1142,6 +1146,16 @@ function Loader:_api(mod) world = module.new(game, modId) return world end }) + -- When capability queries are installed, report the selected content + -- format's actual availability. Other surfaces retain their host query. + if api.contentFormat == "unified" and api.supports then + local supports, content = api.supports, api.content + api.supports = function(self, surface, name) + local handle = type(name) == "string" and content[name] + if surface == "content" and handle and handle.supports then return handle:supports() end + return supports(self, surface, name) + end + end return api end diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index ac5614634..2fbc8602b 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -223,6 +223,10 @@ function Manifest.validate(raw, path) assert(api <= Version.modApi, ("requires mod API %d; this engine provides %d") :format(api, Version.modApi)) local strict = api >= 2 + assert(raw.content_format == nil or raw.content_format == "native" + or raw.content_format == "unified", "content_format must be native or unified") + assert(raw.content_format ~= "unified" or api >= 2, + "unified content requires mod API 2") local profile = raw.profile or "content" if not Manifest.PROFILES[profile] then @@ -305,6 +309,7 @@ function Manifest.validate(raw, path) version = raw.version, entry = raw.entry, api = api, + content_format = raw.content_format or "native", priority = tonumber(raw.priority) or 0, dependencies = array(raw.dependencies), optional_dependencies = array(raw.optional_dependencies), diff --git a/src/mods/Registry.lua b/src/mods/Registry.lua index 9719673ef..cf200f844 100644 --- a/src/mods/Registry.lua +++ b/src/mods/Registry.lua @@ -28,11 +28,16 @@ function Registry.new(name, spec) }, Registry) end +local function validId(self, id) + return type(id) == "string" and id ~= "" + or self.numericIds and type(id) == "number" and id >= 1 and id < math.huge and id % 1 == 0 +end + local function append(self, id, op, value, owner) if self.frozen then error(self.name .. ": content is frozen after load") end - assert(type(id) == "string" and id ~= "", self.name .. " id is required") + assert(validId(self, id), self.name .. " id is required") local list = self.ops[id] if not list then list = {} @@ -94,6 +99,14 @@ local function fold(self, value, opList, id) if type(entry.value) == "table" then value = Merge.deepMerge(Merge.deepCopy(value == nil and {} or value), entry.value, self.spec.semantics) + -- Adapter-produced list patches include empty replacements. Native + -- patch semantics keep {} as a dictionary/no-op, so this internal + -- path list explicitly distinguishes clearing a list from omitting it. + for _, path in ipairs(entry.replaceLists or {}) do + local into, from = value, entry.value + for i = 1, #path - 1 do into, from = into[path[i]], from[path[i]] end + into[path[#path]] = Merge.deepCopy(from[path[#path]]) + end else value = entry.value end @@ -120,12 +133,14 @@ function Registry:override(id, value, owner) return append(self, id, "override", value, owner) end -function Registry:patch(id, partial, owner) +function Registry:patch(id, partial, owner, replaceLists) assert(partial ~= nil, self.name .. " patch value is required for " .. tostring(id)) if self.spec.semantics == "compose" then error(self.name .. ": patch is not supported on compose registries") end - return append(self, id, "patch", partial, owner) + local result = append(self, id, "patch", partial, owner) + if replaceLists then self.ops[id][#self.ops[id]].replaceLists = Merge.deepCopy(replaceLists) end + return result end -- tombstone: consumers treat the id as absent after the merge @@ -196,7 +211,7 @@ end -- Works after the loader has frozen content, because it appends nothing -- the -- ops are untouched and only the reading of them changes. function Registry:preferOwner(id, owner) - assert(type(id) == "string" and id ~= "", self.name .. " id is required") + assert(validId(self, id), self.name .. " id is required") self.prefer = self.prefer or {} self.prefer[id] = owner self.cache[id] = nil diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 9014835cc..d9e994c1a 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -268,13 +268,14 @@ end -- ------- cross-reference pass +local NUMERIC_REFS = { pokemon = true, moves = true, items = true, trainers = true } local collectRefs collectRefs = function(t, value, path, out) if value == nil or value == Merge.DELETE then return end local kind = t.kind if kind == "opt" then return collectRefs(t.inner, value, path, out) end if kind == "id" then - if type(value) == "string" then + if type(value) == "string" or NUMERIC_REFS[t.registry] and type(value) == "number" and value > 0 then out[#out + 1] = { registry = t.registry, ref = value, path = path } end return @@ -353,7 +354,7 @@ function Schemas.crossValidate(loader, data) end end for name, registry in pairs(loader.content) do - local spec = registry.spec + local spec = registry.referenceSpec or registry.spec for id in pairs(registry.ops) do local value = registry:get(id) if value ~= nil and registry.owners[id] ~= Schemas.ENGINE then diff --git a/src/mods/UnifiedContent.lua b/src/mods/UnifiedContent.lua new file mode 100644 index 000000000..dde1870a8 --- /dev/null +++ b/src/mods/UnifiedContent.lua @@ -0,0 +1,518 @@ +-- Explicit opt-in definition-record boundary. Native registries and gameplay +-- stay authoritative; no save, callback, world or battle data is rewritten. +local Merge = require("src.mods.Merge") +local Schemas = require("src.mods.Schemas") +local Shapes = require("src.mods.UnifiedSchemas") +local Ids = require("src.mods.UnifiedIds") +local U = {} +local copy, DELETE = Merge.deepCopy, Merge.DELETE +local function unsupported(reason) error({ unsupported = reason }, 0) end +local function check(ok, reason) if not ok then error("invalid unified content: " .. reason, 0) end end +local function equal(a, b, seen) + if a == b then return true end + if type(a) ~= "table" or type(b) ~= "table" then return false end + seen = seen or {}; if seen[a] == b then return true end; seen[a] = b + for k, v in pairs(a) do if not equal(v, b[k], seen) then return false end end + for k in pairs(b) do if a[k] == nil then return false end end + return true +end +local function finite(v, seen) + if type(v) == "number" then check(v == v and math.abs(v) < math.huge, "non-finite number") end + if type(v) ~= "table" or v == DELETE then return end + seen = seen or {}; check(not seen[v], "cyclic record"); seen[v] = true + for k, sub in pairs(v) do finite(k, seen); finite(sub, seen) end + seen[v] = nil +end +local function list(value, old, convert) + if value == DELETE then return {} end + check(type(value) == "table", "expected list") + if Merge.isWrapper(value) then + for k in pairs(value) do check(k == "__prepend" or k == "__append", "unknown list extension") end + local out = {} + for _, source in ipairs({ value.__prepend or {}, old or {}, value.__append or {} }) do + for _, row in ipairs(list(source, nil, function(v) return copy(v) end)) do out[#out + 1] = row end + end + return list(out, nil, convert) + end + local count = 0 + for k in pairs(value) do + check(type(k) == "number" and k % 1 == 0 and k >= 1 and k <= #value, "expected contiguous list") + count = count + 1 + end + check(count == #value, "expected contiguous list") + local out = {} + for i, row in ipairs(value) do out[i] = convert(row, i) end + return out +end +local function resolved(ctx, kind, id) + check(type(id) == "string" and id ~= "", "expected string " .. kind .. " ID") + local found, why = ctx.ids:resolve(kind, id) + if not found then unsupported(why) end + return found.key +end +local function readIds(ctx, kind, values) + local out = {} + for _, id in ipairs(values or {}) do + if id ~= 0 then out[#out + 1] = ctx.ids:read(kind, id) end + end + return out +end +local function writeIds(ctx, kind, values, old) + return list(values, old, function(id) return resolved(ctx, kind, id) end) +end +-- Gen 3's seven-byte names also produce these spellings (TypeChart's +-- registrant deliberately keeps them authoritative rather than renaming Data). +local TYPE_ALIASES = { PSYCHIC = "PSYCHIC_TYPE", PSYCHC = "PSYCHIC_TYPE", + MYSTERY = "CURSE_TYPE", FIGHT = "FIGHTING", ELECTR = "ELECTRIC" } +local function typeRead(id) return TYPE_ALIASES[id] or id end +local function typeWrite(ctx, id) + local registry = ctx.loader.content.type_chart + local found = registry:get(id) ~= nil and id or nil + for native, canonical in pairs(TYPE_ALIASES) do + if canonical == id and registry:get(native) ~= nil then + if found then unsupported("ambiguous type ID: " .. tostring(id)) end + found = native + end + end + if found then return found end + unsupported("unknown type ID: " .. tostring(id)) +end +local function reference(ctx, kind, id) + if ctx.loader.content[kind]:get(id) == nil then unsupported("unknown " .. kind .. " ID: " .. tostring(id)) end + return id +end + +local EVO = { + level = "LEVEL", useItem = "ITEM", trade = "TRADE", tradeWithItem = "TRADE", + friendship = "FRIENDSHIP", friendshipDay = "FRIENDSHIP", friendshipNight = "FRIENDSHIP", + HAPPINESS = "FRIENDSHIP", STAT = "LEVEL", + levelAtkGreater = "LEVEL", levelAtkLess = "LEVEL", levelAtkEqual = "LEVEL", +} +local function evolutionRead(ctx, row) + local method = EVO[row.method] or row.method + local out = { method = method, species = ctx.ids:read("pokemon", row.species or row.target) } + if method == "LEVEL" then out.level = row.level or row.param end + if method == "ITEM" then out.item = ctx.ids:read("items", row.item or row.param) end + if method == "TRADE" then out.item = ctx.ids:read("items", row.heldItem or (row.method == "tradeWithItem" and row.param)) end + if method == "FRIENDSHIP" then + out.time = row.timeOfDay or (row.method == "friendshipDay" and "MORNDAY") + or (row.method == "friendshipNight" and "NITE") + end + out.comparison = row.compare or ({ levelAtkGreater = "ATK_GT_DEF", levelAtkLess = "ATK_LT_DEF", + levelAtkEqual = "ATK_EQ_DEF" })[row.method] + return out +end +local function evolutionWrite(ctx, row, old) + if old and equal(row, evolutionRead(ctx, old)) then return copy(old) end + local method = row.method + if method ~= "LEVEL" and method ~= "ITEM" and method ~= "TRADE" and method ~= "FRIENDSHIP" then + unsupported("evolution method has no unified writer: " .. tostring(method)) + end + if ctx.gen == 1 and (method == "FRIENDSHIP" or row.item and method == "TRADE" + or row.time or row.comparison) then unsupported("evolution condition has no Gen 1 representation") end + if ctx.gen >= 4 then + -- This engine implements the native lowercase level method. Other DS + -- evolution conditions retain cartridge parameters and lack a safe writer. + if method ~= "LEVEL" or row.comparison then unsupported("DS evolution condition has no unified writer") end + check(row.level ~= nil and row.item == nil and row.time == nil, "invalid LEVEL condition") + return { method = "level", param = row.level, paramKind = "level", methodId = 4, + target = resolved(ctx, "pokemon", row.species) } + end + local out = { method = method, species = resolved(ctx, "pokemon", row.species) } + if method == "LEVEL" then + check(row.level ~= nil and row.item == nil and row.time == nil, "invalid LEVEL condition") + out.level = row.level + if row.comparison then out.method, out.compare = "STAT", row.comparison end + elseif method == "ITEM" then + check(row.item ~= nil and row.level == nil and row.time == nil and row.comparison == nil, "invalid ITEM condition") + out.item = resolved(ctx, "items", row.item) + elseif method == "TRADE" then + check(row.level == nil and row.time == nil and row.comparison == nil, "invalid TRADE condition") + if row.item then out.heldItem = resolved(ctx, "items", row.item) end + else + check(row.level == nil and row.item == nil and row.comparison == nil, "invalid FRIENDSHIP condition") + out.method, out.timeOfDay = "HAPPINESS", row.time ~= "ANYTIME" and row.time or nil + end + return out +end + +local readRecord +readRecord = function(ctx, kind, id, raw) + local out = { id = id, native = copy(raw) } + for key in pairs(Shapes[kind].fields) do + if key ~= "native" and key ~= "id" then out[key] = copy(raw[key]) end + end + if kind == "pokemon" then + local stats = raw.baseStats or {} + out.baseStats = { hp = stats.hp, attack = stats.attack, defense = stats.defense, speed = stats.speed, + special = stats.special or stats.spatk or stats.spAttack, + specialAttack = ctx.gen > 1 and (stats.spatk or stats.spAttack) or nil, + specialDefense = ctx.gen > 1 and (stats.spdef or stats.spDefense) or nil } + out.types = {}; for i, value in ipairs(raw.types or {}) do out.types[i] = typeRead(value) end + for _, key in ipairs({ "level1Moves", "tmhm", "eggMoves" }) do + if raw[key] then out[key] = readIds(ctx, "moves", raw[key]) end + end + out.level1Moves, out.learnset, out.evolutions = out.level1Moves or {}, {}, {} + for i, row in ipairs(raw.learnset or {}) do + out.learnset[i] = { level = row.level, move = ctx.ids:read("moves", row.move) } + end + for i, row in ipairs(raw.evolutions or {}) do out.evolutions[i] = evolutionRead(ctx, row) end + elseif kind == "moves" then + out.type = typeRead(raw.type) + out.category = raw.category or raw.class + if type(raw.effect) ~= "string" then out.effect = nil end + elseif kind == "items" then + if type(raw.effect) ~= "string" then out.effect = nil end + if raw.machine then out.machine.move = ctx.ids:read("moves", raw.machine.move) end + elseif kind == "trainers" then + out.parties = {} + for i, party in ipairs(raw.parties or (raw.party and { raw.party }) or {}) do + out.parties[i] = {} + for j, mon in ipairs(party) do + out.parties[i][j] = { species = ctx.ids:read("pokemon", mon.species), level = mon.level, + item = ctx.ids:read("items", mon.item), moves = mon.moves and readIds(ctx, "moves", mon.moves) } + end + end + elseif kind == "encounters" then + local function areaRead(area) + if not area then return nil end + local row = { rate = (area.rate or 0) * 256 / (area.rateMax or 256), slots = {} } + for i, slot in ipairs(area.slots or {}) do + row.slots[i] = { species = ctx.ids:read("pokemon", slot.species), level = slot.level, + minLevel = slot.min, maxLevel = slot.max } + end + if area.byTime then + row.times = { DAY = { rate = row.rate, slots = copy(row.slots) } } + for key, native in pairs({ MORN = "morn", NITE = "nite" }) do + if area.byTime[native] then row.times[key] = areaRead(area.byTime[native]) end + end + end + return row + end + out.grass, out.water = areaRead(raw.grass), areaRead(raw.water) + end + return out +end + +local function writeRecord(ctx, kind, id, value, old, mode) + local previous = old and readRecord(ctx, kind, id, old) or {} + if value.native ~= nil and not equal(value.native, old or {}) then unsupported("native diagnostics are read-only") end + if value.id ~= nil then check(value.id == id, "record id differs from operation id") end + if value.native ~= nil and old then + -- Detached read/modify/write records carry inherited fields. Convert only + -- actual edits, so round trips retain native selectors and absent aliases. + value = copy(value) + for key, v in pairs(value) do + if key ~= "id" and key ~= "native" and equal(v, previous[key]) then value[key] = nil end + end + end + local out, replacements = {}, {} + local function replace(key, v) out[key] = v; replacements[#replacements + 1] = { key } end + for key, v in pairs(value) do + if key ~= "native" and key ~= "id" then out[key] = copy(v) end + end + if not old then out.id = id end + if kind == "pokemon" then + if value.baseStats and value.baseStats ~= DELETE then + local stats, native = value.baseStats, {} + for _, stat in pairs(stats) do check(stat ~= DELETE, "cannot delete a native base stat") end + for _, key in ipairs({ "hp", "attack", "defense", "speed" }) do native[key] = stats[key] end + local a, b = stats.specialAttack, stats.specialDefense + if ctx.gen == 1 then + a, b = a or stats.special or (previous.baseStats or {}).special, + b or stats.special or (previous.baseStats or {}).special + if (a or b) and a ~= b then unsupported("Gen 1 cannot split Special") end + if stats.special and a ~= stats.special then check(false, "conflicting Special values") end + native.special = stats.special or stats.specialAttack or stats.specialDefense + else + -- A patch to special intentionally writes both halves. On detached + -- read/modify/write records, unchanged split fields are inherited. + if stats.special ~= nil and (value.native == nil or stats.special ~= (previous.baseStats or {}).special) then + if value.native ~= nil then + if a == (previous.baseStats or {}).specialAttack then a = nil end + if b == (previous.baseStats or {}).specialDefense then b = nil end + end + check(a == nil or a == stats.special, "conflicting Special Attack values") + a, b = a or stats.special, b or stats.special + elseif stats.special ~= nil and not old then a, b = a or stats.special, b or stats.special end + native.spatk, native.spdef = a, b + if a ~= nil then native.special = a end + if old and old.baseStats and old.baseStats.spAttack ~= nil then native.spAttack = a end + if old and old.baseStats and old.baseStats.spDefense ~= nil then native.spDefense = b end + end + out.baseStats = native + end + check(value.types ~= DELETE, "species types cannot be deleted") + if value.types then + local types = list(value.types, previous.types, function(t) return typeWrite(ctx, t) end) + check(#types >= 1 and #types <= 2, "species needs one or two types") + replace("types", types) + out.type1, out.type2 = types[1], types[2] or types[1] + end + for _, key in ipairs({ "level1Moves", "tmhm", "eggMoves" }) do + if value[key] ~= nil then replace(key, writeIds(ctx, "moves", value[key], previous[key])) end + end + if value.learnset ~= nil then + replace("learnset", list(value.learnset, previous.learnset, function(row) + return { level = row.level, move = resolved(ctx, "moves", row.move) } + end)) + end + if value.evolutions ~= nil then + replace("evolutions", list(value.evolutions, previous.evolutions, function(row, i) + return evolutionWrite(ctx, row, old and old.evolutions and old.evolutions[i]) + end)) + end + if value.growthRate and value.growthRate ~= DELETE then reference(ctx, "growth_rates", value.growthRate) end + if ctx.gen == 1 and (value.eggMoves or value.genderRatio or value.eggCycles) then + unsupported("breeding fields have no Gen 1 representation") + end + if not old and ctx.gen >= 3 then unsupported("new species require native EV/ability/storage metadata; patch an existing species") end + elseif kind == "moves" then + if value.type and value.type ~= DELETE then + out.type = typeWrite(ctx, value.type) + if old and old.typeId and out.type ~= old.type then unsupported("changing a DS type requires native type metadata") end + end + if value.effect and value.effect ~= DELETE then + reference(ctx, "move_effects", value.effect) + if ctx.gen > 1 and (not old or value.effect ~= old.effect) then unsupported("changing move effects requires a native effect adapter") end + end + if value.category ~= nil and old and old.class ~= nil then + check(value.category ~= DELETE, "native move class cannot be deleted") + out.class = value.category + if old.classId ~= nil then out.classId = ({ physical = 0, special = 1, status = 2 })[value.category] end + end + elseif kind == "items" then + if value.machine and value.machine ~= DELETE then + out.machine.move = resolved(ctx, "moves", value.machine.move) + end + for key, registry in pairs({ effect = "item_effects", ball = "balls" }) do + if value[key] and value[key] ~= DELETE then + reference(ctx, registry, value[key]) + if ctx.gen > 1 and (not old or value[key] ~= old[key]) then unsupported("changing item behavior requires native metadata") end + end + end + elseif kind == "trainers" and value.parties ~= nil then + local prior = old and (old.parties or (old.party and { old.party })) or {} + local parties = list(value.parties, previous.parties, function(party, i) + return list(party, nil, function(mon, j) + local native = copy(prior[i] and prior[i][j] or {}) + native.species, native.level = resolved(ctx, "pokemon", mon.species), mon.level + if ctx.gen == 1 and mon.item then unsupported("held items have no Gen 1 representation") end + native.item = mon.item and resolved(ctx, "items", mon.item) or nil + native.moves = mon.moves and writeIds(ctx, "moves", mon.moves) or nil + return native + end) + end) + replace("parties", parties) + if old and old.party then + check(#parties == 1, "native trainer has exactly one party") + replace("party", parties[1]) + end + elseif kind == "encounters" then + local function areaWrite(input, native, before, path) + if input == DELETE then return DELETE end + native, before = native or {}, before or {} + check(input.times ~= DELETE, "native time tables cannot be deleted") + if input.times then + input = copy(input) + if equal(input.rate, before.rate) then input.rate = nil end + if equal(input.slots, before.slots) then input.slots = nil end + end + local result = {} + if input.rate ~= nil then + check(input.rate ~= DELETE, "encounter rate cannot be deleted") + local rate = input.rate * (native.rateMax or 256) / 256 + if math.abs(rate - math.floor(rate + 0.5)) > 1e-8 then unsupported("encounter rate is not representable in native units") end + result.rate = math.floor(rate + 0.5) + end + if input.slots ~= nil then + local rows = list(input.slots, before.slots, function(slot, i) + local row = copy((native.slots or {})[i] or {}) + row.species = resolved(ctx, "pokemon", slot.species) + if slot.level then + check(slot.minLevel == nil and slot.maxLevel == nil, "conflicting encounter levels") + row.level, row.min, row.max = slot.level, nil, nil + else + check(slot.minLevel ~= nil and slot.maxLevel ~= nil and slot.minLevel <= slot.maxLevel, "invalid encounter level range") + row.level, row.min, row.max = nil, slot.minLevel, slot.maxLevel + end + if slot.chance then unsupported("changing encounter weights requires native buckets") end + return row + end) + if #rows ~= #(native.slots or {}) then unsupported("changing slot count requires native probability buckets") end + result.slots = rows; local p = copy(path); p[#p + 1] = "slots"; replacements[#replacements + 1] = p + end + if input.times then check(ctx.gen == 2 and native.byTime ~= nil, "time-specific encounters require a native time table") end + if native.byTime then + result.byTime = {} + for time, nativeTime in pairs({ MORN = "morn", DAY = "day", NITE = "nite" }) do + local shared = { rate = input.rate, slots = input.slots } + -- A full detached record contains inherited times: only explicit + -- changes override a shared rate/slot patch. + local specific = input.times and input.times[time] + if specific and before.times and equal(specific, before.times[time]) then specific = nil end + if specific then + for k, v in pairs(specific) do + if not before.times or not before.times[time] or not equal(v, before.times[time][k]) then shared[k] = v end + end + end + if nativeTime == "day" then + local d = areaWrite(shared, { rate = native.rate, rateMax = native.rateMax, slots = native.slots }, before, path) + if d.rate ~= nil then result.rate = d.rate end + if d.slots then result.slots = d.slots end + elseif native.byTime[nativeTime] then + local p = copy(path); p[#p + 1] = "byTime"; p[#p + 1] = nativeTime + result.byTime[nativeTime] = areaWrite(shared, native.byTime[nativeTime], before.times and before.times[time], p) + end + end + end + return result + end + for _, key in ipairs({ "grass", "water" }) do + if value[key] ~= nil then out[key] = areaWrite(value[key], old and old[key], previous[key], { key }) end + end + end + return out, replacements +end + +local function supported(kind, gen) + if kind == "encounters" and gen >= 4 then + return false, "unsupported: DS encounters have seasonal/daily replacement tables; use native content" + end + return true +end +local function protect(fn) + local result = { pcall(fn) } + if not result[1] then + if type(result[2]) == "table" and result[2].unsupported then return nil, "unsupported: " .. result[2].unsupported end + error(result[2], 0) + end + return unpack(result, 2) +end +function U.new(loader, mod, native, gen) + local out = {}; for name, handle in pairs(native) do out[name] = handle end + for kind, shape in pairs(Shapes) do + local registry = loader.content[kind] + if gen >= 4 and kind ~= "encounters" then registry.numericIds = true end + local f = Schemas.f + registry.referenceSpec = copy(registry.spec) + if kind == "pokemon" then + registry.referenceSpec.fields.evolutions = f.list(f.rec { + species = f.opt(f.id("pokemon")), target = f.opt(f.id("pokemon")), + item = f.opt(f.id("items")), heldItem = f.opt(f.id("items")), + }) + elseif kind == "trainers" then + local parties = f.list(f.list(f.rec { species = f.id("pokemon"), + item = f.opt(f.id("items")), moves = f.opt(f.list(f.id("moves"))) })) + registry.referenceSpec.fields.parties = parties + registry.referenceSpec.fields.party = parties.inner + elseif kind == "encounters" then + local slots = f.list(f.rec { species = f.id("pokemon") }) + local area = f.rec { slots = slots, byTime = f.opt(f.map(f.str, f.rec { slots = slots })) } + registry.referenceSpec.fields.grass, registry.referenceSpec.fields.water = area, area + if gen >= 4 then + -- The DS writer is unavailable, but removing a numeric species must + -- still see native encounter references, including seasonal tables. + local fields = {} + for _, key in ipairs({ "grass", "darkGrass", "rustling" }) do fields[key] = slots end + for _, key in ipairs({ "surf", "oldRod", "goodRod", "superRod", "ripplingSurf", "ripplingFishing" }) do + fields[key] = f.rec { slots = slots } + end + fields.timed = f.map(f.str, f.list(f.id("pokemon"))) + fields.radar = f.list(f.id("pokemon")) + fields.dualSlot = f.map(f.str, f.list(f.id("pokemon"))) + fields.swarm = f.union { f.list(f.id("pokemon")), f.map(f.str, f.id("pokemon")) } + fields.seasons = f.map(f.str, f.rec(fields)) + registry.referenceSpec.fields = fields + end + end + local available, why = supported(kind, gen) + local function context() return { loader = loader, gen = gen, ids = Ids.new(loader) } end + local function read(id) + if not available then return nil, why end + local ctx = context() + local found, err = ctx.ids:resolve(kind, id) + if not found then return nil, err end + local raw = registry:get(found.key) + return raw and readRecord(ctx, kind, id, raw) or nil + end + local handle = { available = available, supports = function() return available, why end, + get = function(_, id) return read(id) end, + has = function(_, id) return read(id) ~= nil end, + each = function() + if not available then return function() end, why end + local ctx, keys = context(), {} + for id, found in pairs(ctx.ids:entries(kind)) do + if found == false then error("ambiguous " .. kind .. " ID: " .. id, 0) end + keys[#keys + 1] = id + end + table.sort(keys) + local i = 0 + return function() + i = i + 1; local id = keys[i] + if id == nil then return end + local found = ctx.ids:resolve(kind, id) + return id, readRecord(ctx, kind, id, registry:get(found.key)) + end + end, + } + for _, verb in ipairs({ "register", "override", "patch", "remove" }) do + handle[verb] = function(_, id, value) + if not available then return nil, why end + return protect(function() + check(type(id) == "string" and id ~= "", "operation id must be a non-empty string") + local ctx = context() + local found, err = ctx.ids:resolve(kind, id) + if not found and err:match("^ambiguous") then unsupported(err) end + if not found then + if gen >= 4 then unsupported("new numeric records require native storage allocation") end + found = { key = id, keys = { id } } + end + local old = registry:get(found.key) + if verb == "register" then check(old == nil, kind .. " already registered: " .. id) end + if verb == "remove" then + check(not registry.frozen, kind .. ": content is frozen after load") + loader:_journal(kind) + for _, key in ipairs(found.keys) do registry:remove(key, mod.manifest.id) end + return true + end + finite(value) + check(type(value) == "table" and value ~= DELETE, "expected record") + for key in pairs(value) do check(shape.fields[key] ~= nil, "unknown " .. kind .. " field " .. tostring(key)) end + local function deletes(fields, record) + for key, entry in pairs(record) do + local field = fields[key] + if field and field.kind ~= "opt" then + check(entry ~= DELETE or field.kind == "list", "cannot delete required field " .. key) + if field.kind == "rec" and type(entry) == "table" then deletes(field.fields, entry) end + end + end + end + deletes(shape.fields, value) + local valid, validation = Schemas.check(shape, kind, id, value, verb) + check(valid, validation or "invalid record") + local payload, replacements = writeRecord(ctx, kind, id, value, old, verb) + if verb == "patch" and not old then unsupported("cannot patch absent " .. kind .. " ID: " .. id) end + check(not registry.frozen, kind .. ": content is frozen after load") + loader:_journal(kind) + for _, key in ipairs(found.keys) do + if verb == "patch" then registry:patch(key, payload, mod.manifest.id, replacements) + else + local record = Merge.deepMerge(copy(old or {}), payload) + for _, path in ipairs(replacements) do + local into, from = record, payload + for i = 1, #path - 1 do into, from = into[path[i]], from[path[i]] end + into[path[#path]] = copy(from[path[#path]]) + end + registry[verb](registry, key, record, mod.manifest.id) + end + end + return true + end) + end + end + out[kind] = handle + end + return out +end +return U diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua new file mode 100644 index 000000000..50866a636 --- /dev/null +++ b/src/mods/UnifiedIds.lua @@ -0,0 +1,85 @@ +-- Resolve portable names through this boot's own data, never a hard-coded +-- National Dex/item-number table (ROM hacks can assign entirely different IDs). +local Ids = {} +local function slug(name) + if type(name) ~= "string" then return nil end + name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M") + :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2") + return name ~= "" and name or nil +end +local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" } +local function canonical(key, row, kind) + if kind == "pokemon" or kind == "moves" or kind == "items" then + if type(key) ~= "string" or key:match("^SPECIES_%d+$") + or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then + return aliases[slug(row.name)] or slug(row.name) + end + return aliases[key] or key + end + -- Trainer classes, individual trainers and map IDs are game-specific. + return type(key) == "number" and tostring(key) or key +end + +function Ids.new(loader) + local indexes = {} + local function index(kind) + if indexes[kind] then return indexes[kind] end + local out = { public = {}, native = {} } + indexes[kind] = out + local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind)) + -- Preserve identity/name metadata before the loader merges alias keys + -- into separate result tables. Presentation-name patches cannot rename IDs. + loader._unifiedIdBase = loader._unifiedIdBase or {} + local base = loader._unifiedIdBase[kind] + if not base then + base = {} + for key, row in pairs(registry.base and registry.base() or {}) do + if type(row) == "table" then + base[key] = { identity = row, id = canonical(key, row, kind) } + end + end + loader._unifiedIdBase[kind] = base + end + local groups = {} + for key, row in registry:each() do + if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then + -- Aliases pointing at one pristine record form one identity even + -- after an earlier native mod patched only one of its keys. + local identity = base[key] and base[key].identity or row + local group = groups[identity] + if not group then + local id = base[key] and base[key].id or canonical(key, row, kind) + if id then + group = { id = id, key = key, keys = {} } + groups[identity] = group + local previous = out.public[id] + -- false is an ambiguity marker, never last-writer-wins. + if previous ~= nil then out.public[id] = false else out.public[id] = group end + end + end + if group then + group.keys[#group.keys + 1] = key + out.native[key] = group + if type(key) == "number" then group.key = key end + end + end + end + return out + end + return { + resolve = function(_, kind, id) + local found = index(kind).public[id] + if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end + if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end + return found + end, + read = function(_, kind, id) + if id == nil or id == false or id == 0 or id == "" then return nil end + local found = index(kind).native[id] + return found and found.id or tostring(id) + end, + entries = function(_, kind) return index(kind).public end, + } +end +return Ids diff --git a/src/mods/UnifiedSchemas.lua b/src/mods/UnifiedSchemas.lua new file mode 100644 index 000000000..10d2f6bb2 --- /dev/null +++ b/src/mods/UnifiedSchemas.lua @@ -0,0 +1,60 @@ +-- The opt-in content format is separate from the legacy/native schemas. +-- These are definition records, not saved Pokemon or callback payloads. +local Schemas = require("src.mods.Schemas") +local f = Schemas.f +local id = f.str +local ids = f.list(id) +local level = f.int(1, 100) +local stats = f.rec { + hp = f.int(1, 255), attack = f.int(1, 255), defense = f.int(1, 255), + speed = f.int(1, 255), special = f.int(1, 255), + specialAttack = f.opt(f.int(1, 255)), specialDefense = f.opt(f.int(1, 255)), +} +local evolution = f.rec { + method = id, species = id, level = f.opt(level), item = f.opt(id), + time = f.opt(f.enum { "ANYTIME", "MORNDAY", "NITE" }), + comparison = f.opt(f.enum { "ATK_GT_DEF", "ATK_LT_DEF", "ATK_EQ_DEF" }), +} +local slot = f.rec { + species = id, level = f.opt(level), minLevel = f.opt(level), maxLevel = f.opt(level), + chance = f.opt(f.numRange(0, 100)), +} +local area = f.rec { rate = f.numRange(0, 256), slots = f.list(slot), + times = f.opt(f.map(f.enum { "MORN", "DAY", "NITE" }, + f.rec { rate = f.opt(f.numRange(0, 256)), slots = f.opt(f.list(slot)) })) } +local U = { + pokemon = { fields = { + id = id, name = f.str, dex = f.opt(f.int(1)), types = ids, baseStats = stats, + catchRate = f.int(0, 255), baseExp = f.int(0), growthRate = id, + level1Moves = ids, learnset = f.list(f.rec { level = level, move = id }), + evolutions = f.list(evolution), tmhm = f.opt(ids), eggMoves = f.opt(ids), + genderRatio = f.opt(f.int(0, 255)), eggCycles = f.opt(f.int(0, 255)), + spriteFront = f.opt(f.path), spriteBack = f.opt(f.path), frontSize = f.opt(f.int(1, 7)), + trueColor = f.opt(f.bool), + } }, + moves = { fields = { + id = id, name = f.str, type = id, power = f.int(0, 255), accuracy = f.int(0, 100), + pp = f.int(0, 64), effect = f.opt(id), + category = f.opt(f.enum { "physical", "special", "status" }), + priority = f.opt(f.int(-7, 7)), description = f.opt(f.str), + } }, + items = { fields = { + id = id, name = f.str, price = f.int(0), + machine = f.opt(f.rec { kind = f.str, move = id, number = f.int(0) }), + effect = f.opt(id), ball = f.opt(id), tossable = f.opt(f.bool), + needsTarget = f.opt(f.bool), description = f.opt(f.str), + } }, + trainers = { fields = { + id = id, name = f.str, pic = f.opt(f.path), baseMoney = f.opt(f.int(0)), + battleTheme = f.opt(id), partyNames = f.opt(f.list(f.str)), + parties = f.list(f.list(f.rec { + species = id, level = level, item = f.opt(id), moves = f.opt(ids), + })), + } }, + encounters = { fields = { id = f.opt(id), grass = f.opt(area), water = f.opt(area) } }, +} +for _, spec in pairs(U) do + spec.fields.native = f.opt(f.any) + spec.semantics = "record" +end +return U diff --git a/tests/engine/unified_content.lua b/tests/engine/unified_content.lua new file mode 100644 index 000000000..b7695caa9 --- /dev/null +++ b/tests/engine/unified_content.lua @@ -0,0 +1,470 @@ +-- Synthetic extractor-shaped datasets; all mod changes go through Loader. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local Version = require("src.core.GameVersion") +local Manifest = require("src.mods.Manifest") +local Merge = require("src.mods.Merge") +local DELETE = Merge.DELETE +local Registry = require("src.mods.Registry") +local Runtime = require("src.mods.Runtime") +local original = Version.get() +local function raises(fn, fragment) + return T.raises(fn, fragment, "rejects " .. fragment) +end +local versions = { "red", "crystal", "emerald", "platinum", "black" } +local function fixture(gen) + local ds = gen >= 4 + local keys = { mon = ds and 25 or gen == 2 and "SPECIES_025" or "PIKACHU", + into = ds and 26 or gen == 2 and "SPECIES_026" or "RAICHU", + move = ds and 33 or gen == 2 and "MOVE_033" or "TACKLE", + item = ds and 17 or gen == 2 and "ITEM_017" or "POTION", + trainer = ds and 7 or "OPP_PROBE" } + local stats = { hp = 35, attack = 55, defense = 40, speed = 90, special = 50 } + if gen > 1 then stats.spatk, stats.spdef = 50, 60 end + if ds then stats.spAttack, stats.spDefense = 50, 60 end + local mon = { id = keys.mon, name = "Pikachu", dex = 25, types = { "NORMAL" }, + baseStats = stats, catchRate = 190, baseExp = 82, growthRate = "MEDIUM_FAST", + level1Moves = { keys.move }, learnset = { { level = 9, move = keys.move } }, tmhm = { keys.move }, + evolutions = { ds and { method = "level", target = keys.into, param = 20, methodId = 4, paramKind = "level" } + or { method = "LEVEL", species = keys.into, level = 20 } }, + spriteFront = "synthetic-front.png", spriteBack = "synthetic-back.png", frontSize = 7, + customNative = { sentinel = "retained" } } + if gen >= 3 then mon.evYield = { speed = 2 }; mon.abilities = { "STATIC" } end + local into = Merge.deepCopy(mon); into.id, into.name, into.dex, into.evolutions = keys.into, "Raichu", 26, {} + local move = { id = keys.move, name = "Tackle", type = "NORMAL", power = 35, accuracy = 95, pp = 35, + effect = "NO_ADDITIONAL_EFFECT", priority = 0, untouchedTarget = 6 } + if ds then move.class, move.classId, move.typeId, move.gen4Effect = "physical", 0, 0, 0 end + local item = { id = keys.item, name = "Potion", price = 300, tossable = true, heldEffect = 27 } + local party = { { species = keys.mon, level = 12, item = gen > 1 and keys.item or nil, + moves = { keys.move }, customIV = 31 } } + local trainer = { id = keys.trainer, name = "Probe", parties = { party }, baseMoney = 10, aiMods = { 9 } } + if ds then trainer.party = party; trainer.nativeFlag = 17 end + local grass = { rate = 20, slots = { { species = keys.mon, level = 5 }, { species = keys.into, level = 7 } }, + buckets = { 128, 256 } } + if gen == 2 then + grass.byTime = { morn = Merge.deepCopy(grass), nite = Merge.deepCopy(grass) } + grass.byTime.nite.rate = 10 + elseif gen == 3 then + grass.rateMax, grass.bucketSpan, grass.buckets = 180, 100, { 50, 100 } + grass.slots[1].level, grass.slots[1].min, grass.slots[1].max = nil, 4, 6 + end + local data = { pokemon = { [keys.mon] = mon, [keys.into] = into }, moves = { [keys.move] = move }, + items = { [keys.item] = item }, trainers = { [keys.trainer] = trainer }, + encounters = { ROUTE_PROBE = { grass = grass } }, constants = { generation = gen }, audio = {} } + if ds then + data.items.ITEM_017 = item + data.encounters = { [1] = { grassRate = 20, grass = { { species = keys.mon, level = 5, chance = 100 } } } } + end + return data, keys +end +local function run(gen, body, format, extra, supplied, selected) + Version.set(selected or versions[gen]) + local data, keys = fixture(gen) + data = supplied or data + local mods = { probe = { entry = body, format = format or "unified" } } + for id, spec in pairs(extra or {}) do mods[id] = spec end + local loader + local fs = { + read = function(path) + local id = path:match("^mods/([^/]+)/manifest.json$") + local mod = id and mods[id] + if mod then return ('{"id":"%s","name":"Probe","version":"1.0.0","api":2,"entry":"main.lua","content_format":"%s","priority":%d}') + :format(id, mod.format or "unified", mod.priority or 0) end + end, + getDirectoryItems = function(path) + if path ~= "mods" then return {} end + local out = {}; for id in pairs(mods) do out[#out + 1] = id end; return out + end, + getInfo = function(path) + if path:match("main.lua$") or path:match("manifest.json$") then return { type = "file" } end + if path == "mods" or path:match("^mods/[^/]+$") then return { type = "directory" } end + end, + load = function(path) + local id = path:match("^mods/([^/]+)/main.lua$") + if mods[id] then return function(api) return mods[id].entry(api, loader) end end + end, + } + loader = Loader.new({ fs = fs, dev = false }) + loader._game = function() error("content must not load Game") end + loader:load(data) + return loader.apis.probe, loader, data, keys +end +local function ok(value, reason) assert(value, reason) end +local function ops(registry) local n = 0; for _, rows in pairs(registry.ops) do n = n + #rows end; return n end +local function dataOnly(v) + if type(v) == "function" then return "" end + if type(v) ~= "table" then return v end + local out = {}; for k, sub in pairs(v) do out[k] = dataOnly(sub) end; return out +end + +-- Native defaults, manifest rejection and registry numeric-ID confinement. +raises(function() Manifest.validate({ id = "bad", name = "bad", version = "1", entry = "main.lua", api = 1, + content_format = "unified" }, "mods/bad") end, "unified content requires mod API 2") +raises(function() Manifest.validate({ id = "bad", name = "bad", version = "1", entry = "main.lua", api = 2, + content_format = "unifed" }, "mods/bad") end, "content_format must be native or unified") +raises(function() Registry.new("legacy"):patch(25, {}) end, "id is required") + +local savedLove = love; love = nil +for gen = 1, 5 do + local api, loader, data, keys = run(gen, function(mod) + T.eq(mod.contentFormat, "unified", "manifest selects unified content in entry") + if mod.supports then + T.eq(mod:supports("content", "encounters"), gen <= 3, "host query follows selected content format") + T.eq(mod:supports("hooks", "battle.damage"), true, "other capability surfaces retain host query") + end + local pokemon = assert(mod.content.pokemon:get("PIKACHU")) + T.eq(pokemon.id, "PIKACHU", "canonical species identity") + T.eq(pokemon.level1Moves[1], "TACKLE", "canonical starting move") + T.eq(pokemon.evolutions[1].species, "RAICHU", "canonical evolution target") + T.eq(pokemon.baseStats.special, 50, "canonical Special") + T.eq(pokemon.baseStats.specialDefense, gen > 1 and 60 or nil, "split Special retained") + pokemon.baseStats.hp = 99 + pokemon.native.customNative.sentinel = "local" + T.eq(mod.content.pokemon:get("PIKACHU").baseStats.hp, 35, "read is detached") + local _, why = mod.content.pokemon:patch("PIKACHU", pokemon) + T.check(why and why:find("read%-only"), "native diagnostics cannot be written") + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { special = 70 } })) + ok(mod.content.moves:patch("TACKLE", { power = 45 })) + ok(mod.content.items:patch("POTION", { price = 100 })) + local trainerId = gen >= 4 and "7" or "OPP_PROBE" + local trainer = assert(mod.content.trainers:get(trainerId)) + T.eq(trainer.parties[1][1].species, "PIKACHU", "canonical trainer species") + T.eq(trainer.parties[1][1].moves[1], "TACKLE", "canonical trainer moves") + trainer.parties[1][1].level = 18 + ok(mod.content.trainers:patch(trainerId, { parties = trainer.parties })) + local _, whyMissing = mod.content.moves:patch("ABSENT", { power = 5 }) + T.check(whyMissing ~= nil, "absent patch declines with reason") + local encounter = mod.content.encounters + if gen <= 3 then + T.check(encounter:supports(), "GB/GBA encounter adapter available") + local row = assert(encounter:get("ROUTE_PROBE")) + row.grass.slots[1].species = "RAICHU" + ok(encounter:patch("ROUTE_PROBE", { grass = { slots = row.grass.slots } })) + else + local available, why = encounter:supports() + T.check(available == false and type(why) == "string", "DS encounters explicitly unsupported") + local result, err = encounter:patch("1", { grass = { rate = 10 } }) + T.check(result == nil and type(err) == "string", "DS encounter writes decline safely") + end + end) + T.eq(loader.mods.probe.state, "loaded", versions[gen] .. ": real entry/merge succeeds") + T.eq(#loader.errors, 0, versions[gen] .. ": no cross-reference errors") + T.eq(data.pokemon[keys.mon].baseStats.special, 70, "native Special alias updated") + T.eq(data.pokemon[keys.mon].baseStats.spdef, gen > 1 and 70 or nil, "native Special Defense updated") + T.eq(data.pokemon[keys.mon].customNative.sentinel, "retained", "native extensions survive") + T.eq(data.moves[keys.move].power, 45, "native move power updated") + T.eq(data.moves[keys.move].untouchedTarget, 6, "native move targeting preserved") + T.eq(data.items[keys.item].price, 100, "native item price updated") + T.eq(data.items[keys.item].heldEffect, 27, "native held effect preserved") + T.eq(data.trainers[keys.trainer].parties[1][1].level, 18, "native trainer party updated") + T.eq(data.trainers[keys.trainer].parties[1][1].customIV, 31, "native trainer metadata preserved") + T.eq(api.content.pokemon:get("PIKACHU").baseStats.special, 70, "read works after merge/freeze") + if gen >= 4 then + T.eq(data.items.ITEM_017.price, 100, "DS item string alias updated together with numeric ID") + T.eq(api.content.items:get("POTION").price, 100, "DS alias identity survives merge") + T.same(data.trainers[keys.trainer].party, data.trainers[keys.trainer].parties[1], "DS party aliases stay synchronized") + end + raises(function() api.content.moves:patch("TACKLE", { power = 99 }) end, "frozen") + local count = 0 + for id, row in api.content.items:each() do count = count + 1; T.eq(id, row.id, "iterator canonical ID") end + T.eq(count, 1, "iterator coalesces native aliases") +end +love = savedLove or require("tests.love_stub") + +-- No-op canonical round trips preserve every native field and native format. +for gen = 1, 5 do + local _, baseline = run(gen, function() end, "native") + local _, canonical, data = run(gen, function(mod) + local kinds = { "pokemon", "moves", "items", "trainers" } + if gen <= 3 then kinds[#kinds + 1] = "encounters" end + for _, kind in ipairs(kinds) do + for id, record in mod.content[kind]:each() do ok(mod.content[kind]:override(id, record)) end + end + end) + T.eq(canonical.mods.probe.state, "loaded", "round-trip load succeeds") + T.same(dataOnly(data), dataOnly(baseline.baseData), versions[gen] .. ": full native round-trip parity") +end + +-- Unsupported/malformed operations must append nothing and preserve input. +for gen = 1, 5 do + local _, loader = run(gen, function(mod) + local pokemon = mod.content.pokemon + local before = mod.content.pokemon:get("PIKACHU") + local invalid = { baseStats = { hp = 0 } } + raises(function() pokemon:patch("PIKACHU", invalid) end, "invalid") + T.eq(invalid.baseStats.hp, 0, "invalid caller payload untouched") + raises(function() pokemon:patch("PIKACHU", { base_stats = {} }) end, "unknown") + raises(function() pokemon:patch("PIKACHU", { baseStats = DELETE }) end, "required") + raises(function() pokemon:patch("PIKACHU", { learnset = { [2] = { level = 4, move = "TACKLE" } } }) end, "contiguous") + local cyclic = {}; cyclic.foo = cyclic + raises(function() pokemon:patch("PIKACHU", cyclic) end, "cyclic") + raises(function() mod.content.moves:patch("TACKLE", { power = 0 / 0 }) end, "non-finite") + local result, reason = pokemon:patch("PIKACHU", { level1Moves = { "MISSING" } }) + T.check(result == nil and reason:find("unknown moves ID", 1, true), "unknown cross-generation reference refuses") + T.same(pokemon:get("PIKACHU"), before, "failed operations leave effective record intact") + end) + T.eq(loader.mods.probe.state, "loaded", "caught refusals do not disable mod") + T.eq(ops(loader.content.pokemon), 0, "refused operations leave no Pokemon op log") +end + +-- Empty lists, append/prepend, DELETE and stacking with native mods. +local _, lists, data = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { learnset = {} })) + T.eq(#mod.content.pokemon:get("PIKACHU").learnset, 0, "empty list clears immediately") + ok(mod.content.pokemon:patch("PIKACHU", { learnset = { __append = { { level = 10, move = "TACKLE" } } } })) + ok(mod.content.pokemon:patch("PIKACHU", { learnset = { __prepend = { { level = 5, move = "TACKLE" } } } })) + ok(mod.content.pokemon:patch("PIKACHU", { tmhm = mod.DELETE })) +end, "unified", { + earlier = { format = "native", priority = -1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 80 } }) + end }, + later = { format = "native", priority = 1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { defense = 75 } }) + end }, +}) +T.eq(lists.mods.probe.state, "loaded", "list and native stacking load") +T.eq(data.pokemon.SPECIES_025.learnset[1].level, 5, "prepend before append") +T.eq(data.pokemon.SPECIES_025.learnset[2].level, 10, "append retains order") +T.eq(#data.pokemon.SPECIES_025.tmhm, 0, "DELETE materializes empty native list") +T.eq(data.pokemon.SPECIES_025.baseStats.attack, 80, "earlier native patch preserved") +T.eq(data.pokemon.SPECIES_025.baseStats.defense, 75, "later native patch preserved") + +-- An entry failure rolls every canonical/native alias operation back. +for gen = 1, 5 do + local _, loader, data, keys = run(gen, function(mod) + ok(mod.content.items:patch("POTION", { price = 1 })) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 100 } })) + error("intentional entry failure") + end) + T.eq(loader.mods.probe.state, "failed", "entry failure attributed") + T.eq(data.items[keys.item].price, 300, "failed item edit rolled back") + T.eq(data.pokemon[keys.mon].baseStats.hp, 35, "failed species edit rolled back") + if gen >= 4 then T.eq(data.items.ITEM_017.price, 300, "failed DS alias edit rolled back") end +end + +-- Native consumers see the result, rather than merely passing schema checks. +local _, nativeLoader, data, keys = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { specialAttack = 90, specialDefense = 95 } })) + ok(mod.content.trainers:patch("OPP_PROBE", { parties = { { { species = "RAICHU", level = 18, moves = { "TACKLE" } } } } })) + ok(mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 32, + slots = { { species = "RAICHU", level = 8 }, { species = "PIKACHU", level = 9 } } } })) +end) +T.eq(nativeLoader.mods.probe.state, "loaded", "consumer probe loads") +local Pokemon = require("src.pokemon.Pokemon") +local mon = Pokemon.new(data, keys.mon, 10, function(a) return a end) +T.check(mon.stats.spatk ~= nil and mon.stats.spdef ~= nil, "native Pokemon constructor keeps split stat model") +local Encounter = require("src.world.Encounter") +for _, time in ipairs({ "MORNING", "DAY", "NIGHT" }) do + local tableAtTime = Encounter.atTime(data.encounters.ROUTE_PROBE.grass, time) + local encountered = Encounter.rollTable(tableAtTime, function(a) return a end) + T.eq(encountered.species, keys.into, time .. ": native encounter chooses translated species") + T.eq(encountered.level, 8, time .. ": native encounter uses translated level") +end +local Battle = require("src.battle.BattleState") +local game = { data = data, save = { options = {}, player = {}, party = {} } } +local party = Battle.buildTrainerParty(game, keys.trainer, 1, data.trainers[keys.trainer].parties[1]) +T.eq(party[1].species, keys.into, "native trainer builder receives translated species") +T.eq(party[1].level, 18, "native trainer builder receives translated level") + +-- Every registered version selects the same format using its own host +-- generation. The facade captures that generation before entry execution. +for _, version in ipairs(Version.ORDER) do + local gen = Version.generation(version) + local _, loader, data, key = run(gen, function(mod) + T.eq(mod.gameVersion, version, "selected host identity: " .. version) + mod.generation, mod.host.generation = 99, 99 + Version.set("red") + ok(mod.content.moves:patch("TACKLE", { power = 42 })) + end, "unified", nil, nil, version) + T.eq(loader.mods.probe.state, "loaded", version .. ": captured host format works") + T.eq(data.moves[key.move].power, 42, version .. ": same portable patch reaches native data") +end + +-- Canonical IDs are based on this ROM's names, not official numeric IDs. +local hacked = fixture(2) +hacked.pokemon.SPECIES_777 = hacked.pokemon.SPECIES_025 +hacked.pokemon.SPECIES_025 = nil +local _, hackLoader, hackData = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 65 } })) +end, "unified", nil, hacked) +T.eq(hackLoader.mods.probe.state, "loaded", "relocated ROM-hack species ID resolves") +T.eq(hackData.pokemon.SPECIES_777.baseStats.hp, 65, "no hard-coded National Dex lookup") + +local ambiguous = fixture(2) +ambiguous.pokemon.SPECIES_999 = Merge.deepCopy(ambiguous.pokemon.SPECIES_025) +local _, ambiguity = run(2, function(mod) + local value, why = mod.content.pokemon:get("PIKACHU") + T.check(value == nil and why:find("ambiguous", 1, true), "ambiguous name has a diagnostic") + local changed, reason = mod.content.pokemon:patch("PIKACHU", { catchRate = 1 }) + T.check(changed == nil and reason:find("ambiguous", 1, true), "ambiguous writes cannot choose a record") + raises(function() mod.content.pokemon:each() end, "ambiguous") +end, "unified", nil, ambiguous) +T.eq(ops(ambiguity.content.pokemon), 0, "ambiguity leaves no ops") + +-- New string-key records can be registered without changing legacy schema +-- behavior. Existing numeric records can be patched, not allocated blindly. +for gen = 1, 5 do + local _, loader, data = run(gen, function(mod) + local new = { id = "CUSTOM_ITEM", name = "Custom item", price = 42 } + local saved = Merge.deepCopy(new) + local changed, why = mod.content.items:register("CUSTOM_ITEM", new) + if gen < 4 then + T.check(changed == true, "string-key item registration succeeds") + T.eq(mod.content.items:get("CUSTOM_ITEM").price, 42, "registration immediately readable") + raises(function() mod.content.items:register("CUSTOM_ITEM", new) end, "already registered") + else T.check(changed == nil and why:find("storage", 1, true), "DS allocation declines without a native slot") end + T.same(new, saved, "registration input remains detached") + local payload = { price = 123 } + ok(mod.content.items:patch("POTION", payload)) + payload.price = 999 + T.eq(mod.content.items:get("POTION").price, 123, "later caller mutation cannot edit op log") + local record = assert(mod.content.pokemon:get("PIKACHU")) + local changed, reason = mod.content.pokemon:patch("PIKACHU", { baseStats = { specialAttack = 99 } }) + if gen == 1 then + T.check(changed == nil and reason:find("split", 1, true), "Gen 1 split stat rejected atomically") + T.same(mod.content.pokemon:get("PIKACHU"), record, "rejected split retains native record") + else T.eq(changed, true, "later generation accepts split stat") end + raises(function() mod.content.pokemon:patch("PIKACHU", { types = mod.DELETE }) end, "cannot be deleted") + raises(function() mod.content.pokemon:patch("PIKACHU", { baseStats = { specialDefense = mod.DELETE } }) end, "cannot delete") + end) + T.eq(loader.mods.probe.state, "loaded", "registration/refusal cases survive load") + T.eq(data.items.CUSTOM_ITEM and data.items.CUSTOM_ITEM.price, gen < 4 and 42 or nil, "only supported registrations reach Data") +end + +-- Probability units are uniform (a fraction out of 256) and lossless. +local _, rates, rateData = run(3, function(mod) + local before = mod.content.encounters:get("ROUTE_PROBE") + T.eq(before.grass.rate, 20 * 256 / 180, "GBA rate is projected into common units") + local changed, why = mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 31 } }) + T.check(changed == nil and why:find("representable", 1, true), "unrepresentable GBA rate rejected") + T.same(mod.content.encounters:get("ROUTE_PROBE"), before, "rejected rate leaves slots/weights intact") + ok(mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 30 * 256 / 180 } })) +end) +T.eq(rateData.encounters.ROUTE_PROBE.grass.rate, 30, "representable rate maps exactly to native span") + +-- Native DS parser output is edited through the same facade. Its class +-- selector and numeric evolution target remain consistent for real consumers. +local parsed = fixture(4) +local bytes = { 0, 0, 0, 35, 0, 95, 35, 0, 0, 0, 0, 0, 0, 0, 0, 0 } +local chars = {}; for i, byte in ipairs(bytes) do chars[i] = string.char(byte) end +parsed.moves[33] = assert(require("src.import.Gen4Moves").parse(table.concat(chars))) +parsed.moves[33].id, parsed.moves[33].name = 33, "Tackle" +local _, parsedLoader, parsedData = run(4, function(mod) + ok(mod.content.moves:patch("TACKLE", { category = "special", power = 50 })) + ok(mod.content.pokemon:patch("PIKACHU", { evolutions = { { method = "LEVEL", level = 15, species = "RAICHU" } } })) +end, "unified", nil, parsed) +T.eq(parsedLoader.mods.probe.state, "loaded", "native parser-produced data accepts canonical edits") +T.eq(parsedData.moves[33].class, "special", "native DS class updated") +T.eq(parsedData.moves[33].classId, 1, "native DS class selector updated") +local Evolution = require("src.pokemon.Evolution") +local target = Evolution.pendingFor({ data = parsedData, save = { options = {} } }, + { species = 25, level = 15 }, { kind = "levelup" }) +T.eq(target, 26, "native evolution consumer follows translated numeric target") + +-- Removing a target retains post-merge reference checks, including numeric +-- trainer references and the otherwise hidden Gen 2 night slot table. +for _, gen in ipairs({ 2, 4 }) do + local _, loader = run(gen, function(mod) ok(mod.content.pokemon:remove("RAICHU")) end) + T.check(#loader.errors > 0, "removed native evolution/encounter target is diagnosed") + T.check(table.concat(loader.errors, "\n"):find("probe", 1, true) ~= nil, "reference failure attributed to removing mod") +end +local _, removal, removedData = run(4, function(mod) + local trainer = mod.content.trainers:get("7") + trainer.parties[1][1].item = nil + ok(mod.content.trainers:patch("7", { parties = trainer.parties })) + ok(mod.content.items:remove("POTION")) + T.eq(mod.content.items:has("POTION"), false, "tombstone immediately hidden in canonical view") +end) +T.eq(#removal.errors, 0, "valid alias removal has no dangling reference") +T.check(removedData.items[17] == nil and removedData.items.ITEM_017 == nil, "numeric/string aliases tombstoned together") + +-- Preferences must still choose original patch operations, not snapshots of +-- earlier mods' changes captured into an adapter's replacement record. +local _, arbitration, arbitrationData = run(2, function(mod, loader) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 75 } })) + loader.content.pokemon:preferOwner("SPECIES_025", "probe") +end, "unified", { + earlier = { format = "native", priority = -1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 80 } }) + end }, +}) +T.eq(arbitrationData.pokemon.SPECIES_025.baseStats.hp, 75, "preferred canonical patch remains") +T.eq(arbitrationData.pokemon.SPECIES_025.baseStats.attack, 55, "set-aside native patch does not leak through a snapshot") + +-- The extractor keeps abbreviated GBA type keys, not the Gen 1 keys. +local types = fixture(3) +types.type_chart = { types = { NORMAL = { category = "physical" }, PSYCHC = { category = "special" } }, matchups = {} } +types.moves.TACKLE.type = "PSYCHC" +local _, typeLoader, typeData = run(3, function(mod) + T.eq(mod.content.moves:get("TACKLE").type, "PSYCHIC_TYPE", "abbreviated GBA type is canonical on read") + ok(mod.content.moves:patch("TACKLE", { type = "PSYCHIC_TYPE" })) + ok(mod.content.pokemon:patch("PIKACHU", { types = { "PSYCHIC_TYPE" } })) +end, "unified", nil, types) +T.eq(#typeLoader.errors, 0, "canonical type resolves to actual native registry") +T.eq(typeData.moves.TACKLE.type, "PSYCHC", "native abbreviated move type retained") +T.eq(typeData.pokemon.PIKACHU.types[1], "PSYCHC", "species type references translated") + +-- A pre-existing unmapped native move selector must not become a dangling +-- registry ID merely because a balance mod changes its power. +local opaque = fixture(4) +opaque.moves[33].effect, opaque.moves[33].gen4Effect = 999, 999 +local _, opaqueLoader, opaqueData = run(4, function(mod) + local record = mod.content.moves:get("TACKLE") + T.check(record.effect == nil and record.native.effect == 999, "opaque selector exposed only in native diagnostics") + ok(mod.content.moves:patch("TACKLE", { power = 65 })) +end, "unified", nil, opaque) +T.eq(#opaqueLoader.errors, 0, "untouched numeric effect selector has no false reference error") +T.eq(opaqueData.moves[33].gen4Effect, 999, "opaque native selector survives patch") + +-- Default/explicit native mode continues to expose live native records. +local default = Manifest.validate({ id = "native", name = "Native", version = "1", entry = "main.lua", api = 2 }, "mods/native") +T.eq(default.content_format, "native", "omitted format remains native") +local _, native = run(2, function(mod) + T.eq(mod.contentFormat, "native", "native manifest uses original facade") + T.check(mod.content.pokemon:get("PIKACHU") == nil, "native facade does not invent aliases") + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 99 } }) +end, "native") +T.eq(native.baseData.pokemon.SPECIES_025.baseStats.attack, 99, "native patch still has original behavior") + +-- Full read/modify/write of one time must not flatten the others to DAY. +local timeData = fixture(2) +timeData.encounters.ROUTE_PROBE.grass.byTime.morn.rate = 5 +timeData.encounters.ROUTE_PROBE.grass.byTime.morn.slots[1].level = 2 +local _, timeLoader, editedTimes = run(2, function(mod) + local record = mod.content.encounters:get("ROUTE_PROBE") + record.grass.times.NITE.slots[1].level = 11 + ok(mod.content.encounters:override("ROUTE_PROBE", record)) +end, "unified", nil, timeData) +T.eq(timeLoader.mods.probe.state, "loaded", "time-specific detached edit succeeds") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.rate, 20, "time edit preserves day rate") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.morn.rate, 5, "time edit preserves distinct morning rate") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.morn.slots[1].level, 2, "time edit preserves distinct morning slots") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.nite.slots[1].level, 11, "time edit changes only requested night slot") + +local _, mixedTimeLoader, mixedTimeData = run(2, function(mod) + local record = mod.content.encounters:get("ROUTE_PROBE") + record.grass.rate = 30 + record.grass.times.NITE.slots[1].level = 12 + ok(mod.content.encounters:override("ROUTE_PROBE", record)) +end) +T.eq(mixedTimeData.encounters.ROUTE_PROBE.grass.byTime.nite.rate, 30, "inherited night rate cannot undo shared edit") +T.eq(mixedTimeData.encounters.ROUTE_PROBE.grass.byTime.nite.slots[1].level, 12, "explicit night slot edit survives shared rate") + +local _, conflictingStats = run(2, function(mod) + raises(function() mod.content.pokemon:patch("PIKACHU", { baseStats = { special = 50, specialAttack = 80 } }) end, "conflicting") +end) +T.eq(ops(conflictingStats.content.pokemon), 0, "conflicting stat aliases leave no ops") + +-- The unsupported DS writer must not disable native reference auditing. +local seasonal = fixture(5) +seasonal.encounters[1].seasons = { winter = { grass = { { species = 26, level = 8, chance = 100 } } } } +local _, seasonalLoader = run(5, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { evolutions = {} })) + ok(mod.content.pokemon:remove("RAICHU")) +end, "unified", nil, seasonal) +T.check(table.concat(seasonalLoader.errors, "\n"):find("seasons.winter.grass", 1, true) ~= nil, + "DS seasonal encounter target is included in removal diagnostics") + +Version.set(original) +T.finish("unified content") From 4822a59042c2b65ca2a8d9883d34b3e1f056004a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:00:39 +0200 Subject: [PATCH 004/138] Add opt-in unified hook and event record boundaries --- docs/modding.md | 8 + docs/unified-mod-callbacks.md | 177 +++++++ src/mods/CallbackRecords.lua | 670 +++++++++++++++++++++++++ src/mods/Loader.lua | 4 + src/mods/Manifest.lua | 5 + src/mods/UnifiedCallbacks.lua | 214 ++++++++ src/mods/UnifiedIds.lua | 85 ++++ tests/engine/gate_meta_coverage.lua | 2 - tests/engine/unified_mod_callbacks.lua | 634 +++++++++++++++++++++++ 9 files changed, 1797 insertions(+), 2 deletions(-) create mode 100644 docs/unified-mod-callbacks.md create mode 100644 src/mods/CallbackRecords.lua create mode 100644 src/mods/UnifiedCallbacks.lua create mode 100644 src/mods/UnifiedIds.lua create mode 100644 tests/engine/unified_mod_callbacks.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..1f52f9979 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -192,6 +192,14 @@ default** (1x front, 2x back). ball-to-pic grow multiplies your scale through each stage, so a rescaled mon still grows into place from the ball, grounded the whole way. +## Unified hook and event records (opt-in) + +API-2 mods can select `"callback_format": "unified"` independently of their +content format. Known Pokémon, battler, trainer-slot and encounter records +then use common fields and symbolic IDs. Existing mods keep native payloads. +See [the callback format and mutation contract](unified-mod-callbacks.md) for +adapted arguments/results, native handles and validation rules. + ## Developer console Boot with developer mode on to unlock the in-game console and hot-reload diff --git a/docs/unified-mod-callbacks.md b/docs/unified-mod-callbacks.md new file mode 100644 index 000000000..a79661ca6 --- /dev/null +++ b/docs/unified-mod-callbacks.md @@ -0,0 +1,177 @@ +# Unified hook and event records + +This is an additive, explicit Mod API 2 option inspired by Gen1Recomp's +unified callback boundary. It adapts Gen2Recomped's existing call sites across +generations 1–5. It does not introduce Gen1Recomp API 3 hooks or events. + +```json +{ + "id": "portable_hooks", + "name": "Portable hooks", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "callback_format": "unified" +} +``` + +Absent/`"native"` keeps the original API, including object identities and +custom hook names. Unknown format values and unified callbacks on API 1 fail +manifest validation. `mod.callbackFormat` exposes the selected format. The +option is independent of `content_format`; either can be adopted first. +There is no global conversion of saves, registry data or battle state. + +```lua +return function(mod) + mod.events:on("pokemon.level_up", function(event) + mod.log:info("%s reached %d", event.mon.species, event.level) + end) + + mod.hooks:wrap("battle.damage", function(next, ctx) + local damage, info = next() + if ctx.user.mon.species == "PIKACHU" then damage = damage + 1 end + return damage, info + end) + + mod.events:on("pokemon.before_give", function(gift) + if gift.species == "EEVEE" then gift.nickname = "Buddy" end + end) +end +``` + +## Records + +IDs are resolved through this boot's registries. Pokémon, move and item IDs +are symbolic strings, including on numeric-ID DS hosts. Imported numeric +records use their names (e.g. `PIKACHU`); named/custom keys retain their IDs. +No National Dex lookup or hard-coded item-number conversion is used. Unknown +or ambiguous replacement IDs fail validation. An unchanged unknown native +ID can round-trip without being guessed or rewritten. Native aliases remain +one identity even after registry merging creates separate row tables. + +| Record | Common fields | +| --- | --- | +| Live Pokémon | `species`, `item`, `nickname`, `name`, `level`, `hp`, `maxHp`, `status`, `isEgg`, `stats`, `moves`, `native` | +| Stats | `hp`, `attack`, `defense`, `speed`, `special`, `specialAttack`, `specialDefense` | +| Live move slot | `{ id, pp, maxPp, ppUps }` | +| Battler | `{ mon, id, side, native }`; `side` is `"player"`/`"enemy"` when known | +| Trainer party slot | `species`, `level`, optional `item`, `moves` as symbolic ID strings, `native`; other native metadata is read-only | +| Encounter / fishing slot | `species`, `level` or `min`/`max`; native slot metadata retained | +| Move definition | `id`, `name`, `type`, `power`, `accuracy`, `pp`, `priority`, `category`, symbolic `effect` when available, `native` | +| Species definition | `id`, `name`, `baseStats`, `types`, `baseExp`, `catchRate`, `growthRate`, `native` | +| Item definition | `id`, `name`, `price`, `description`, `consumable`, `native` | + +Optional fields remain absent if the native data does not provide them. +`maxHp` maps to native `mon.stats.hp`. Special stats map to `spatk`/`spdef`; +Gen 1's single special stat is exposed through all three special fields. +Editing one keeps its aliases consistent; contradictory edits are rejected. +Type aliases match unified content IDs (`ELECTR` → `ELECTRIC`, `FIGHT` → +`FIGHTING`, `PSYCHIC`/`PSYCHC` → `PSYCHIC_TYPE`, `MYSTERY` → `CURSE_TYPE`). +Status values are `sleep`, `poison`, `burn`, `freeze`, `paralyze`, `toxic`, or +`nil`; clearing uses `nil` or `false`. Unknown existing statuses are retained +until explicitly changed. `isEgg` is the native boolean flag. + +Definitions and `native` snapshots are detached, read-only diagnostics. Use +content registration during mod initialization to change definitions. The +callback view intentionally exposes a limited common definition shape; +additional generation-specific fields are available under `native`. +Battler metadata is read-only. Change its live Pokémon via `battler.mon`. +Shared native records share one public view within a call, so `user.mon` and +`target.mon` remain the same object when they reference the same Pokémon. + +## Adapted hooks + +| Hook | Adapted boundary | +| --- | --- | +| `battle.accuracy`, `battle.damage`, `battle.crit`, `exp.gain` | Context records: battlers, Pokémon, move/species definitions, known ID fields | +| `battle.run`, `battle.catch_exp`, `battle.low_health_alarm` | Context shell; native battle handle | +| `battle.turn_order` | Both battler/move pairs; bound battler result preserves native identity | +| `battle.enemy_action` | Second argument battler; move-action `id` in result | +| `battle.exp_award` | `alive` Pokémon list and `applyShare` helper | +| `catch.rate` | Ball ID, Pokémon, species definition, `opts.ballDef` item definition | +| `trainer.party` | Third argument and result use trainer slots, not live saved Pokémon | +| `evolution.check` | Second argument live Pokémon, evolution/trigger context known IDs | +| `fieldmove.eligibility` | Move ID and bound live Pokémon result | +| `encounter.fishing` | Rod ID, candidate list and encounter result | +| `encounter.roll` | Context and encounter result; imported encounter-table argument stays native | +| `encounter.species` | Encounter argument/result and context | +| `pokemon.sprite`, `pokemon.icon` | Context Pokémon and species ID; path result stays native | +| `ui.party.submenu` | Third argument live Pokémon; menu rows and nested callbacks stay native | + +The remaining native hooks pass their arguments/results through. In +particular, UI callbacks, renderers, input, script arguments, maps, movement, +saves, intro speech and link fingerprint data retain their engine contracts. +Registering an unknown engine callback in unified mode fails immediately. +The source-catalog test forces an explicit boundary decision for new seams; +registration is not a promise that a given game executes that seam. Where +available, use `mod:supports("hooks"/"events", name)` to query the host. + +`battle.exp_award`'s `ctx.applyShare(mon, divisor, announce)` accepts a bound +public Pokémon, commits pending validated edits, calls the native helper, +and refreshes the public views. It retains native argument/result behavior, +including errors. Retained helpers and `next` cannot be used after the hook +callback returns. Live party membership/order cannot be changed through this +view. New encounter, enemy-action and trainer-party records may be returned; +live Pokémon/battler replacements must be records bound during that call. + +## Mutation and failure behavior + +Edits to represented live Pokémon fields, trainer slots, encounters and +context fields are validated before being written. Levels must be 1–100; +stats/max HP positive integers; HP must fit the maximum. Move lists contain +at most four entries, PP must fit the selected move and PP Ups, and `maxPp` +is read-only. Trainer parties contain at most six slots. Unsupported fields, +read-only changes and invalid IDs reject the transaction before any staged +record is changed. Hidden native metadata (IVs, EVs, personality, slot fields) +is retained. Native Pokémon, stats tables, move lists and existing move slots +keep their identities, including battle aliases into those tables. + +Before `next()`, validated edits are committed together. After downstream +returns, public views refresh from native state. A successful callback's +later edits commit together too. Throwing before `next()` skips the wrapper; +throwing/validation failure after `next()` keeps downstream results and +changes. Already committed edits and native helper effects are not undone. +Calling `next()` again returns the cached result (including nil holes), or +rethrows the cached error; it never repeats downstream gameplay. Native +errors preserve their identity and are not converted into mod failures. + +Only the represented record edits participate in this transaction. Game, +battle, save, world, map, data and RNG handles stay native, as do unclassified +engine-specific fields; direct changes through such handles have ordinary +native side effects. Changing a species/level does not automatically rebuild +battle definitions, recalculate stats, grant moves or emit evolution events. +Use the engine's gameplay operations when that bookkeeping is required. + +## Events + +Ordinary engine notifications copy their outer context and adapt known +record fields/IDs: battle Pokémon/battlers/moves, level-up learnable move IDs, +move actions, caught/received/evolved Pokémon and trade records. Edits to +these snapshots do not write back. Engine handles remain native, including +`side`, `battle`, `game`, interaction targets and script contexts. A status +source such as `SYNCHRONIZE` remains an engine label rather than a guessed +move ID. + +`pokemon.before_give` is the explicit mutable exception: `species`, `level` +and optional `nickname` are validated and committed atomically. Its script +`ctx` stays native. A failure changes none of those gift fields, and later +listeners continue through the original event bus. + +Save lifecycle/repair events, screen and intro-speech events, `game.ready`, +`mods.loaded`, and mod option events pass through natively. This preserves +save repair semantics, forwarding metatables and nested callback identities. +Namespaced `mod..` events also pass through unchanged. +Mods may listen to other mods' events and emit only their own namespace. +Priorities, unsubscribe, once semantics, owner attribution and failed-entry +rollback are provided by the original event/hook buses. + +## Verification + +`tests/engine/unified_mod_callbacks.lua` runs through a ROM-free in-memory +filesystem and the real loader/event/hook buses across every supported host. +It covers string/numeric IDs and aliases, transactional writeback, native +identity, mixed mod chains, `next`/helper failures, event isolation, mutable +gifts, priorities, ownership rollback and the native default. The catalog +checks compare against actual source call sites, rather than another list. +Run it with the same LuaJIT runtime as the engine, then run `scripts/test.sh`. +ROM-dependent gameplay checks still require an imported legal ROM. diff --git a/src/mods/CallbackRecords.lua b/src/mods/CallbackRecords.lua new file mode 100644 index 000000000..6d702f4b1 --- /dev/null +++ b/src/mods/CallbackRecords.lua @@ -0,0 +1,670 @@ +-- A call-local transaction. Only known record roles cross this boundary; +-- opaque engine handles (Game, battle, save, map, RNG, UI callbacks) stay native. +local Ids = require("src.mods.UnifiedIds") +local R = {} +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function shallow(t) + local out = {} + for k, v in pairs(t or {}) do + out[k] = v + end + return out +end +local function copy(v, seen) + if type(v) ~= "table" then + if type(v) == "function" then + return nil + end + return v + end + seen = seen or {} + if seen[v] then + return seen[v] + end + local out = {} + seen[v] = out + for k, x in pairs(v) do + out[k] = copy(x, seen) + end + return out +end +local function equal(a, b, seen) + if a == b then + return true + end + if type(a) ~= "table" or type(b) ~= "table" then + return false + end + seen = seen or {} + if seen[a] == b then + return true + end + seen[a] = b + for k, v in pairs(a) do + if not equal(v, b[k], seen) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end +local function integer(v, lo, hi, label) + assert(type(v) == "number" and v == v and v % 1 == 0 and v >= lo and v <= hi, label .. " out of range") + return v +end +local status = + { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local short = {} +for k, v in pairs(status) do + short[v] = k +end +local stats = { + hp = "hp", + attack = "attack", + defense = "defense", + speed = "speed", + special = "special", + specialAttack = "spatk", + specialDefense = "spdef", +} +local typeAliases = { + PSYCHIC = "PSYCHIC_TYPE", + PSYCHC = "PSYCHIC_TYPE", + MYSTERY = "CURSE_TYPE", + FIGHT = "FIGHTING", + ELECTR = "ELECTRIC", +} +local function typeRead(id) + return typeAliases[id] or id +end +local monFields = { + species = true, + item = true, + nickname = true, + name = true, + level = true, + hp = true, + maxHp = true, + status = true, + isEgg = true, + stats = true, + moves = true, + native = true, +} +local idRoles = { + species = "pokemon", + fromSpecies = "pokemon", + toSpecies = "pokemon", + evolveTo = "pokemon", + moveId = "moves", + item = "items", + itemId = "items", + ball = "items", +} +local recordRoles = { + mon = "mon", + pokemon = "mon", + user = "battler", + target = "battler", + attacker = "battler", + defender = "battler", + battler = "battler", + incoming = "battler", + previous = "battler", + move = "move", + defeatedDef = "species", + ballDef = "item", + opts = "context", + alive = "party", + sent = "mon", + received = "mon", + playerAction = "action", + enemyAction = "action", +} +local function array(t, label, max) + assert(type(t) == "table", label .. " must be a list") + local n, count = #t, 0 + assert(not max or n <= max, label .. " too long") + for k in pairs(t) do + count = count + 1 + assert(type(k) == "number" and k % 1 == 0 and k >= 1 and k <= n, label .. " must be dense") + end + assert(count == n, label .. " must be dense") +end + +function R.new(loader, name, generation) + local ids = Ids.new(loader) + local byRaw, byView, entries = {}, {}, {} + local view, restore, commit, refresh + local open = true + local function read(kind, id) + return ids:read(kind, id) + end + local function native(kind, id, old) + if id == read(kind, old) then + return old + end + if id == nil or id == false then + return nil + end + assert(type(id) == "string", kind .. " ID must be a string") + local entry, why = ids:resolve(kind, id) + assert(entry, why) + -- Keep each host's existing numeric/string representation. + if type(old) == "string" then + for _, key in ipairs(entry.keys) do + if type(key) == "string" then + return key + end + end + end + return entry.key + end + local function moveDef(id) + local group = ids:resolve("moves", read("moves", id)) + return group and loader.content.moves:get(group.key) or nil + end + local function moveViews(raw) + local out = {} + for i, m in ipairs(raw or {}) do + local id = type(m) == "table" and m.id or m + local def = moveDef(id) + local ups = type(m) == "table" and (m.ppUps or 0) or 0 + out[i] = { + id = read("moves", id), + pp = type(m) == "table" and m.pp or nil, + maxPp = def and math.floor((def.pp or 0) + ups * math.floor((def.pp or 0) / 5)) or nil, + ppUps = ups, + } + end + return out + end + local function statView(raw) + local out = {} + for k, n in pairs(stats) do + out[k] = (raw or {})[n] + end + out.specialAttack = out.specialAttack or out.special + out.specialDefense = out.specialDefense or out.special + return out + end + local function fields(raw, kind) + if kind == "mon" then + return { + species = read("pokemon", raw.species), + item = read("items", raw.item), + nickname = raw.nickname, + name = raw.name, + level = raw.level, + hp = raw.hp, + maxHp = raw.stats and raw.stats.hp, + status = status[raw.status] or raw.status, + isEgg = raw.isEgg == true, + stats = statView(raw.stats), + moves = moveViews(raw.moves), + native = copy(raw), + } + elseif kind == "battler" then + return { + mon = view(raw.mon, "mon"), + id = raw.id, + side = raw.isPlayer == true and "player" or raw.isPlayer == false and "enemy" or nil, + native = copy({ name = raw.name, species = raw.species, stages = raw.stages }), + } + elseif kind == "move" or kind == "species" or kind == "item" then + local out = { native = copy(raw), name = raw.name } + local registryKind = kind == "species" and "pokemon" or kind == "move" and "moves" or "items" + local key = raw.id or raw.index or raw.numId + if key == nil then + for id, row in loader.content[registryKind]:each() do + if row == raw then + key = id + break + end + end + end + out.id = read(registryKind, key) + if kind == "species" then + out.baseStats = statView(raw.baseStats) + out.types = {} + for i, t in ipairs(raw.types or {}) do + out.types[i] = typeRead(t) + end + out.baseExp = raw.baseExp or raw.expYield + out.catchRate = raw.catchRate + out.growthRate = raw.growthRate + elseif kind == "move" then + for _, key in ipairs({ "type", "power", "accuracy", "pp", "priority" }) do + out[key] = raw[key] + end + out.type = typeRead(out.type) + out.category = raw.category or raw.class + out.effect = type(raw.effect) == "string" and raw.effect or nil + else + for _, key in ipairs({ "price", "description", "consumable" }) do + out[key] = raw[key] + end + end + return out + elseif kind == "party" or kind == "candidates" or kind == "trainerParty" then + local out = {} + local role = kind == "party" and "mon" or kind == "trainerParty" and "trainerMon" or "encounter" + for i, v in ipairs(raw) do + out[i] = view(v, role) + end + return out + elseif kind == "trainerMon" then + local out = copy(raw) + out.species = read("pokemon", raw.species) + out.item = read("items", raw.item) + out.moves = {} + for i, id in ipairs(raw.moves or {}) do + out.moves[i] = read("moves", id) + end + out.native = copy(raw) + return out + end + local out = shallow(raw) + for k, v in pairs(raw) do + local role = recordRoles[k] + if name == "world.interacted" and k == "target" then + role = nil + end + if name == "trade.completed" and k == "evolution" then + role = "context" + end + if name == "pokemon.before_give" and k == "ctx" then + role = nil + end + if k == "status" then + out[k] = status[v] or v + elseif k == "move" and type(v) ~= "table" then + out[k] = read("moves", v) + elseif kind == "action" and k == "id" and not raw.special then + out[k] = read("moves", v) + elseif idRoles[k] and type(v) ~= "table" then + out[k] = read(idRoles[k], v) + elseif role and type(v) == "table" then + out[k] = view(v, role) + elseif k == "learnable" and name == "pokemon.level_up" then + out[k] = {} + for i, id in ipairs(v) do + out[k][i] = read("moves", id) + end + elseif k == "applyShare" and name == "battle.exp_award" and type(v) == "function" then + out[k] = function(mon, ...) + local bound = byView[mon] + assert(bound and bound.kind == "mon", "applyShare requires a bound Pokemon") + commit() + local result = pack(pcall(v, bound.raw, ...)) + if not result[1] then + error(result[2], 0) + end + refresh() + return unpack(result, 2, result.n) + end + end + end + return out + end + view = function(raw, kind) + if type(raw) ~= "table" then + return raw + end + byRaw[raw] = byRaw[raw] or {} + if byRaw[raw][kind] then + return byRaw[raw][kind].public + end + local e = { raw = raw, kind = kind, public = {} } + byRaw[raw][kind] = e + byView[e.public] = e + entries[#entries + 1] = e + for k, v in pairs(fields(raw, kind)) do + e.public[k] = v + end + e.before = ( + kind == "action" + or kind == "gift" + or kind == "context" + or kind == "encounter" + or kind == "party" + or kind == "trainerParty" + or kind == "candidates" + ) + and shallow(e.public) + or copy(e.public) + return e.public + end + local function statChanges(public, old, baseline, ops) + assert(type(old) == "table", "native stats are missing") + assert(type(public) == "table", "stats must be a record") + for k in pairs(public) do + assert(stats[k], "unknown stat " .. tostring(k)) + end + local specialValue + for _, k in ipairs({ "special", "specialAttack", "specialDefense" }) do + if public[k] ~= baseline[k] and (generation == 1 or k ~= "specialDefense") then + assert(not specialValue or specialValue == public[k], "conflicting special stats") + specialValue = public[k] + end + end + for k, n in pairs(stats) do + local value = public[k] + if generation == 1 and (k == "special" or k == "specialAttack" or k == "specialDefense") then + value = specialValue or baseline[k] + n = "special" + elseif specialValue and (k == "special" or k == "specialAttack") then + value = specialValue + end + if value ~= baseline[k] then + value = integer(value, 1, 65535, "stats." .. k) + ops[#ops + 1] = function() + old[n] = value + end + end + end + end + local pending, visiting + local function stage(raw, key, value) + assert(type(raw) == "table", "native record is missing") + pending[#pending + 1] = function() + raw[key] = value + end + end + local function movesNative(public, old, baseline) + array(public, "moves", 4) + local out = {} + for i, m in ipairs(public) do + assert(type(m) == "table", "move must be a record") + for k in pairs(m) do + assert(k == "id" or k == "pp" or k == "maxPp" or k == "ppUps", "unknown move field") + end + local prev = (old or {})[i] + local prior = (baseline or {})[i] or {} + assert(m.maxPp == prior.maxPp, "maxPp is read-only") + local row = type(prev) == "table" and shallow(prev) or {} + row.id = native("moves", m.id, row.id) + assert(row.id, "move ID is required") + row.ppUps = integer(m.ppUps or 0, 0, 3, "ppUps") + local def = moveDef(row.id) + local max = def and math.floor((def.pp or 0) + row.ppUps * math.floor((def.pp or 0) / 5)) or 0 + row.pp = integer(m.pp, 0, max, "pp") + out[i] = row + end + return out + end + restore = function(value, kind) + if type(value) ~= "table" then + assert( + value == nil or (kind == "encounter" and value == false), + "expected " .. tostring(kind) .. " record" + ) + return value + end + local e = byView[value] + if not e then + assert( + kind == "action" + or kind == "encounter" + or kind == "trainerMon" + or kind == "candidates" + or kind == "trainerParty", + "replacement " .. tostring(kind) .. " must be a bound record" + ) + e = { public = value, kind = kind, raw = {}, before = {} } + end + if visiting[e] then + return e.raw + end + visiting[e] = true + local raw, before = e.raw, e.before + kind = e.kind + if kind == "move" or kind == "species" or kind == "item" or kind == "battler" then + if kind == "battler" then + assert( + value.id == before.id and value.side == before.side and equal(value.native, before.native), + "battler metadata is read-only" + ) + assert(byView[value.mon] and byView[value.mon].raw == raw.mon, "battler.mon identity is read-only") + for k in pairs(value) do + assert(k == "mon" or k == "id" or k == "side" or k == "native", "unknown battler field") + end + restore(value.mon, "mon") + else + assert(equal(value, before), kind .. " definition is read-only; use mod.content") + end + elseif kind == "mon" then + for k in pairs(value) do + assert(monFields[k], "unknown Pokemon field " .. tostring(k)) + end + assert(equal(value.native, before.native), "native snapshot is read-only") + assert(value.name == before.name, "name is read-only; set nickname") + for _, key in ipairs({ "species", "item", "nickname", "level", "hp", "maxHp", "status", "isEgg" }) do + local v = value[key] + if not equal(v, before[key]) then + if key == "species" or key == "item" then + v = native(key == "species" and "pokemon" or "items", v, raw[key]) + assert(key ~= "species" or v, "species is required") + elseif key == "level" then + v = integer(v, 1, 100, key) + elseif key == "hp" then + v = integer(v, 0, 65535, key) + elseif key == "maxHp" then + v = integer(v, 1, 65535, key) + elseif key == "status" then + assert(v == nil or v == false or short[v], "unknown status") + v = short[v] + elseif key == "nickname" then + assert(v == nil or type(v) == "string", "nickname must be a string") + elseif key == "isEgg" then + assert(type(v) == "boolean", "isEgg must be boolean") + end + if key == "maxHp" then + stage(raw.stats, "hp", v) + else + stage(raw, key, v) + end + end + end + if value.maxHp ~= before.maxHp and value.stats.hp ~= before.stats.hp then + assert(value.maxHp == value.stats.hp, "conflicting maxHp and stats.hp") + end + local limit = value.stats.hp ~= before.stats.hp and value.stats.hp or value.maxHp + if value.hp ~= before.hp or limit ~= before.maxHp then + assert( + type(value.hp) == "number" and type(value.maxHp) == "number" and value.hp <= limit, + "hp exceeds maxHp" + ) + end + if not equal(value.stats, before.stats) then + statChanges(value.stats, raw.stats, before.stats, pending) + end + if not equal(value.moves, before.moves) then + local new = movesNative(value.moves, raw.moves, before.moves) + local target = raw.moves or {} + stage(raw, "moves", target) + pending[#pending + 1] = function() + local previous = shallow(target) + for i in pairs(target) do + target[i] = nil + end + for i, row in ipairs(new) do + local slot = type(previous[i]) == "table" and previous[i] or {} + for k in pairs(slot) do + slot[k] = nil + end + for k, v in pairs(row) do + slot[k] = v + end + target[i] = slot + end + end + end + elseif kind == "party" or kind == "candidates" or kind == "trainerParty" then + array(value, kind, kind ~= "candidates" and 6 or nil) + local out = {} + local role = kind == "party" and "mon" or kind == "trainerParty" and "trainerMon" or "encounter" + for i, v in ipairs(value) do + out[i] = restore(v, role) + end + if kind == "party" then + assert(#value == #raw, "live party membership is read-only") + for i, v in ipairs(out) do + assert(v == raw[i], "live party order is read-only") + end + else + pending[#pending + 1] = function() + for k in pairs(raw) do + raw[k] = nil + end + for i, v in ipairs(out) do + raw[i] = v + end + end + end + elseif kind == "gift" then + assert(value.ctx == before.ctx, "gift context is read-only") + for k in pairs(value) do + assert(k == "ctx" or k == "species" or k == "level" or k == "nickname", "unknown gift field") + end + stage(raw, "species", assert(native("pokemon", value.species, raw.species), "species is required")) + stage(raw, "level", integer(value.level, 1, 100, "level")) + assert(value.nickname == nil or type(value.nickname) == "string", "nickname must be a string") + stage(raw, "nickname", value.nickname) + elseif kind == "trainerMon" then + assert(value.native == nil or equal(value.native, before.native), "native snapshot is read-only") + local allowed = { species = true, item = true, level = true, moves = true, native = true } + for k, v in pairs(value) do + if not allowed[k] then + assert(equal(v, before[k]), "trainer metadata is read-only") + end + end + stage(raw, "species", assert(native("pokemon", value.species, raw.species), "species is required")) + stage(raw, "item", native("items", value.item, raw.item)) + stage(raw, "level", integer(value.level, 1, 100, "level")) + if not equal(value.moves, before.moves) then + array(value.moves, "trainer moves", 4) + local out = {} + for i, id in ipairs(value.moves) do + out[i] = assert(native("moves", id, (raw.moves or {})[i]), "move is required") + end + stage(raw, "moves", out) + end + else + if kind == "encounter" then + assert(value.species ~= nil, "encounter species is required") + if value.level ~= nil then + integer(value.level, 1, 100, "encounter level") + end + for _, k in ipairs({ "min", "max" }) do + if value[k] ~= nil then + integer(value[k], 1, 100, k) + end + end + assert( + value.level ~= nil or (value.min and value.max and value.min <= value.max), + "encounter level is required" + ) + end + local keys = shallow(before) + for k in pairs(value) do + keys[k] = true + end + for k in pairs(keys) do + local v = value[k] + local old = raw[k] + if type(v) == "table" and byView[v] then + local restored = restore(v) + if v ~= before[k] then + stage(raw, k, restored) + end + elseif k == "move" and type(v) ~= "table" and v ~= before[k] then + stage(raw, k, native("moves", v, old)) + elseif kind == "action" and k == "id" and not value.special and v ~= before[k] then + stage(raw, k, native("moves", v, old)) + elseif k == "status" and v ~= before[k] then + assert(v == nil or v == false or short[v], "unknown status") + stage(raw, k, short[v]) + elseif idRoles[k] and v ~= before[k] then + stage(raw, k, native(idRoles[k], v, old)) + elseif k == "applyShare" and name == "battle.exp_award" then + assert(v == before[k], "applyShare is read-only") + elseif k == "learnable" and name == "pokemon.level_up" then + assert(equal(v, before[k]), "learnable is read-only") + elseif v ~= before[k] then + local role = recordRoles[k] + if kind == "encounter" and k == "level" then + integer(v, 1, 100, "encounter level") + end + if role and type(v) == "table" then + v = restore(v, role) + end + stage(raw, k, v) + end + end + end + return raw + end + commit = function(extra) + assert(open, "callback transaction is closed") + pending, visiting = {}, {} + for _, e in ipairs(entries) do + restore(e.public, e.kind) + end + local result + if extra then + result = extra() + end + for _, op in ipairs(pending) do + op() + end + return result + end + refresh = function() + -- Reuse public identities, but refresh after vanilla mutates native data. + for _, e in ipairs(entries) do + local nextValue = fields(e.raw, e.kind) + for k in pairs(e.public) do + e.public[k] = nil + end + for k, v in pairs(nextValue) do + e.public[k] = v + end + end + for _, e in ipairs(entries) do + e.before = ( + e.kind == "action" + or e.kind == "gift" + or e.kind == "context" + or e.kind == "encounter" + or e.kind == "party" + or e.kind == "trainerParty" + or e.kind == "candidates" + ) + and shallow(e.public) + or copy(e.public) + end + end + return { + close = function() + open = false + end, + view = view, + restore = function(v, kind) + return restore(v, kind) + end, + commit = commit, + refresh = refresh, + read = read, + native = native, + copy = copy, + } +end +return R diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..02b33db62 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -891,6 +891,7 @@ function Loader:_api(mod) }, -- a deep copy: what a mod does to its own view never reaches the loader manifest = Merge.deepCopy(mod.manifest), + callbackFormat = mod.manifest.callback_format or "native", content = {}, exports = {}, DELETE = Registry.DELETE, @@ -999,6 +1000,9 @@ function Loader:_api(mod) }, } self.exports[modId] = api.exports + if api.callbackFormat == "unified" then + engineRequire("src.mods.UnifiedCallbacks").install(loader, api, modId, hostGen) + end -- a handle, not the mod object: {id, version, exports} or nil when the -- other mod is absent, disabled, failed, or has not run yet. Tolerates -- mod:find(id) as well as the documented mod.find(id). diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index ac5614634..09f2a1abb 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -279,6 +279,10 @@ function Manifest.validate(raw, path) return value end + assert(raw.callback_format == nil or raw.callback_format == "native" or raw.callback_format == "unified", + "callback_format must be native or unified") + assert(raw.callback_format ~= "unified" or api >= 2, "callback_format unified requires mod API 2") + local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible) -- `support`: where to thank the mod's creator -- a Ko-fi, a Patreon, a @@ -316,6 +320,7 @@ function Manifest.validate(raw, path) category = raw.category or "OTHER", game_version = raw.game_version, description = raw.description or "", + callback_format = raw.callback_format or "native", github = github, support = support, updateCheck = updateCheck, diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua new file mode 100644 index 000000000..ce1659a02 --- /dev/null +++ b/src/mods/UnifiedCallbacks.lua @@ -0,0 +1,214 @@ +local Records = require("src.mods.CallbackRecords") +local Ids = require("src.mods.UnifiedIds") +local C = {} +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function names(text) + local out = {} + for name in text:gmatch("%S+") do + out[name] = true + end + return out +end +-- This is an audited boundary catalog, not a claim that every game executes +-- every seam. The source-catalog test requires a decision for each new seam. +C.hooks = names([[ + battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award + battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate + encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility + input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed + music.select music.volume player.sprite pokemon.icon pokemon.sprite render.compose render.hud + render.letterbox render.zones save.new_game save.write script.command trainer.party transition.style + ui.list_menu ui.naming.grid ui.options.rows ui.party.submenu ui.pc.items ui.start_menu.items + ui.title_menu.items warp.destination world.tod zoom.range +]]) +C.events = names([[ + assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt battle.ended + battle.exp_gained battle.fainted battle.move_used battle.started battle.status_inflicted battle.turn_ended + battle.turn_started discord.join_requested flag.changed game.ready intro.birch_speech.finished + intro.birch_speech.started intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started + intro.oak_speech.step link.connected link.desync link.ended map.entered map.exited map.reloaded + mod.option_action mod.options_changed mods.loaded music.started music.stopped player.warped + pokemon.before_give pokemon.caught pokemon.egg_hatched pokemon.evolved pokemon.level_up + pokemon.move_learned pokemon.received save.created save.loaded save.loading save.writing screen.popped + screen.pushed script.ended script.started sound.played trade.completed world.blacked_out + world.block_replaced world.blocks_replaced world.boulder_moved world.interacted world.npc_spawned + world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged +]]) +local positions = { + ["battle.accuracy"] = { "context" }, + ["battle.damage"] = { "context" }, + ["battle.crit"] = { "context" }, + ["battle.run"] = { "context" }, + ["battle.catch_exp"] = { "context" }, + ["battle.low_health_alarm"] = { "context" }, + ["battle.exp_award"] = { "context" }, + ["exp.gain"] = { "context" }, + ["battle.turn_order"] = { "battler", "move", "battler", "move", "context" }, + ["battle.enemy_action"] = { false, "battler" }, + ["catch.rate"] = { "items", "mon", "species", "context" }, + ["trainer.party"] = { false, false, "trainerParty" }, + ["evolution.check"] = { false, "mon", "context", "context" }, + ["fieldmove.eligibility"] = { "moves", false }, + ["encounter.fishing"] = { "items", false, "candidates" }, + ["encounter.roll"] = { false, "context" }, + ["encounter.species"] = { "encounter", "context" }, + ["ui.party.submenu"] = { false, false, "mon", false }, + ["pokemon.sprite"] = { false, "context" }, + ["pokemon.icon"] = { false, "context" }, +} +local results = { + ["fieldmove.eligibility"] = "mon", + ["battle.enemy_action"] = "action", + ["trainer.party"] = "trainerParty", + ["battle.turn_order"] = "battler", + ["encounter.fishing"] = "encounter", + ["encounter.roll"] = "encounter", + ["encounter.species"] = "encounter", +} +-- These payloads are engine handles, repair flows or UI objects, not record +-- notifications. In particular, game.ready has a forwarding metatable. +local nativeEvents = names([[ + game.ready save.created save.loaded save.loading save.writing mods.loaded + screen.popped screen.pushed intro.birch_speech.finished intro.birch_speech.started + intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started intro.oak_speech.step + mod.option_action mod.options_changed +]]) +local function custom(name) + return type(name) == "string" and name:match("^mod%.[%w_%-]+%..+$") ~= nil +end +function C.install(loader, api, owner, generation) + -- Capture pristine alias identities before Loader merges them into separate + -- records. Each call still builds a fresh index, including later mods' IDs. + local ids = Ids.new(loader) + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + ids:entries(kind) + end + local function check(catalog, name) + assert( + type(name) == "string" and (catalog[name] or (catalog == C.events and custom(name))), + "unknown unified callback: " .. tostring(name) + ) + end + local function subscribe(once, name, callback, priority) + check(C.events, name) + assert(type(callback) == "function", "event callback must be a function") + local wrapped = callback + if not custom(name) and not nativeEvents[name] then + wrapped = function(payload) + local boundary = Records.new(loader, name, generation) + local public = boundary.view(payload, name == "pokemon.before_give" and "gift" or "context") + local values = pack(callback(public)) + if name == "pokemon.before_give" then + boundary.commit() + end + return unpack(values, 1, values.n) + end + end + if once then + return loader.events:once(name, wrapped, priority, owner) + end + return loader.events:on(name, wrapped, priority, owner) + end + api.events = { + on = function(_, ...) + return subscribe(false, ...) + end, + once = function(_, ...) + return subscribe(true, ...) + end, + emit = function(_, name, payload) + local prefix = "mod." .. owner .. "." + assert(custom(name) and name:sub(1, #prefix) == prefix, "mods may only emit " .. prefix .. "* events") + return loader.events:emit(name, payload) + end, + } + api.hooks = { + wrap = function(_, name, callback, priority) + check(C.hooks, name) + assert(type(callback) == "function", "hook callback must be a function") + return loader.hooks:wrap(name, function(nextFn, ...) + local boundary = Records.new(loader, name, generation) + local roles = positions[name] or {} + local function translate(args) + local out = { n = args.n } + for i = 1, args.n do + local role = roles[i] + local v = args[i] + if role == "items" or role == "moves" then + v = boundary.read(role, v) + elseif role then + v = boundary.view(v, role) + end + out[i] = v + end + return out + end + local original = pack(...) + local function rawArgs(args) + local out = { n = args.n } + for i = 1, args.n do + local role, v = roles[i], args[i] + if role == "items" or role == "moves" then + v = boundary.native(role, v, original[i]) + elseif role then + v = boundary.restore(v, role) + end + out[i] = v + end + return out + end + local public = translate(original) + local called, downstream, publicResult = false, nil, nil + local active = true + local function nextPublic(...) + assert(active, "next is only valid during its hook callback") + assert(not called or downstream, "next is already running") + if not called then + local args = select("#", ...) == 0 and public or pack(...) + local raw = boundary.commit(function() + return rawArgs(args) + end) + called = true + downstream = pack(pcall(nextFn, unpack(raw, 1, raw.n))) + if downstream[1] then + boundary.refresh() + publicResult = pack(unpack(downstream, 2, downstream.n)) + if results[name] then + publicResult[1] = boundary.view(publicResult[1], results[name]) + end + end + end + if not downstream[1] then + error(downstream[2], 0) + end + return unpack(publicResult, 1, publicResult.n) + end + local invoked = pack(pcall(callback, nextPublic, unpack(public, 1, public.n))) + active = false + if not invoked[1] then + boundary.close() + error(invoked[2], 0) + end + local values = pack(unpack(invoked, 2, invoked.n)) + if called and not downstream[1] then + boundary.close() + error(downstream[2], 0) + end + local ok, err = pcall(boundary.commit, function() + if results[name] then + values[1] = boundary.restore(values[1], results[name]) + end + end) + boundary.close() + if not ok then + error(err, 0) + end + return unpack(values, 1, values.n) + end, priority, owner) + end, + } +end +return C diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua new file mode 100644 index 000000000..50866a636 --- /dev/null +++ b/src/mods/UnifiedIds.lua @@ -0,0 +1,85 @@ +-- Resolve portable names through this boot's own data, never a hard-coded +-- National Dex/item-number table (ROM hacks can assign entirely different IDs). +local Ids = {} +local function slug(name) + if type(name) ~= "string" then return nil end + name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M") + :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2") + return name ~= "" and name or nil +end +local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" } +local function canonical(key, row, kind) + if kind == "pokemon" or kind == "moves" or kind == "items" then + if type(key) ~= "string" or key:match("^SPECIES_%d+$") + or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then + return aliases[slug(row.name)] or slug(row.name) + end + return aliases[key] or key + end + -- Trainer classes, individual trainers and map IDs are game-specific. + return type(key) == "number" and tostring(key) or key +end + +function Ids.new(loader) + local indexes = {} + local function index(kind) + if indexes[kind] then return indexes[kind] end + local out = { public = {}, native = {} } + indexes[kind] = out + local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind)) + -- Preserve identity/name metadata before the loader merges alias keys + -- into separate result tables. Presentation-name patches cannot rename IDs. + loader._unifiedIdBase = loader._unifiedIdBase or {} + local base = loader._unifiedIdBase[kind] + if not base then + base = {} + for key, row in pairs(registry.base and registry.base() or {}) do + if type(row) == "table" then + base[key] = { identity = row, id = canonical(key, row, kind) } + end + end + loader._unifiedIdBase[kind] = base + end + local groups = {} + for key, row in registry:each() do + if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then + -- Aliases pointing at one pristine record form one identity even + -- after an earlier native mod patched only one of its keys. + local identity = base[key] and base[key].identity or row + local group = groups[identity] + if not group then + local id = base[key] and base[key].id or canonical(key, row, kind) + if id then + group = { id = id, key = key, keys = {} } + groups[identity] = group + local previous = out.public[id] + -- false is an ambiguity marker, never last-writer-wins. + if previous ~= nil then out.public[id] = false else out.public[id] = group end + end + end + if group then + group.keys[#group.keys + 1] = key + out.native[key] = group + if type(key) == "number" then group.key = key end + end + end + end + return out + end + return { + resolve = function(_, kind, id) + local found = index(kind).public[id] + if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end + if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end + return found + end, + read = function(_, kind, id) + if id == nil or id == false or id == 0 or id == "" then return nil end + local found = index(kind).native[id] + return found and found.id or tostring(id) + end, + entries = function(_, kind) return index(kind).public end, + } +end +return Ids diff --git a/tests/engine/gate_meta_coverage.lua b/tests/engine/gate_meta_coverage.lua index 2c29d47cc..d3bbee83b 100644 --- a/tests/engine/gate_meta_coverage.lua +++ b/tests/engine/gate_meta_coverage.lua @@ -111,13 +111,11 @@ local DEBT = { ["hook:encounter.fishing"] = "M5", ["hook:render.zones"] = "M9", - ["hook:trainer.party"] = "M7", ["hook:ui.pc.items"] = "M8", ["event:link.connected"] = "M12", ["event:link.ended"] = "M12", ["event:player.warped"] = "M5", - ["event:pokemon.before_give"] = "M7", ["event:pokemon.evolved"] = "M7", ["event:pokemon.level_up"] = "M7", ["event:pokemon.move_learned"] = "M7", diff --git a/tests/engine/unified_mod_callbacks.lua b/tests/engine/unified_mod_callbacks.lua new file mode 100644 index 000000000..a4059d663 --- /dev/null +++ b/tests/engine/unified_mod_callbacks.lua @@ -0,0 +1,634 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local Manifest = require("src.mods.Manifest") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Catalog = require("tests.modkit.catalog") +local C = require("src.mods.UnifiedCallbacks") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love } +love = nil +local function manifest(format, api) + return { + id = "probe", + name = "Probe", + version = "1.0.0", + entry = "main.lua", + api = api or 2, + callback_format = format, + } +end +T.eq(Manifest.validate(manifest()).callback_format, "native", "existing manifests keep native callbacks") +T.eq(Manifest.validate(manifest("unified")).callback_format, "unified", "opt-in accepted") +T.raises(function() + Manifest.validate(manifest("unified", 1)) +end, "requires mod API 2", "unified requires API 2") +T.raises(function() + Manifest.validate(manifest("other")) +end, "callback_format", "unknown format rejected") +for _, invalid in ipairs({ false, 42, {} }) do + T.raises(function() + Manifest.validate(manifest(invalid)) + end, "callback_format", "malformed format rejected") +end +local function set(names) + local out = {} + for _, n in ipairs(names) do + out[n] = true + end + return out +end +T.same(C.hooks, set(Catalog.hooks()), "every native hook has an explicit boundary decision") +T.same(C.events, set(Catalog.events()), "every native event has an explicit boundary decision") +local function boot(version, format, entry) + Version.set(version) + local numeric = Version.generation() >= 4 + local a, b, m, n, item = + numeric and 501 or "ALPHA", + numeric and 502 or "BETA", + numeric and 601 or "TACKLE", + numeric and 602 or "EMBER", + numeric and 701 or "POTION" + local data = { + pokemon = { + [a] = { + id = a, + name = "Alpha", + baseStats = { hp = 40, attack = 45, defense = 40, speed = 30, special = 35 }, + types = { + "NORMAL", + }, + }, + [b] = { id = b, name = "Beta", baseStats = { hp = 50 }, types = { "NORMAL" } }, + }, + moves = { + [m] = { id = m, name = "Tackle", pp = 35, power = 40 }, + [n] = { + id = n, + name = "Ember", + pp = 25, + power = 40, + }, + }, + items = { [item] = { id = item, name = "Potion" } }, + trainers = {}, + encounters = {}, + } + if numeric then + data.pokemon.SPECIES_501 = data.pokemon[a] + data.moves.MOVE_601 = data.moves[m] + end + local json = require("src.link.Json") + local files = { + ["mods/probe/manifest.json"] = json.encode(manifest(format)), + ["mods/probe/main.lua"] = entry or "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + loader._game = function() + error("callback opt-in must not construct Game") + end + local loaded = loader:load(data) + if not entry then + assert(loaded) + assert(loader.apis.probe, tostring(loader.errors.probe)) + end + return loader.apis.probe, loader, data, { a, b, m, n, item } +end +local function mon(ids) + return { + species = ids[1], + item = ids[5], + level = 10, + hp = 25, + status = "PSN", + stats = { hp = 40, attack = 21, defense = 22, speed = 23, special = 24, spatk = 24, spdef = 25 }, + moves = { { id = ids[3], pp = 12 } }, + ivs = { hp = 17 }, + personality = 123, + custom = { keep = true }, + } +end +for _, version in ipairs(Version.ORDER) do + local api, loader, data, ids = boot(version, "unified") + local label = version .. ": " + T.eq(api.callbackFormat, "unified", label .. "format exposed before Game construction") + local raw = mon(ids) + if Version.generation() == 1 then + raw.stats.spatk = nil + raw.stats.spdef = nil + end + local unchanged = require("src.mods.Merge").deepCopy(raw) + local noop = api.hooks:wrap("battle.damage", function(next) + return next() + end) + loader.hooks:call("battle.damage", function() + return 5 + end, { user = { mon = raw } }) + T.same(raw, unchanged, label .. "no-op unified callback leaves native record exactly unchanged") + T.eq(raw.isEgg, nil, label .. "false view flag does not materialize native field") + noop() + local originalStats, originalMoves, originalSlot = raw.stats, raw.moves, raw.moves[1] + local battler = + { mon = raw, isPlayer = true, curStats = raw.stats, curMoves = raw.moves, stages = { attack = 0 } } + local battle = { tag = "native handle" } + local count = 0 + local off = api.hooks:wrap("battle.damage", function(next, ctx) + T.eq(ctx.battle, battle, label .. "battle stays native") + T.eq(ctx.user.mon, ctx.target.mon, label .. "shared mon preserves public alias") + T.eq(ctx.user.mon.species, "ALPHA", label .. "canonical species") + T.eq(ctx.user.mon.item, "POTION", label .. "canonical held item") + T.eq(ctx.user.mon.moves[1].id, "TACKLE", label .. "canonical moves") + T.eq(ctx.user.mon.status, "poison", label .. "canonical status") + T.eq(ctx.user.mon.maxHp, 40, label .. "maxHp reads stats.hp") + T.eq(ctx.user.mon.stats.specialAttack, 24, label .. "canonical special attack") + ctx.user.mon.hp = 24 + ctx.user.mon.status = "burn" + ctx.user.mon.nickname = "Test" + ctx.user.mon.stats.attack = 26 + ctx.user.mon.moves[1].id = "EMBER" + ctx.user.mon.moves[1].pp = 20 + local n, hole, last = next() + T.eq(ctx.user.mon.hp, 19, label .. "native downstream changes refreshed") + T.eq(select("#", next()), 3, label .. "cached next retains nil holes") + ctx.user.mon.hp = 18 + return n + 1, hole, last + end) + local n, hole, last = loader.hooks:call("battle.damage", function(ctx) + count = count + 1 + T.eq(ctx.user, battler, label .. "native battler identity restored") + T.eq(ctx.user.mon, raw, label .. "native mon identity restored") + T.eq(raw.hp, 24, label .. "pre-next mutation committed") + T.eq(raw.status, "BRN", label .. "status written in native vocabulary") + T.eq(raw.moves[1].id, ids[4], label .. "move ID restored for host") + raw.hp = 19 + return 5, nil, "tail" + end, { battle = battle, user = battler, target = battler, move = data.moves[ids[3]] }) + T.eq(n, 6, label .. "scalar result postprocessing") + T.eq(last, "tail", label .. "result tail preserved") + T.eq(hole, nil, label .. "nil preserved") + T.eq(count, 1, label .. "vanilla runs once") + T.eq(raw.hp, 18, label .. "post-next edit committed") + T.eq(raw.stats, originalStats, label .. "stats table identity preserved") + T.eq(raw.moves, originalMoves, label .. "moves list identity preserved") + T.eq(raw.moves[1], originalSlot, label .. "move slot identity preserved") + T.eq(raw.personality, 123, label .. "hidden personality retained") + T.eq(raw.ivs.hp, 17, label .. "hidden IVs retained") + off() + + -- Mutations across separate bound records preflight as one transaction. + local other = mon(ids) + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.hp = 4 + ctx.target.mon.level = 101 + return 99 + end) + count = 0 + T.eq( + loader.hooks:call("battle.damage", function() + count = count + 1 + return 3 + end, { user = battler, target = { mon = other } }), + 3, + label .. "invalid edit skips wrapper" + ) + T.eq(raw.hp, 18, label .. "earlier record edit rolled back") + T.eq(other.level, 10, label .. "invalid record unchanged") + T.eq(count, 1, label .. "failure before next falls through once") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + local n = next() + ctx.user.mon.hp = -1 + return n + 20 + end) + count = 0 + T.eq( + loader.hooks:call("battle.damage", function() + count = count + 1 + raw.hp = 16 + return 7 + end, { user = battler }), + 7, + label .. "bad post-next edit keeps downstream result" + ) + T.eq(raw.hp, 16, label .. "downstream mutation retained") + T.eq(count, 1, label .. "bad post-next never retries") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + next() + ctx.user.mon.hp = 2 + error("after next") + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 8 + end, { user = battler }), + 8, + label .. "throw after next preserves result" + ) + T.eq(raw.hp, 16, label .. "throw after next discards late edits") + off() + + local sentinel = { error = "vanilla" } + off = api.hooks:wrap("battle.damage", function(next) + pcall(next) + pcall(next) + return 999 + end) + count = 0 + local ok, err = pcall(function() + loader.hooks:call("battle.damage", function() + count = count + 1 + error(sentinel) + end, { user = battler }) + end) + T.check(not ok and err == sentinel, label .. "vanilla error identity survives swallowed next failure") + T.eq(count, 1, label .. "throwing vanilla never replayed") + off() + off = api.hooks:wrap("battle.damage", function(next) + pcall(next) + return 999 + end) + local savedMoves = raw.moves + local okBroken, errBroken = pcall(function() + loader.hooks:call("battle.damage", function() + raw.moves = 42 -- a failed native operation can leave an unreadable record + error(sentinel) + end, { user = battler }) + end) + T.check(not okBroken and errBroken == sentinel, label .. "refresh cannot mask a native error") + raw.moves = savedMoves + off() + + -- Event records are detached. The native handles deliberately remain handles. + local seen = 0 + off = api.events:once("battle.exp_gained", function(payload) + seen = seen + 1 + T.eq(payload.mon.species, "ALPHA", label .. "event mon normalized") + payload.mon.hp = 1 + payload.mon.native.ivs.hp = 1 + end) + loader.events:emit("battle.exp_gained", { mon = raw, battle = battle, gained = 20 }) + loader.events:emit("battle.exp_gained", { mon = raw, battle = battle, gained = 20 }) + T.eq(seen, 1, label .. "once retained") + T.eq(raw.hp, 16, label .. "event mutation detached") + T.eq(raw.ivs.hp, 17, label .. "native diagnostic snapshot detached") + off() + local gift = { species = ids[1], level = 5, ctx = { opaque = true } } + off = api.events:on("pokemon.before_give", function(p) + p.species = "BETA" + p.level = 15 + p.nickname = "Gift" + end) + loader.events:emit("pokemon.before_give", gift) + T.eq(gift.species, ids[2], label .. "gift ID restored") + T.eq(gift.level, 15, label .. "gift level committed") + T.eq(gift.nickname, "Gift", label .. "gift nickname committed") + off() + off = api.events:on("pokemon.before_give", function(p) + p.species = "ALPHA" + p.level = 101 + end) + loader.events:emit("pokemon.before_give", gift) + T.eq(gift.species, ids[2], label .. "invalid gift atomic species") + T.eq(gift.level, 15, label .. "invalid gift atomic level") + off() + + off = api.hooks:wrap("battle.turn_order", function(next, p, pm, e, em, ctx) + T.eq(pm.id, "TACKLE", label .. "move definition canonical ID") + T.eq(next(), p, label .. "native result maps to original public battler") + return e + end) + local enemy = { mon = other, isPlayer = false } + T.eq( + loader.hooks:call("battle.turn_order", function(p) + return p + end, battler, data.moves[ids[3]], enemy, data.moves[ids[4]], {}), + enemy, + label .. "selected battler restores identity" + ) + off() + off = api.hooks:wrap("encounter.species", function(next, enc) + enc.species = "BETA" + enc.level = 20 + return next() + end) + local encounter = { species = ids[1], level = 10, form = 2 } + T.eq( + loader.hooks:call("encounter.species", function(enc) + return enc + end, encounter, {}), + encounter, + label .. "encounter identity retained" + ) + T.eq(encounter.species, ids[2], label .. "encounter species converted") + T.eq(encounter.form, 2, label .. "encounter hidden field retained") + off() + off = api.hooks:wrap("trainer.party", function(next, cl, index, party) + T.eq(party[1].species, "ALPHA", label .. "trainer slot canonical species") + T.eq(party[1].moves[1], "TACKLE", label .. "trainer slot moves are IDs") + party[1].species = "BETA" + party[1].moves = { "EMBER" } + party[1].level = 20 + return next() + end) + local party = { { species = ids[1], level = 10, moves = { ids[3] }, dvs = { attack = 15 } } } + T.eq( + loader.hooks:call("trainer.party", function(_, _, p) + return p + end, "CLASS", 1, party), + party, + label .. "trainer party native identity" + ) + T.eq(party[1].species, ids[2], label .. "trainer species restored") + T.eq(party[1].moves[1], ids[4], label .. "trainer move restored") + T.eq(party[1].dvs.attack, 15, label .. "trainer hidden DVs retained") + off() + + count = 0 + off = api.hooks:wrap("battle.exp_award", function(_, ctx) + local p = ctx.alive[1] + p.hp = 15 + ctx.applyShare(p, 2, false) + T.eq(p.level, 11, label .. "applyShare refreshes public mon") + p.hp = 14 + end) + loader.hooks:call("battle.exp_award", function() + error("wrapper replaces vanilla") + end, { + battle = battle, + alive = { raw }, + applyShare = function(p, divisor, announce) + count = count + 1 + T.eq(p, raw, label .. "helper gets native identity") + T.eq(p.hp, 15, label .. "helper sees pending edits") + T.eq(divisor, 2, label .. "helper scalar args") + T.eq(announce, false, label .. "helper false arg") + p.level = 11 + end, + }) + T.eq(count, 1, label .. "helper called once") + T.eq(raw.hp, 14, label .. "helper postprocessing committed") + off() + + off = api.hooks:wrap("fieldmove.eligibility", function(next, id) + T.eq(id, "TACKLE", label .. "field move argument normalized") + local p = next() + T.eq(p.species, "ALPHA", label .. "field move result normalized") + return p + end) + T.eq( + loader.hooks:call("fieldmove.eligibility", function(id) + T.eq(id, ids[3], label .. "field move argument restored") + return raw + end, ids[3], {}), + raw, + label .. "field move mon result identity restored" + ) + off() + off = api.hooks:wrap("battle.enemy_action", function(next) + local action = next() + T.eq(action.id, "TACKLE", label .. "enemy action normalized") + action.id = "EMBER" + return action + end) + local action = { id = ids[3], index = 1 } + T.eq( + loader.hooks:call("battle.enemy_action", function() + return action + end, battle, battler), + action, + label .. "enemy action identity restored" + ) + T.eq(action.id, ids[4], label .. "enemy action ID restored") + off() + off = api.hooks:wrap("encounter.roll", function() + return { species = "BETA", level = 30 } + end) + local enc = loader.hooks:call("encounter.roll", function() + error("suppressed") + end, nil, {}) + T.eq(enc.species, ids[2], label .. "new encounter result native species") + T.eq(enc.level, 30, label .. "new encounter level") + off() + off = api.hooks:wrap("encounter.roll", function() + return { species = "MISSING", level = 30 } + end) + T.eq( + loader.hooks:call("encounter.roll", function() + return "fallback" + end, nil, {}), + "fallback", + label .. "unknown species rejected" + ) + off() + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.move.power = 999 + return 88 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 4 + end, { move = data.moves[ids[3]] }), + 4, + label .. "definition mutation rejected" + ) + T.eq(data.moves[ids[3]].power, 40, label .. "definition unchanged") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + ctx.user.mon.maxHp = 50 + ctx.user.mon.stats.specialAttack = 28 + next() + return 0 + end) + loader.hooks:call("battle.damage", function() + T.eq(raw.stats.hp, 50, label .. "maxHp writes native stats.hp") + T.eq(raw.stats.special, 28, label .. "special alias kept") + T.eq( + Version.generation() == 1 and raw.stats.special or raw.stats.spatk, + 28, + label .. "split special stat writes correctly" + ) + end, { user = battler }) + off() + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.native.ivs.hp = 9 + ctx.user.mon.hp = 2 + return 88 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 3 + end, { user = battler }), + 3, + label .. "native snapshot mutation rejected" + ) + T.eq(raw.hp, 14, label .. "snapshot rejection atomic") + off() + local nativeCalls = 0 + local undoNative = loader.hooks:wrap("battle.damage", function(next, ctx) + nativeCalls = nativeCalls + 1 + T.eq(ctx.user, battler, label .. "native mod sees native battler") + return next() + end, -5, "native") + off = api.hooks:wrap("battle.damage", function(next, ctx) + -- Explicit argument packs accept bound records inside a bound context. + ctx.extra = 123 + return next(ctx) + end, 5) + loader.hooks:call("battle.damage", function(ctx) + T.eq(ctx.extra, 123, label .. "explicit next args committed") + end, { user = battler }) + T.eq(nativeCalls, 1, label .. "mixed native/unified chain") + off() + undoNative() + local order = {} + local a = api.events:on("battle.started", function() + order[#order + 1] = "low" + end, -1) + local b = api.events:on("battle.started", function() + order[#order + 1] = "high" + end, 1) + loader.events:emit("battle.started", {}) + T.same(order, { "high", "low" }, label .. "event priority retained") + a() + b() + local actionView + off = api.events:on("battle.turn_started", function(p) + actionView = p.playerAction + end) + loader.events:emit("battle.turn_started", { playerAction = { id = ids[3], index = 1 } }) + T.eq(actionView.id, "TACKLE", label .. "turn event action ID normalized") + off() + + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.moves = { [2] = { id = "TACKLE", pp = 1, ppUps = 0 } } + ctx.user.mon.hp = 1 + return 99 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 2 + end, { user = battler }), + 2, + label .. "sparse move list rejected" + ) + T.eq(raw.hp, 14, label .. "sparse move list fails atomically") + off() + local retainedNext, retainedShare + off = api.hooks:wrap("battle.exp_award", function(next, ctx) + retainedNext = next + retainedShare = ctx.applyShare + end) + loader.hooks:call("battle.exp_award", function() + error("must not run") + end, { + alive = { raw }, + applyShare = function() + error("must not run") + end, + }) + T.raises(function() + retainedNext() + end, "only valid during", label .. "retained next cannot run after callback") + T.raises(function() + retainedShare(raw) + end, "bound Pokemon", label .. "helper rejects native mon") + off() + local heldMon + off = api.hooks:wrap("battle.exp_award", function(_, ctx) + heldMon = ctx.alive[1] + retainedShare = ctx.applyShare + end) + loader.hooks:call("battle.exp_award", function() end, { + alive = { raw }, + applyShare = function() + error("must not run") + end, + }) + T.raises(function() + retainedShare(heldMon) + end, "closed", label .. "retained helper cannot write after callback") + off() + + local custom = { mon = raw } + local got + off = api.events:on("mod.other.ready", function(p) + got = p + end) + loader.events:emit("mod.other.ready", custom) + T.eq(got, custom, label .. "custom payload identity untouched") + off() + T.raises(function() + api.events:emit("mod.other.ready", {}) + end, "only emit", label .. "cannot spoof other mod") + T.raises(function() + api.events:emit("mod.probe.", {}) + end, "only emit", label .. "empty custom suffix rejected") + T.raises(function() + api.hooks:wrap("invented.hook", function() end) + end, "unknown unified", label .. "unknown hook rejected") + T.raises(function() + api.events:on("invented.event", function() end) + end, "unknown unified", label .. "unknown event rejected") +end +-- The native path must not install any wrapper or copy its payloads. +local api, loader = boot("crystal") +T.eq(api.callbackFormat, "native", "default API native") +local raw = { mon = { species = 42 } } +api.events:on("custom.engine", function(p) + T.eq(p, raw, "native event identity unchanged") +end) +loader.events:emit("custom.engine", raw) +api.hooks:wrap("custom.hook", function(next, ctx) + T.eq(ctx, raw, "native hook identity unchanged") + return next() +end) +T.eq( + loader.hooks:call("custom.hook", function(ctx) + return ctx + end, raw), + raw, + "native result identity unchanged" +) +-- Entry rollback removes unified subscriptions using the same native owner. +local _, failed = boot( + "crystal", + "unified", + [[return function(mod) + mod.events:on('battle.started',function() end) + mod.hooks:wrap('battle.damage',function() end) + error('rollback probe') +end]] +) +T.check(#failed.errors > 0, "failed entry attributed") +T.check( + not failed.events.listeners["battle.started"] or #failed.events.listeners["battle.started"] == 0, + "failed entry event removed" +) +T.check( + not failed.hooks.chains["battle.damage"] or #failed.hooks.chains["battle.damage"] == 0, + "failed entry hook removed" +) +Version.set(saved[1]) +Runtime.install(saved[2], saved[3], saved[4]) +love = saved[5] +T.finish() From 886fca58204c5043f31a6d8f5e325c3cd0d3d4bf Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:17:55 +0200 Subject: [PATCH 005/138] Add detached read-only world and party queries --- docs/modding.md | 8 + docs/world-read-api.md | 151 ++++++++++++ src/mods/Loader.lua | 13 +- src/mods/UnifiedIds.lua | 85 +++++++ src/mods/WorldReads.lua | 227 +++++++++++++++++ src/world/WorldAPI.lua | 27 ++- tests/engine/world_read_api.lua | 415 ++++++++++++++++++++++++++++++++ 7 files changed, 923 insertions(+), 3 deletions(-) create mode 100644 docs/world-read-api.md create mode 100644 src/mods/UnifiedIds.lua create mode 100644 src/mods/WorldReads.lua create mode 100644 tests/engine/world_read_api.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..8743187dd 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -70,6 +70,14 @@ is a normal answer rather than a failure, and telling it apart from per frame, and a directory walk over several hundred blobs per frame is not something a menu can afford. +## Read-only world and party queries + +`mod.world:party()`, `activeBlockAt(mapId, bx, by)` and `liveMaps()` expose +common Pokémon snapshots and current runtime map data across generations +1–5. Reads return detached data and do not load maps or change gameplay. +See [record fields, coordinate units and DS limitations](world-read-api.md). +The existing `loadedMaps()` method retains its legacy shape. + ## Editing maps in Tiled Maps are data, not assets, so they can be authored in a real map editor and diff --git a/docs/world-read-api.md b/docs/world-read-api.md new file mode 100644 index 000000000..01b2a60d0 --- /dev/null +++ b/docs/world-read-api.md @@ -0,0 +1,151 @@ +# Read-only world and party queries + +Native mods can read `mod.world:party()`, `activeBlockAt(mapId, bx, by)` and +`liveMaps()` across generations 1–5. These are additive methods: no manifest +format switch or Mod API bump is required. Their records use the common live +Pokémon shape from unified callbacks, independently of the mod's selected +content/callback format. Existing world actions and `loadedMaps()` retain +their previous behavior. + +The methods read the current save/runtime map when called. They do not load +maps, decode whole packed grids, create Pokémon, emit events, write state or +advance random generators. Returned tables are detached plain data; editing +or retaining them never changes the game. Native engine handles remain +available through the existing APIs, but these three methods return none. + +```lua +return function(mod) + mod.events:on("world.stepped", function() + local here = mod.world:current() + if not here then return end + + local party, reason = mod.world:party() + if not party then mod.log:warn("%s", reason); return end + + local maps = mod.world:liveMaps() + local geometry = maps and maps[1].block + if geometry then + -- current().x/y use walk cells; activeBlockAt uses map blocks. + local cellsPerBlock = geometry.tileSize / geometry.cellSize + local block, info = mod.world:activeBlockAt(here.mapId, + math.floor(here.x / cellsPerBlock), math.floor(here.y / cellsPerBlock)) + if block ~= nil then mod.log:info("%s block %d", info.kind, block) end + end + end) +end +``` + +## Party snapshots + +`party()` returns an array in native party order. A save with an empty or +absent party gives `{}`; no save gives `nil, "no save"`. Reading a saved party +does not require an active overworld, so menus and a title-screen save are +valid contexts. A retained facade observes a replacement of `game.save` on +its next call; previously returned records remain snapshots. + +Each Pokémon has the following common fields (optional native values may +be absent): + +| Field | Meaning | +| --- | --- | +| `species`, `item` | Symbolic IDs; an absent/zero held item gives `nil` | +| `nickname`, `name` | Native nickname and optional native name | +| `level`, `hp`, `maxHp` | Current values; `maxHp` is native `stats.hp` | +| `status` | `sleep`, `poison`, `burn`, `freeze`, `paralyze`, `toxic`, or absent; unknown native status labels are retained | +| `isEgg` | Native egg flag as a boolean | +| `stats` | `hp`, `attack`, `defense`, `speed`, `special`, `specialAttack`, `specialDefense` | +| `moves` | Array of `{ id, pp, maxPp, ppUps }`; absent definitions leave `maxPp` absent | +| `native` | Detached data-only diagnostics, including IVs/EVs and custom save fields | + +IDs use this boot's actual registries, including custom records and numeric +DS IDs, rather than hard-coded National Dex numbers. Numeric IDs resolve via +imported names; existing named keys retain their names. Standard aliases use +the shared unified-ID resolver. Identity/name metadata is captured before +mod merging, so a display-name patch does not rename a canonical ID. Records +registered later during mod initialization remain visible. Unknown IDs are +preserved as strings; this is a read-only view, not an allocation or reference +validation API. The native diagnostics retain the underlying numeric IDs. + +Gen 1's single special stat fills both special-attack and special-defense +fields; later split stats read native `spatk` and `spdef`. PP maxima use native +PP-Up rounding (`base + ppUps * floor(base / 5)`). Reads never recalculate +stats, learn moves, infer shininess or consume RNG. Functions, userdata, +threads, metatables and table-valued keys are excluded from diagnostics; +cycles in data are copied without retaining native tables. + +## Active block lookup + +`activeBlockAt(mapId, bx, by)` returns `blockId, metadata`, or `nil, reason`. +The expected map ID is required: a request prepared before a warp cannot +silently inspect the replacement map. Only the active runtime map is read. +Coordinates are finite, nonnegative integers in **zero-based block units**; +off-map coordinates are refused rather than returning the map's border fill. +A block ID of zero is a valid result: check `blockId ~= nil`. + +Runtime `blockPatch` entries take precedence, including zero-valued changes. +Table grids, packed little-endian 16-bit grids and an existing decoded cache +follow the runtime Map representation. A cold packed read extracts only the +requested word and its low ten ID bits; it does not populate `_blockArray`. +Missing/truncated data, negative/invalid IDs and inconsistent geometry give +`nil, reason`. No call into map loading, rendering or collision is made. + +Metadata is a fresh record: + +| Field | Units / meaning | +| --- | --- | +| `tileSize` | Pixels per map block, from runtime `blockTiles * 8` | +| `cellSize` | 16 pixels per walk cell | +| `coordinateBase` | `0` | +| `width`, `height` | Map dimensions in blocks | +| `kind` | `block`, `metatile`, or `runtime_cell` | + +Ordinary Gen 1/2 blocks are 32 pixels; Gen 3 metatiles are 16 pixels. Geometry +comes from the actual runtime tileset so custom maps need not match their +selected generation's usual size. Gen 4/5 imported maps use a 16-pixel proxy +grid in the shared runtime; `runtime_cell` explicitly identifies that grid. +Its values are synthetic runtime/behavior values, **not DS mesh/model IDs or +a public collision/elevation encoding**. Use the existing terrain/elevation +services for those questions. A DS map must carry the runtime grid; this +method does not generate one from meshes or lazily load shared layouts. + +## Loaded map snapshots + +`liveMaps()` returns only maps already held by the active overworld, with the +active map first and neighbors in native order. It never enumerates all ROM +maps or loads missing neighbors. No overworld gives `nil, "no overworld"`. +Each row contains: + +```lua +{ + mapId = "MAP_ID", active = true, + x = 0, y = 0, -- placement relative to active map, walk cells + width = 20, height = 18, -- dimensions in walk cells + block = { -- absent if runtime geometry is unavailable + tileSize = 32, cellSize = 16, coordinateBase = 0, + width = 10, height = 9, kind = "block" -- dimensions in blocks + } +} +``` + +Neighbor offsets may be negative. Exact duplicate placements collapse; +distinct placements of one map at different offsets remain separate rows. +Each row carries its own geometry, including mixed-size neighborhoods. +The legacy `loadedMaps()` method keeps its original shape, duplicates and +order for existing consumers. + +## Availability and tests + +When the optional `mod:supports` capability-query feature is installed, +`mod:supports("world", "party")`, `activeBlockAt` and `liveMaps` report true. +Probes do not construct Game or a world. Availability describes the method; +a call can still return `no save`, `no overworld`, `map is not active`, invalid +coordinates, unavailable geometry or unavailable data. Other capability +answers retain their existing behavior. + +`tests/engine/world_read_api.lua` exercises the real loader and Map objects +without an imported ROM or a graphics context, across all supported version +IDs. It checks detached nested data, ID aliases and late registrations, +current save/map replacement, block sizes/bounds, cold/warm packed data, +runtime patches, map placements, native `loadedMaps()` parity and RNG/gameplay +tripwires. When the unified-callback feature is present, it also compares the +party shape against an actual event's Pokémon record. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..8ea12fc6e 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -20,6 +20,7 @@ local Semver = require("src.mods.Semver") local Events = require("src.mods.Events") local Hooks = require("src.mods.Hooks") local ModStorage = require("src.mods.Storage") +local WorldReads = require("src.mods.WorldReads") local Runtime = require("src.mods.Runtime") local ModGens = require("src.mods.ModGens") local ModSeams = require("src.mods.ModSeams") @@ -1125,6 +1126,16 @@ function Loader:_api(mod) -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. local world, storage + local readContext = WorldReads.context(loader) + -- Capability queries are optional on older API-2 hosts. When present, + -- report the additive readers without constructing Game or the world. + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "world" and WorldReads.supports(name) then return true end + return supports(self, surface, name) + end + end setmetatable(api, { __index = function(_, key) if key == "game" then return loader:_game() end if key == "storage" then @@ -1139,7 +1150,7 @@ function Loader:_api(mod) local game = loader:_game() local module = game and engineRequire("src.world.WorldAPI") if not module then return nil end - world = module.new(game, modId) + world = module.new(game, modId, readContext) return world end }) return api diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua new file mode 100644 index 000000000..50866a636 --- /dev/null +++ b/src/mods/UnifiedIds.lua @@ -0,0 +1,85 @@ +-- Resolve portable names through this boot's own data, never a hard-coded +-- National Dex/item-number table (ROM hacks can assign entirely different IDs). +local Ids = {} +local function slug(name) + if type(name) ~= "string" then return nil end + name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M") + :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2") + return name ~= "" and name or nil +end +local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" } +local function canonical(key, row, kind) + if kind == "pokemon" or kind == "moves" or kind == "items" then + if type(key) ~= "string" or key:match("^SPECIES_%d+$") + or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then + return aliases[slug(row.name)] or slug(row.name) + end + return aliases[key] or key + end + -- Trainer classes, individual trainers and map IDs are game-specific. + return type(key) == "number" and tostring(key) or key +end + +function Ids.new(loader) + local indexes = {} + local function index(kind) + if indexes[kind] then return indexes[kind] end + local out = { public = {}, native = {} } + indexes[kind] = out + local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind)) + -- Preserve identity/name metadata before the loader merges alias keys + -- into separate result tables. Presentation-name patches cannot rename IDs. + loader._unifiedIdBase = loader._unifiedIdBase or {} + local base = loader._unifiedIdBase[kind] + if not base then + base = {} + for key, row in pairs(registry.base and registry.base() or {}) do + if type(row) == "table" then + base[key] = { identity = row, id = canonical(key, row, kind) } + end + end + loader._unifiedIdBase[kind] = base + end + local groups = {} + for key, row in registry:each() do + if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then + -- Aliases pointing at one pristine record form one identity even + -- after an earlier native mod patched only one of its keys. + local identity = base[key] and base[key].identity or row + local group = groups[identity] + if not group then + local id = base[key] and base[key].id or canonical(key, row, kind) + if id then + group = { id = id, key = key, keys = {} } + groups[identity] = group + local previous = out.public[id] + -- false is an ambiguity marker, never last-writer-wins. + if previous ~= nil then out.public[id] = false else out.public[id] = group end + end + end + if group then + group.keys[#group.keys + 1] = key + out.native[key] = group + if type(key) == "number" then group.key = key end + end + end + end + return out + end + return { + resolve = function(_, kind, id) + local found = index(kind).public[id] + if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end + if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end + return found + end, + read = function(_, kind, id) + if id == nil or id == false or id == 0 or id == "" then return nil end + local found = index(kind).native[id] + return found and found.id or tostring(id) + end, + entries = function(_, kind) return index(kind).public end, + } +end +return Ids diff --git a/src/mods/WorldReads.lua b/src/mods/WorldReads.lua new file mode 100644 index 000000000..2ca8f2504 --- /dev/null +++ b/src/mods/WorldReads.lua @@ -0,0 +1,227 @@ +-- Data-only readers shared by mod.world. Nothing here constructs a Pokemon, +-- loads a map, calls gameplay methods or advances either random generator. +local Ids = require("src.mods.UnifiedIds") +local Reads = {} +local METHODS = { party = true, activeBlockAt = true, liveMaps = true } + +function Reads.supports(name) + return type(name) == "string" and METHODS[name] == true +end + +-- Capture native alias identity/name metadata before the loader splits alias +-- rows during merging. Fresh indexes at read time still include later mods. +function Reads.context(loader) + local ids = Ids.new(loader) + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + ids:entries(kind) + end + return { + ids = function() + return Ids.new(loader) + end, + } +end + +-- Engine callers can construct WorldAPI without a mod loader too. The +-- private registry facade only reads data; Ids' bookkeeping stays on it. +local function dataIds(data) + local loader = { content = {} } + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + local rows = type(data[kind]) == "table" and data[kind] or {} + loader.content[kind] = { + base = function() + return rows + end, + each = function() + return pairs(rows) + end, + } + end + return Ids.new(loader) +end + +local function copy(value, seen) + local kind = type(value) + if kind ~= "table" then + if kind == "number" or kind == "string" or kind == "boolean" then + return value + end + return nil + end + seen = seen or {} + if seen[value] then + return seen[value] + end + local out = {} + seen[value] = out + for key, sub in pairs(value) do + if type(key) == "string" or type(key) == "number" then + out[key] = copy(sub, seen) + end + end + return out +end +local STATUS = + { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local STAT = { + hp = "hp", + attack = "attack", + defense = "defense", + speed = "speed", + special = "special", + specialAttack = "spatk", + specialDefense = "spdef", +} + +-- The same common live-Pokemon shape as the unified callback boundary. +-- Native diagnostics are data-only copies, including unknown save extensions. +function Reads.party(game, context) + local save = game and game.save + if not save then + return nil, "no save" + end + local data = game.data or {} + local ids = context and context.ids() or dataIds(data) + local out = {} + for i, raw in ipairs(save.party or {}) do + local stats, moves = {}, {} + for key, native in pairs(STAT) do + stats[key] = (raw.stats or {})[native] + end + stats.specialAttack = stats.specialAttack or stats.special + stats.specialDefense = stats.specialDefense or stats.special + for slot, move in ipairs(raw.moves or {}) do + local id = type(move) == "table" and move.id or move + local def = (data.moves or {})[id] + local ups = type(move) == "table" and (move.ppUps or 0) or 0 + moves[slot] = { + id = ids:read("moves", id), + pp = type(move) == "table" and move.pp or nil, + maxPp = def and ((def.pp or 0) + ups * math.floor((def.pp or 0) / 5)) or nil, + ppUps = ups, + } + end + out[i] = { + species = ids:read("pokemon", raw.species), + item = ids:read("items", raw.item), + nickname = raw.nickname, + name = raw.name, + level = raw.level, + hp = raw.hp, + maxHp = raw.stats and raw.stats.hp, + status = STATUS[raw.status] or raw.status, + isEgg = raw.isEgg == true, + stats = stats, + moves = moves, + native = copy(raw), + } + end + return out +end + +local function integer(value, low) + return type(value) == "number" + and value == value + and value < math.huge + and value > -math.huge + and value % 1 == 0 + and value >= low +end + +-- Public block coordinates are zero-based, not walk-cell coordinates. Derive +-- geometry from this map's runtime tileset, not the selected game's number. +function Reads.metadata(map) + local def = map and map.def + if + not def + or not integer(def.width, 1) + or not integer(def.height, 1) + or not integer(map.blockCells, 1) + or not integer(map.blockTiles, 1) + or map.blockTiles * 8 ~= map.blockCells * 16 + then + return nil, "block geometry unavailable" + end + return { + tileSize = map.blockTiles * 8, + cellSize = 16, + coordinateBase = 0, + kind = (def.generation == 4 or def.generation == 5 or def.gen5) and "runtime_cell" + or map.blockCells == 1 and "metatile" + or "block", + width = def.width, + height = def.height, + } +end + +function Reads.activeBlockAt(map, mapId, bx, by) + if map.id ~= mapId then + return nil, "map is not active" + end + if not integer(bx, 0) or not integer(by, 0) then + return nil, "invalid block coordinates" + end + local metadata, reason = Reads.metadata(map) + if not metadata then + return nil, reason + end + if bx >= metadata.width or by >= metadata.height then + return nil, "block coordinates out of bounds" + end + local index = by * metadata.width + bx + 1 + local block = map.blockPatch and map.blockPatch[index] + if block == nil then + local blocks = map.def.blocks + if type(blocks) == "table" then + block = blocks[index] + elseif type(blocks) == "string" then + -- Respect a cache already used by Map:blockAt, without populating it. + if map.def._blockArray then + block = map.def._blockArray[index] + else + local lo, hi = blocks:byte(index * 2 - 1, index * 2) + if hi then + block = (lo + hi * 256) % 1024 + end + end + end + end + if not integer(block, 0) then + return nil, "block unavailable" + end + return block, metadata +end + +function Reads.liveMaps(world) + local active = world.map + local out = {} + local seen = {} + local function add(map, x, y, isActive) + if not map then + return + end + -- A connected layout can show one map at several different offsets. + -- Collapse duplicate placements only, retaining the spatial instances. + local key = tostring(map.id) .. ":" .. tostring(x) .. ":" .. tostring(y) + if seen[key] then + return + end + seen[key] = true + local metadata = Reads.metadata(map) + out[#out + 1] = { + mapId = map.id, + x = x, + y = y, + active = isActive, + width = map.widthCells, + height = map.heightCells, + block = metadata, + } + end + add(active, 0, 0, true) + for _, neighbor in ipairs(world.neighbors or {}) do + add(neighbor.map, neighbor.cx or 0, neighbor.cy or 0, false) + end + return out +end +return Reads diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index f21419ae9..9d7a1cdea 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -9,14 +9,15 @@ local Logger = require("src.core.Logger") local MapLoader = require("src.world.MapLoader") local Gen3Elevation = require("src.world.Gen3Elevation") local Runtime = require("src.mods.Runtime") +local Reads = require("src.mods.WorldReads") local WorldAPI = {} WorldAPI.__index = WorldAPI local NO_OVERWORLD = "no overworld" -function WorldAPI.new(game, modId) - return setmetatable({ game = game, modId = modId }, WorldAPI) +function WorldAPI.new(game, modId, readContext) + return setmetatable({ game = game, modId = modId, readContext = readContext }, WorldAPI) end -- the live overworld, or nil. Game.overworld is the fast path; the stack @@ -58,6 +59,28 @@ function WorldAPI:current() elevation) } end +-- Detached common Pokemon records. This also works with a save at the title +-- screen: reading the party does not need an active overworld. +function WorldAPI:party() + return Reads.party(self.game, self.readContext) +end + +-- Require the expected active map to reject stale requests after a warp. +-- The second result describes block size/coordinates and the DS proxy grid. +function WorldAPI:activeBlockAt(mapId, bx, by) + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + return Reads.activeBlockAt(ow.map, mapId, bx, by) +end + +-- Loaded maps only, active first and with distinct placements. loadedMaps() +-- retains its legacy shape/order; these records also describe block units. +function WorldAPI:liveMaps() + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + return Reads.liveMaps(ow) +end + -- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else -- lands the player without one, like a scripted warp. function WorldAPI:warpTo(mapId, x, y, facing, opts) diff --git a/tests/engine/world_read_api.lua b/tests/engine/world_read_api.lua new file mode 100644 index 000000000..b7beda07f --- /dev/null +++ b/tests/engine/world_read_api.lua @@ -0,0 +1,415 @@ +-- ROM-free tests through the real loader and real runtime Map objects. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local WorldAPI = require("src.world.WorldAPI") +local Map = require("src.world.Map") +local MapLoader = require("src.world.MapLoader") +local Merge = require("src.mods.Merge") +local Runtime = require("src.mods.Runtime") +local GameVersion = require("src.core.GameVersion") +local Json = require("src.link.Json") +local Reads = require("src.mods.WorldReads") +local savedLove, savedVersion = love, GameVersion.get() +local savedEvents, savedHooks, savedErrors = Runtime.events, Runtime.hooks, Runtime.errors +love = nil +local function pack16(...) + local out = {} + for _, v in ipairs({ ... }) do + out[#out + 1] = string.char(v % 256, math.floor(v / 256)) + end + return table.concat(out) +end +local function map(id, cells, packed, ds) + return Map.new({ + id = id, + width = 3, + height = 2, + blocks = packed and pack16(0, 1 + 3 * 1024, 2 + 9 * 4096, 3, 4, 1023) or { 0, 1, 2, 3, 4, 1023 }, + borderBlock = 999, + generation = ds and 4 or nil, + }, { blockCells = cells, blockTiles = cells * 2, walkable = {}, blocks = {} }) +end +local function boot(version, entry) + GameVersion.set(version) + local gen = GameVersion.generation() + local s, m, item = gen >= 4 and 25 or "PIKACHU", gen >= 4 and 33 or "TACKLE", gen >= 4 and 13 or "POTION" + local species = { id = s, name = "Pikachu" } + local move = { id = m, name = "Tackle", pp = 35 } + local data = { + pokemon = { [s] = species }, + moves = { [m] = move }, + items = { + [item] = { id = item, name = "Potion" }, + }, + } + if gen >= 4 then + data.pokemon.SPECIES_25 = species + data.moves.MOVE_33 = move + end + local mon = { + species = s, + item = item, + nickname = "Buddy", + name = "native name", + level = 7, + hp = 19, + status = "PSN", + stats = { hp = 23, attack = 10, defense = 11, speed = 12, special = 13 }, + moves = { { id = m, pp = 29, ppUps = 1, hidden = false } }, + ivs = { hp = 27 }, + evs = { speed = 3 }, + personality = 12345, + extension = { flag = false }, + isEgg = false, + } + if gen > 1 then + mon.stats.spatk = 13 + mon.stats.spdef = 14 + end + local active = map("ACTIVE", gen <= 2 and 2 or 1, gen >= 3, gen >= 4) + local neighbor = map("NORTH", gen <= 2 and 2 or 1, gen >= 3, gen >= 4) + local ow = { isOverworld = true, map = active, neighbors = { { map = neighbor, cx = -2, cy = -4 } } } + local game = { + data = data, + save = { party = { mon }, money = 500 }, + stack = { states = { ow, { isMenu = true } } }, + overworld = ow, + } + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = 2, + entry = "main.lua", + }), + ["mods/probe/main.lua"] = entry or "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolves = 0 + loader._game = function() + resolves = resolves + 1 + return game + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + local api = assert(loader.apis.probe) + return api, loader, game, ow, { s, m, item }, function() + return resolves + end +end + +-- Fail if any reader tries to load a map, generate a Pokemon or use RNG. +local oldMapLoad = MapLoader.load +local Pokemon = require("src.pokemon.Pokemon") +local oldNew = Pokemon.new +local oldRandom = math.random +local function forbidden() + error("read crossed into gameplay") +end +MapLoader.load, Pokemon.new, math.random = forbidden, forbidden, forbidden + +for _, version in ipairs(GameVersion.ORDER) do + local api, loader, game, ow, ids, resolves = boot(version) + local label = version .. ": " + T.eq(resolves(), 0, label .. "entry and read metadata do not instantiate Game") + if api.supports then + for _, name in ipairs({ "party", "liveMaps", "activeBlockAt" }) do + T.eq(api:supports("world", name), true, label .. "capability " .. name) + end + T.eq(resolves(), 0, label .. "capability probes do not instantiate Game") + end + local before = Merge.deepCopy(game) + local world = api.world + T.eq(api.world, world, label .. "same cached facade") + local party = world:party() + local mon = party[1] + T.eq(mon.species, "PIKACHU", label .. "symbolic species") + T.eq(mon.item, "POTION", label .. "symbolic item") + T.eq(mon.moves[1].id, "TACKLE", label .. "symbolic move") + T.eq(mon.moves[1].pp, 29, label .. "current PP") + T.eq(mon.moves[1].maxPp, 42, label .. "PP Ups use native rounding") + T.eq(mon.moves[1].ppUps, 1, label .. "PP Ups exposed") + T.eq(mon.maxHp, 23, label .. "max HP from stats.hp") + T.eq(mon.stats.specialAttack, 13, label .. "special attack") + T.eq(mon.stats.specialDefense, GameVersion.generation() == 1 and 13 or 14, label .. "special defense") + T.eq(mon.status, "poison", label .. "status normalized") + T.eq(mon.isEgg, false, label .. "false flags preserved") + T.eq(mon.native.extension.flag, false, label .. "native false extension preserved") + T.eq(mon.native.ivs.hp, 27, label .. "native IV diagnostics") + T.eq(getmetatable(mon), nil, label .. "plain detached mon") + mon.species = "OTHER" + mon.stats.hp = 1 + mon.moves[1].pp = 0 + mon.native.ivs.hp = 0 + party[2] = { species = "INJECTED" } + T.same(game, before, label .. "party mutations do not reach game") + local nextParty = world:party() + T.eq(nextParty[1].stats.hp, 23, label .. "fresh nested stat snapshot") + T.eq(nextParty[1].moves[1].pp, 29, label .. "fresh move snapshot") + T.eq(#nextParty, 1, label .. "party membership detached") + game.save.party[1].hp = 18 + T.eq(world:party()[1].hp, 18, label .. "reads current native HP") + game.save = { party = { { species = ids[1], hp = 1, stats = { hp = 2 }, moves = {} } } } + T.eq(world:party()[1].hp, 1, label .. "held facade follows a replaced save") + game.save = before.save + + local cells = ow.map.blockCells + before = Merge.deepCopy(game) + local id, metadata = world:activeBlockAt("ACTIVE", 0, 0) + T.eq(id, 0, label .. "block zero is present") + T.eq(metadata.tileSize, cells * 16, label .. "block pixel size from runtime map") + T.eq(metadata.cellSize, 16, label .. "walk-cell pixel size") + T.eq(metadata.coordinateBase, 0, label .. "zero-based block coordinates") + T.eq(metadata.width, 3, label .. "block width") + T.eq(metadata.height, 2, label .. "block height") + local gen = GameVersion.generation() + T.eq( + metadata.kind, + gen >= 4 and "runtime_cell" or gen >= 3 and "metatile" or "block", + label .. "honest block kind" + ) + T.eq(world:activeBlockAt("ACTIVE", 1, 0), 1, label .. "packed collision bits stripped") + T.eq(world:activeBlockAt("ACTIVE", 2, 0), 2, label .. "packed elevation bits stripped") + T.eq(world:activeBlockAt("ACTIVE", 2, 1), 1023, label .. "last block / full 10-bit ID") + T.eq(ow.map.def._blockArray, nil, label .. "cold packed grid remains undecoded") + metadata.width = 999 + T.eq(select(2, world:activeBlockAt("ACTIVE", 0, 0)).width, 3, label .. "metadata detached") + for _, xy in ipairs({ + { -1, 0 }, + { 0, -1 }, + { 3, 0 }, + { 0, 2 }, + { 0.5, 0 }, + { 0, 0.5 }, + { math.huge, 0 }, + { + 0, + 0 / 0, + }, + { + "1", + 0, + }, + }) do + local value, reason = world:activeBlockAt("ACTIVE", xy[1], xy[2]) + T.check(value == nil and type(reason) == "string", label .. "invalid/off-map coordinates rejected") + end + local missing, reason = world:activeBlockAt("NORTH", 0, 0) + T.check(missing == nil and reason == "map is not active", label .. "stale map rejected") + T.same(game, before, label .. "block reads do not alter world or decoder cache") + ow.map:setBlock(1, 0, 0) + local patched = Merge.deepCopy(game) + T.eq(world:activeBlockAt("ACTIVE", 1, 0), 0, label .. "live zero-valued block patch wins") + T.same(game, patched, label .. "patch read remains pure") + ow.map.blockPatch[2] = 777 + T.eq(world:activeBlockAt("ACTIVE", 1, 0), 777, label .. "reads current runtime patch") + local oldMap = ow.map + ow.map = ow.neighbors[1].map + T.eq(world:activeBlockAt("ACTIVE", 0, 0), nil, label .. "held facade rejects map after warp") + T.eq(world:activeBlockAt("NORTH", 0, 0), 0, label .. "held facade reads new active map") + ow.map = oldMap + + before = Merge.deepCopy(game) + local legacy = world:loadedMaps() + local maps = world:liveMaps() + T.eq(#maps, 2, label .. "only loaded maps returned") + T.eq(maps[1].mapId, "ACTIVE", label .. "active first") + T.eq(maps[1].active, true, label .. "active tagged") + T.eq(maps[1].width, 3 * cells, label .. "map dimensions in cells") + T.eq(maps[2].mapId, "NORTH", label .. "loaded neighbor included") + T.eq(maps[2].x, -2, label .. "neighbor x offset in cells") + T.eq(maps[2].y, -4, label .. "neighbor y offset in cells") + T.eq(maps[2].block.tileSize, cells * 16, label .. "per-map block units") + maps[1].mapId = "MUTATED" + maps[2].block.width = 9 + maps[2].x = 123 + T.same(game, before, label .. "live map snapshots detached") + T.same(world:loadedMaps(), legacy, label .. "legacy loadedMaps shape unchanged") + T.eq(world:liveMaps()[2].x, -2, label .. "fresh map snapshots") + + game.stack.states = {} + game.overworld = nil + T.eq(world:activeBlockAt("ACTIVE", 0, 0), nil, label .. "no active world block result") + T.eq(select(2, world:activeBlockAt("ACTIVE", 0, 0)), "no overworld", label .. "no world reason") + T.eq(world:liveMaps(), nil, label .. "no active world maps result") + T.check(world:party() ~= nil, label .. "party available without world") + game.save = nil + T.eq(world:party(), nil, label .. "no save party result") + T.eq(select(2, world:party()), "no save", label .. "no save reason") +end + +MapLoader.load, Pokemon.new, math.random = oldMapLoad, oldNew, oldRandom +-- Trap LÖVE's RNG too when the host provides one; queries remain ordinary +-- data reads in a host with no graphics/filesystem surface. +love = { math = { random = forbidden } } +local rngApi = WorldAPI.new( + { data = {}, save = { party = {} }, overworld = { isOverworld = true, map = map("RNG", 1, true, false) } }, + "rng" +) +T.check(rngApi:party() ~= nil, "party never calls love.math.random") +T.eq(rngApi:activeBlockAt("RNG", 0, 0), 0, "block never calls love.math.random") +T.eq(#rngApi:liveMaps(), 1, "map list never calls love.math.random") +love = nil +-- Direct engine construction also produces symbolic records without a loader. +local data = { + pokemon = { [999] = { name = "Mr. Mime" } }, + moves = { [888] = { name = "Ember", pp = 25 } }, + items = { + [777] = { name = "Potion" }, + }, +} +local raw = { + species = 999, + item = 777, + hp = 3, + stats = { hp = 9 }, + moves = { { id = 888, pp = 5 } }, + extension = { + flag = false, + }, +} +local direct = WorldAPI.new({ data = data, save = { party = { raw } } }, "direct") +local before = Merge.deepCopy(data) +local p = direct:party() +T.eq(p[1].species, "MR_MIME", "standalone numeric species resolves by imported name") +T.eq(p[1].moves[1].id, "EMBER", "standalone numeric move resolves") +T.eq(p[1].native.extension.flag, false, "standalone false diagnostics retained") +T.same(data, before, "standalone resolver does not mutate data") +raw.self = raw +raw.callback = function() end +raw[{}] = { hidden = true } +local cyclic = direct:party()[1] +T.eq(cyclic.native.self, cyclic.native, "cyclic diagnostics detached consistently") +T.eq(cyclic.native.callback, nil, "diagnostics do not expose functions") +local tableKey = false +for k in pairs(cyclic.native) do + if type(k) == "table" then + tableKey = true + end +end +T.eq(tableKey, false, "diagnostics do not retain native table keys") +raw.self = nil +raw.callback = nil + +-- Metadata comes from the actual tileset even when its size is unusual. +local mixed = map("MIXED", 3, false, false) +local mixedApi = WorldAPI.new({ overworld = { isOverworld = true, map = mixed } }, "mixed") +T.eq( + select(2, mixedApi:activeBlockAt("MIXED", 0, 0)).tileSize, + 48, + "custom block size is not inferred from generation" +) +local missingMap = map("SHORT", 1, true, false) +missingMap.def.blocks = "\1" +local shortApi = WorldAPI.new({ overworld = { isOverworld = true, map = missingMap } }, "short") +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "truncated packed block refused") +missingMap.def.blocks = {} +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "missing array cell refused") +missingMap.def.blocks = { -1 } +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "negative block ID refused") +missingMap.def.blocks = pack16(1) +missingMap.def._blockArray = { 7 } +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), 7, "warm decoder cache matches Map:blockAt") +local cache = missingMap.def._blockArray +T.eq(cache[1], 7, "warm cache unmodified") +missingMap.blockTiles = 3 +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "inconsistent runtime geometry refused") +local placements = { isOverworld = true, map = map("HOME", 1, false, false) } +local repeated = map("NORTH", 1, false, false) +placements.neighbors = { + { map = repeated, cx = 0, cy = -2 }, + { map = repeated, cx = 0, cy = -2 }, + { map = repeated, cx = 3, cy = 0 }, + { map = placements.map, cx = 0, cy = 0 }, + { map = map("OTHER", 2, false, false), cx = -4, cy = 0 }, +} +local list = WorldAPI.new({ overworld = placements }, "placements"):liveMaps() +T.eq(#list, 4, "duplicate placements collapse, different placements of the same map remain") +T.eq(list[2].mapId, list[3].mapId, "two placements share map identity") +T.eq(list[3].x, 3, "second placement offset preserved") +T.eq(list[4].block.tileSize, 32, "mixed-neighborhood block sizes describe each map") +T.eq(list[4].width, 6, "mixed-neighborhood width remains in walk cells") +local empty = WorldAPI.new(nil, "empty") +T.eq(empty:party(), nil, "no Game party read safe") +T.eq(empty:liveMaps(), nil, "no Game map read safe") +T.eq(empty:activeBlockAt("MAP", 0, 0), nil, "no Game block read safe") + +-- IDs stay bound to pre-merge identity despite presentation patches and +-- registrations made after the API/context was constructed. +local api, loader, game = boot( + "platinum", + [[return function(mod) + mod.exports.api=mod + mod.content.pokemon:patch("SPECIES_25",{name="A localized display name"}) + mod.content.pokemon:register("CUSTOMMON",{ + id="CUSTOMMON",dex=999,name="Another display name",types={"NORMAL"}, + catchRate=45,baseExp=64,level1Moves={},growthRate="MEDIUM_FAST", + learnset={},evolutions={},spriteFront="front.png",spriteBack="back.png",frontSize=5, + baseStats={hp=40,attack=40,defense=40,speed=40,special=40}, + }) +end]] +) +game.save.party[1].species = "SPECIES_25" +game.save.party[2] = { species = "CUSTOMMON", level = 1, stats = {}, moves = {} } +T.eq(api.world:party()[1].species, "PIKACHU", "patched alias keeps canonical pre-merge ID") +T.eq(api.world:party()[2].species, "CUSTOMMON", "late mod registration uses its authored ID") +local emptyParty = game.save.party +game.save.party = {} +T.same(api.world:party(), {}, "empty saved party returns an empty snapshot") +game.save.party = emptyParty +game.save.party[1].item = 0 +game.save.party[1].status = nil +game.save.party[1].isEgg = true +T.eq(api.world:party()[1].item, nil, "no-item native sentinel is absent") +T.eq(api.world:party()[1].status, nil, "healthy status is absent") +T.eq(api.world:party()[1].isEgg, true, "egg flag exposed") +-- Optional integration gate against #92's actual event record encoder. +if pcall(require, "src.mods.UnifiedCallbacks") then + local Unified = require("src.mods.UnifiedCallbacks") + Unified.install(loader, api, "probe", 4) + local eventMon + api.events:on("pokemon.received", function(event) + eventMon = event.mon + end) + loader.events:emit("pokemon.received", { mon = game.save.party[1] }) + T.same(api.world:party()[1], eventMon, "party/event live record shapes match exactly") +end + +-- Verify both normal RNG streams independently, without relying only on the +-- tripwire above. Lua's sequence must remain unchanged by the real readers. +math.randomseed(24680) +local expected = { math.random(), math.random(), math.random() } +math.randomseed(24680) +api.world:party() +api.world:activeBlockAt("ACTIVE", 1, 0) +api.world:liveMaps() +T.same({ math.random(), math.random(), math.random() }, expected, "reader calls leave RNG sequence unchanged") +for _, name in ipairs({ "party", "activeBlockAt", "liveMaps" }) do + T.check(Reads.supports(name), "new capability " .. name) +end +T.check(not Reads.supports("flyTo") and not Reads.supports({}), "reader capability list stays narrow") + +GameVersion.set(savedVersion) +Runtime.install(savedEvents, savedHooks, savedErrors) +love = savedLove +T.finish() From 9b7bc090401af8e80f89071e296bd215ae36db3e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:43:36 +0200 Subject: [PATCH 006/138] Add guarded public world actions and native flight --- docs/modding.md | 9 + docs/world-actions-api.md | 135 +++++++ src/mods/Loader.lua | 10 + src/world/WorldAPI.lua | 9 + src/world/WorldActions.lua | 469 +++++++++++++++++++++++ tests/engine/world_actions_api.lua | 582 +++++++++++++++++++++++++++++ 6 files changed, 1214 insertions(+) create mode 100644 docs/world-actions-api.md create mode 100644 src/world/WorldActions.lua create mode 100644 tests/engine/world_actions_api.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..33b9ee421 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -255,6 +255,15 @@ without the engine knowing the layout. Developer mode also arms the mod loader's dev tripwire, which flags mods that reach outside their permission set. +## Public world actions + +`mod.world` exposes guarded party swaps, verified contextual field actions +and native flight through `canReorderParty`, `reorderParty`, +`availableFieldActions`, `useFieldAction`, `canFly` and `flyTo`. +See [contracts, busy-state checks and generation coverage](world-actions-api.md). +Calls revalidate the live state and preserve native gameplay paths; the +field-action adapter explicitly refuses unsupported script-driven cases. + ## Support link (`support`) A mod can add a link where players can thank its creator: a Ko-fi, Patreon or diff --git a/docs/world-actions-api.md b/docs/world-actions-api.md new file mode 100644 index 000000000..230b7737e --- /dev/null +++ b/docs/world-actions-api.md @@ -0,0 +1,135 @@ +# Public world actions + +`mod.world` adds six methods without a manifest opt-in or Mod API bump: +`canReorderParty`, `reorderParty`, `availableFieldActions`, `useFieldAction`, +`canFly`, and `flyTo`. The new actions validate free-roam state before using +existing native gameplay entry points. Existing world methods, party menus +and script commands keep their contracts. + +```lua +local ok, reason = mod.world:reorderParty(1, 2) +if not ok then mod.log:info("%s", reason) end + +local actions = mod.world:availableFieldActions() +for _, action in ipairs(actions) do + -- { id = "surf", label = "SURF" }, for example. + -- Show a companion UI; revalidate when the player actually chooses it. +end + +-- Later, from an explicit player choice: +local started, why = mod.world:useFieldAction("surf") +local flying, flyWhy = mod.world:flyTo("A_NATIVE_DESTINATION_MAP_ID") +``` + +## Free-roam boundary + +The save, overworld map, player, native script runner and authoritative stack +top must be present and ready. Methods refuse while another screen owns the +stack (including battles/dialogue), a script is running/queued, scripted +movement is pending, a transition or field animation is active, the player +is moving/locked, a mod script lock is held, a decoration mode owns input, +or a link session is live. Native engine errors propagate; an action that +throws is never retried by this facade. + +`canReorderParty()` and `canFly()` return booleans. Mutations return `true` +when accepted, or `nil, reason` when refused. `availableFieldActions()` gives +an array of detached `{ id, label }` records, or an empty array and a reason +when the world is unavailable/busy. Queries use native eligibility checks, +including existing mod hooks; the facade does not itself perform an action +while probing. A mod's own hook can still have ordinary native side effects. +Recursive mutations from those eligibility hooks are refused, and a changed +source context invalidates the pending request. + +A previous successful probe/list is not a reservation. Every action checks +state again when invoked. Typical reasons are `no save`, `no overworld`, +`world is busy`, invalid arguments, unavailable destination or unavailable +field action. Availability of the interface does not promise every action +exists in every generation. + +## Party ordering + +`reorderParty(fromSlot, toSlot)` swaps two existing party references, matching +the native party menus. It does not insert/remove Pokémon, recalculate them +or copy their data. Slots are integer, one-based indices from 1 to 6; both +must exist. Fainted Pokémon and eggs can be reordered. A same-slot request is +a successful no-op. Invalid slots refuse before changing the party. + +`canReorderParty()` is true only when the world is ready and there are at +least two party members. A retained facade reads the current save when used. +No menu selection or battle party order is changed behind a screen's back. + +## Flight + +`canFly()` additionally requires a real non-egg party carrier, the native +badge/location checks and at least one currently unlocked, valid destination. +`flyTo(mapId)` accepts a native map ID from that game's destination set. It +is not a general warp: unknown, locked, unvisited or invalid landing points +are refused before departure state changes. A caller-provided coordinate, +spawn-table index or arbitrary destination object is not accepted. + +| Host | Eligibility and destinations | Native departure | +| --- | --- | --- | +| Gen 1/2 | `partyKnows("FLY")`, outside-map rules, visited `field.flyOrder` towns with `flyWarps` | `OverworldState:flyTo` | +| Gen 3 | Native carrier/badge check, `Gen3FieldRules`, visited town set | Native field-move cut-in, then `flyTo` | +| Platinum / HGSS | `Gen4FieldMoves`, `Gen4Fly.check/destinations`, partner/Safari/badge checks and HGSS costume refusal | `gen4FlyTo` | +| Black / White | Native numeric move lookup, `Gen5Fly.allowed/destinations/destination`; no invented badge gate | `gen4FlyTo` | + +The native handlers retain departure timing, player input lock, bike/surf +state changes and exact landing coordinates. The direct map-ID call does +not open a destination picker. Gen 3 keeps its native Pokémon cut-in, falling +back directly when there is no picture to display. + +## Verified contextual field actions + +This initial adapter deliberately exposes only these native paths: + +| Action ID | Gen 1 | Gen 2 | Gen 3 | Gen 4/5 | +| --- | --- | --- | --- | --- | +| `cut` | Native cut eligibility + `tryCut` | Same, reaching native Gen 2 Cut | Refused: object-script adapter required | Refused: native script adapter required | +| `surf` | Native eligibility + `trySurf` | Same | Same, with native field-move presentation | Refused: native script adapter required | +| `dig` | Eligible dungeon → native last-heal departure | Eligible cave/dungeon → recorded entrance | `Gen3FieldRules` → recorded entrance | Refused: native script adapter required | +| `teleport` | Outside map → last heal | Outside map → last heal | Native travel rules → last heal | Refused: native script adapter required | + +Native carrier lookup selects a current non-egg party member. Badge checks +and target/location gates are retained; fainted carriers remain eligible +where the native engine permits them. Surf starts mounting; there is no +invented generic dismount action. Dig and Teleport keep distinct native +escape/last-heal semantics. Cut uses live target eligibility rather than +matching a global block number. + +`useFieldAction(id, opts)` accepts no options or an empty plain options table. +Unsupported options, action IDs and generation adapters return a reason. +This version does not expose bicycle/fishing, Strength/Flash, healing moves, +custom source selection, Gen 3 object-script actions or DS field-script +activation. These require separate adapters with their native checks; callers +must use the returned action list rather than assuming a fixed menu. Fly is +separate because it needs a destination. + +## Deferred completion and cancellation + +A `true` result means the native request was accepted, not that the animation, +dialogue or warp has finished. Native gameplay owns those asynchronous flows. +A Gen 3 cut-in blocks competing public mutations until it finishes. Its +effect is guarded by the source save/data/map/player, position/facing, version, +party membership and fresh eligibility. Changing that context cancels the +effect. A repeated completion callback cannot replay it. Abandoning the +presentation does not leave a permanent facade lock; a stale callback cannot +start a later effect. Already executed native gameplay is not rolled back. + +## Capabilities and tests + +With the optional capability-query feature, each of these six method names +returns true from `mod:supports("world", name)` without constructing Game or +the world. Other capability answers are delegated unchanged. Use +`canFly`/`availableFieldActions` and the mutation's return value for current +state and action-level availability. + +`tests/engine/world_actions_api.lua` uses the real loader across all 15 host +version IDs. It exercises refusal parity for world/player/script/link locks, +party identities, slots, destination gates, eggs, partner/costume checks, +contextual dispatch and reentrant/deferred/error paths. It also runs actual +native carrier/badge lookup, Fly/DS Fly departure methods and Gen 1–3 +Dig/Teleport departure state changes without ROM assets or graphics. A cut-in +test double drives completion/cancellation deterministically; it is not a +rendered playthrough. ROM-dependent map scripts and graphics still need an +imported legal ROM. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..51fa8a5bf 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -23,6 +23,7 @@ local ModStorage = require("src.mods.Storage") local Runtime = require("src.mods.Runtime") local ModGens = require("src.mods.ModGens") local ModSeams = require("src.mods.ModSeams") +local WorldActions = require("src.world.WorldActions") -- Module-name aliases for mods written against the Gold port's per-generation -- layout (src.world.gen2.Player and friends). Installed here because this is -- the module that loads mods: the searcher has to be in place before the @@ -1124,6 +1125,15 @@ function Loader:_api(mod) -- the Game the facade acts on is still being wired when the entry chunk -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. + -- When capability queries are installed, these additive methods exist on + -- every host; individual actions still validate native version and state. + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "world" and WorldActions.supports(name) then return true end + return supports(self, surface, name) + end + end local world, storage setmetatable(api, { __index = function(_, key) if key == "game" then return loader:_game() end diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index f21419ae9..3b94e0c15 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -6,6 +6,7 @@ -- stays unsupported; anything a mod legitimately needs belongs here. local Logger = require("src.core.Logger") +local Actions = require("src.world.WorldActions") local MapLoader = require("src.world.MapLoader") local Gen3Elevation = require("src.world.Gen3Elevation") local Runtime = require("src.mods.Runtime") @@ -386,6 +387,14 @@ function WorldAPI:queueScript(rows, extra) return true end +-- Public field mutations are kept behind the same free-roam boundary. +function WorldAPI:canReorderParty() return Actions.canReorderParty(self.game, self:overworld()) end +function WorldAPI:reorderParty(fromSlot, toSlot) return Actions.reorderParty(self.game, self:overworld(), fromSlot, toSlot) end +function WorldAPI:availableFieldActions() return Actions.availableFieldActions(self.game, self:overworld()) end +function WorldAPI:useFieldAction(id, opts) return Actions.useFieldAction(self.game, self:overworld(), id, opts) end +function WorldAPI:canFly() return Actions.canFly(self.game, self:overworld()) end +function WorldAPI:flyTo(mapId) return Actions.flyTo(self.game, self:overworld(), mapId) end + -- drop a map's cached instance so the next load re-reads its record; when -- it is the active map the world reloads around the player in place function WorldAPI:invalidateMap(mapId) diff --git a/src/world/WorldActions.lua b/src/world/WorldActions.lua new file mode 100644 index 000000000..d6172f82c --- /dev/null +++ b/src/world/WorldActions.lua @@ -0,0 +1,469 @@ +-- Supported mutations go through the existing gameplay entry points. Shared +-- guards keep companion UIs out of screens/scripts/transitions that own input. +local GameVersion = require("src.core.GameVersion") +local Map = require("src.world.Map") +local FieldDefaults = require("src.world.FieldDefaults") +local Party = require("src.pokemon.Party") +local A = {} +local METHODS = { + canReorderParty = true, + reorderParty = true, + availableFieldActions = true, + useFieldAction = true, + canFly = true, + flyTo = true, +} +local LOCKS = { + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "frameScriptThisTick", + "_modScriptLock", +} +local pending = setmetatable({}, { __mode = "k" }) +local planning = setmetatable({}, { __mode = "k" }) +local function integer(v, lo, hi) + return type(v) == "number" and v == v and v % 1 == 0 and v >= lo and v <= hi +end +function A.supports(name) + return type(name) == "string" and METHODS[name] == true +end + +local function ready(game, ow) + if not (game and game.save) then + return nil, "no save" + end + if not (ow and ow.map and ow.player) then + return nil, "no overworld" + end + local token = pending[game] + if token and token.state then + local held = false + for _, state in ipairs(game.stack and game.stack.states or {}) do + if state == token.state then + held = true + end + end + if game.stack and game.stack.top and game.stack:top() == token.state then + held = true + end + if + not held + or game.save ~= token.save + or game.data ~= token.data + or ow.map ~= token.map + or ow.player ~= token.player + then + pending[game] = nil + end + end + if not (game.stack and game.stack.top and game.stack:top() == ow) then + return nil, "world is busy" + end + if + pending[game] + or planning[game] + or (game.overworld and game.overworld ~= ow) + or game.linkSession + or (game.linkNet and not game.linkNet.closed) + then + return nil, "world is busy" + end + for _, key in ipairs(LOCKS) do + if ow[key] then + return nil, "world is busy" + end + end + local player = ow.player + if player.moving or player.inputLocked or player.stairExit or player.spinning or player.fieldMove then + return nil, "world is busy" + end + if not (ow.runner and type(ow.runner.isRunning) == "function") then + return nil, "world is busy" + end + if ow.runner:isRunning() or #(ow.scriptMoves or {}) > 0 or #(ow.pendingScripts or {}) > 0 then + return nil, "world is busy" + end + return true +end +local function guardedCall(game, fn, ...) + planning[game] = true + local result = { pcall(fn, game, ...) } + planning[game] = nil + if not result[1] then + error(result[2], 0) + end + return result[2], result[3] +end +local function party(game) + return game and game.save and game.save.party or {} +end +local function member(game, mon) + if type(mon) ~= "table" or Party.isEgg(mon) then + return nil + end + for _, p in ipairs(party(game)) do + if p == mon then + return mon + end + end +end +local function user(game, ow, move) + if type(ow.partyKnows) ~= "function" then + return nil + end + return member(game, ow:partyKnows(move)) +end +local function context(game, ow) + local p = ow.player + return { + save = game.save, + data = game.data, + map = ow.map, + player = p, + x = p.cellX, + y = p.cellY, + facing = p.facing, + version = GameVersion.get(), + } +end +local function same(game, ow, c) + return game.save == c.save + and game.data == c.data + and ow.map == c.map + and ow.player == c.player + and ow.player.cellX == c.x + and ow.player.cellY == c.y + and ow.player.facing == c.facing + and GameVersion.get() == c.version +end +local function currentWorld(game, ow) + -- A state replaced while a cut-in was active must not receive its effect. + return game.stack and game.stack.top and game.stack:top() == ow +end +local function present(game, ow, mon, check, run) + local c = context(game, ow) + local called = false + local token = { save = game.save, data = game.data, map = ow.map, player = ow.player } + pending[game] = token + local result, reason + local function finish() + if called then + return + end + called = true + if pending[game] ~= token then + return + end + pending[game] = nil + if + not currentWorld(game, ow) + or not same(game, ow, c) + or not ready(game, ow) + or not member(game, mon) + then + result, reason = nil, "world changed" + return + end + if not check() then + result, reason = nil, "action unavailable" + return + end + -- A capability hook invoked by check() may have changed the session. + if not same(game, ow, c) or not ready(game, ow) then + result, reason = nil, "world changed" + return + end + local answer = guardedCall(game, run) + if answer == false then + result, reason = nil, "action unavailable" + else + result = true + end + end + if GameVersion.generation() == 3 then + -- Never retry an engine callback that threw after invoking finish(). + local ok, shown = pcall(function() + return require("src.world.Gen3FieldMove").show(game, mon, finish) + end) + if not ok then + pending[game] = nil + error(shown, 0) + end + if shown then + token.state = game.stack:top() + return true + end + end + finish() + return result, reason +end + +function A.canReorderParty(game, ow) + return #party(game) > 1 and ready(game, ow) == true +end +function A.reorderParty(game, ow, from, to) + local ok, reason = ready(game, ow) + if not ok then + return nil, reason + end + local p = party(game) + if + not integer(from, 1, Party.MAX) + or not integer(to, 1, Party.MAX) + or type(p[from]) ~= "table" + or type(p[to]) ~= "table" + then + return nil, "invalid party slot" + end + -- Match the native party menus: swap references, never reconstruct mons. + if from ~= to then + p[from], p[to] = p[to], p[from] + end + return true +end + +local function outside(game, ow) + return Map.isOutside(ow.map.def, FieldDefaults.field(game.data, "outsideTilesets")) +end +local function validSpot(data, id, x, y, gen) + local def = data and data.maps and data.maps[id] + if not def or not integer(def.width, 1, 1000000) or not integer(def.height, 1, 1000000) then + return false + end + local tileset = data.tilesets and data.tilesets[def.tileset] + local cells = tileset and tileset.blockCells or (gen >= 3 and 1 or 2) + return integer(cells, 1, 1000000) + and integer(x, 0, def.width * cells - 1) + and integer(y, 0, def.height * cells - 1) +end +local function flyPlan(game, ow, mapId) + local gen = GameVersion.generation() + local data, save = game.data or {}, game.save + local mon + if gen >= 4 then + local F = require("src.world.Gen4FieldMoves") + for _, p in ipairs(party(game)) do + if not Party.isEgg(p) and F.knows(p, "FLY") then + mon = p + break + end + end + if not mon then + return nil, "fly unavailable" + end + if gen == 5 then + local Fly = require("src.world.Gen5Fly") + if not Fly.allowed(ow.map.def) or type(ow.gen4FlyTo) ~= "function" then + return nil, "fly unavailable" + end + for _, loc in ipairs(Fly.destinations(data, save)) do + local dest = Fly.destination(data, loc) + if dest and (not mapId or dest.map == mapId) and validSpot(data, dest.map, dest.x, dest.y, gen) then + return { mon = mon, dest = dest, ds = true } + end + end + else + local Fly = require("src.world.Gen4Fly") + local costume = require("src.inventory.Gen4FieldChecks").inCostume(data, save) + if costume or not Fly.check(data, save, ow.map.def) or type(ow.gen4FlyTo) ~= "function" then + return nil, "fly unavailable" + end + for _, dest in ipairs(Fly.destinations(data, save)) do + if (not mapId or dest.map == mapId) and validSpot(data, dest.map, dest.x, dest.y, gen) then + return { mon = mon, dest = { map = dest.map, x = dest.x, y = dest.y }, ds = true } + end + end + end + else + mon = user(game, ow, "FLY") + local travel = gen == 3 and require("src.world.Gen3FieldRules").allowsTravel(ow.map.def) + or (gen < 3 and outside(game, ow)) + if not mon or not travel or type(ow.flyTo) ~= "function" then + return nil, "fly unavailable" + end + local field = data.field or {} + for _, id in ipairs(field.flyOrder or {}) do + local def = data.maps and data.maps[id] + local spot = field.flyWarps and field.flyWarps[id] + if + (not mapId or mapId == id) + and save.visited + and save.visited[id] + and spot + and def + and (Map.isOutdoor(def) or def.tileset == "PLATEAU") + and validSpot(data, id, spot.x, spot.y, gen) + then + return { mon = mon, dest = { map = id, x = spot.x, y = spot.y } } + end + end + end + return nil, "destination unavailable" +end +function A.canFly(game, ow) + if not ready(game, ow) then + return false + end + local c = context(game, ow) + return guardedCall(game, flyPlan, ow) ~= nil and same(game, ow, c) and ready(game, ow) == true +end +function A.flyTo(game, ow, mapId) + local ok, reason = ready(game, ow) + if not ok then + return nil, reason + end + if type(mapId) ~= "string" or mapId == "" then + return nil, "invalid destination" + end + local c = context(game, ow) + local plan, why = guardedCall(game, flyPlan, ow, mapId) + if not plan then + return nil, why + end + if not same(game, ow, c) or not ready(game, ow) then + return nil, "world changed" + end + return present(game, ow, plan.mon, function() + local fresh = guardedCall(game, flyPlan, ow, mapId) + return fresh and fresh.mon == plan.mon and fresh.dest.x == plan.dest.x and fresh.dest.y == plan.dest.y + end, function() + if plan.ds then + return ow:gen4FlyTo(plan.dest, plan.mon) + end + return ow:flyTo(plan.dest.map, plan.mon) + end) +end + +local ORDER = { "cut", "surf", "dig", "teleport" } +local function fieldPlan(game, ow, id) + local gen = GameVersion.generation() + if gen >= 4 then + return nil, "DS field actions require a native script adapter" + end + if id == "cut" then + if gen == 3 then + return nil, "Gen 3 Cut requires an object-script adapter" + end + local mon = user(game, ow, "CUT") + if + mon + and type(ow.useCutFieldMove) == "function" + and ow:useCutFieldMove() == "ok" + and type(ow.tryCut) == "function" + then + return { + mon = mon, + run = function() + local x, y = ow.player:facingCell() + return ow:tryCut(x, y) + end, + } + end + elseif id == "surf" then + local mon = user(game, ow, "SURF") + if + mon + and not ow.player.surfing + and type(ow.useSurfFieldMove) == "function" + and ow:useSurfFieldMove() == "ok" + and type(ow.trySurf) == "function" + then + return { + mon = mon, + run = function() + local x, y = ow.player:facingCell() + return ow:trySurf(x, y, nil, mon) + end, + } + end + elseif id == "dig" or id == "teleport" then + local mon = user(game, ow, id:upper()) + if not mon or type(ow.beginTeleportOut) ~= "function" then + return nil, "field action unavailable" + end + local escape = false + if id == "teleport" then + local travel = gen == 3 and require("src.world.Gen3FieldRules").allowsTravel(ow.map.def) + or (gen < 3 and outside(game, ow)) + if not travel or not game.save.lastHeal then + return nil, "field action unavailable" + end + elseif gen == 1 then + local tiles = { FOREST = true, CEMETERY = true, CAVERN = true, FACILITY = true, INTERIOR = true } + if not tiles[ow.map.def.tileset] or ow.map.id == "AGATHAS_ROOM" or not game.save.lastHeal then + return nil, "field action unavailable" + end + else + local point = type(ow.escapePoint) == "function" and ow:escapePoint() + if not point then + return nil, "field action unavailable" + end + if gen == 3 then + if not require("src.world.Gen3FieldRules").allowsEscape(ow.map.def, point) then + return nil, "field action unavailable" + end + elseif ow.map.def.environment ~= 4 and ow.map.def.environment ~= 7 then + return nil, "field action unavailable" + end + escape = true + end + return { + mon = mon, + run = function() + return ow:beginTeleportOut(nil, { escape = escape or nil }) + end, + } + end + return nil, "field action unavailable" +end +function A.availableFieldActions(game, ow) + local ok, reason = ready(game, ow) + if not ok then + return {}, reason + end + local c = context(game, ow) + local out = {} + for _, id in ipairs(ORDER) do + if guardedCall(game, fieldPlan, ow, id) then + out[#out + 1] = { id = id, label = id:upper() } + end + if not same(game, ow, c) or not ready(game, ow) then + return {}, "world changed" + end + end + return out +end +function A.useFieldAction(game, ow, id, opts) + local ok, reason = ready(game, ow) + if not ok then + return nil, reason + end + if type(id) ~= "string" then + return nil, "invalid field action" + end + if opts ~= nil and (type(opts) ~= "table" or next(opts) ~= nil or getmetatable(opts) ~= nil) then + return nil, "unsupported field action options" + end + local c = context(game, ow) + local plan, why = guardedCall(game, fieldPlan, ow, id) + if not plan then + return nil, why + end + if not same(game, ow, c) or not ready(game, ow) then + return nil, "world changed" + end + return present(game, ow, plan.mon, function() + local fresh = guardedCall(game, fieldPlan, ow, id) + return fresh and fresh.mon == plan.mon + end, plan.run) +end +return A diff --git a/tests/engine/world_actions_api.lua b/tests/engine/world_actions_api.lua new file mode 100644 index 000000000..bd029b204 --- /dev/null +++ b/tests/engine/world_actions_api.lua @@ -0,0 +1,582 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local WorldAPI = require("src.world.WorldAPI") +local Actions = require("src.world.WorldActions") +local GameVersion = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Merge = require("src.mods.Merge") +local Json = require("src.link.Json") +local G4Fly = require("src.world.Gen4Fly") +local Gen3Show = require("src.world.Gen3FieldMove") +local saved = { GameVersion.get(), Runtime.events, Runtime.hooks, Runtime.errors, love, Gen3Show.show } +love = nil +-- Deterministic no-picture native fallback for the cross-version cases. +Gen3Show.show = function() + return false +end +local function boot(version) + GameVersion.set(version) + local gen = GameVersion.generation() + local data = { + pokemon = {}, + moves = {}, + items = {}, + constants = { badges = {} }, + tilesets = { TEST = { blockCells = gen < 3 and 2 or 1 } }, + maps = { + HOME = { + id = "HOME", + width = 20, + height = 20, + tileset = "TEST", + outdoor = true, + mapType = "ROUTE", + allowFly = true, + gen5Flags = 0x2000, + }, + DEST = { id = "DEST", width = 20, height = 20, tileset = "TEST", outdoor = true, mapType = "CITY" }, + }, + field = { flyOrder = { "DEST" }, flyWarps = { DEST = { x = 3, y = 4 } } }, + } + for i = 1, 16 do + data.constants.badges[i] = { id = "BADGE_" .. i } + end + local save = { + party = { + { species = "A", hp = 1, moves = { { id = gen >= 4 and 19 or "FLY" } } }, + { + species = "B", + hp = 0, + moves = {}, + }, + }, + visited = { DEST = true }, + lastHeal = { map = "DEST", x = 3, y = 4 }, + flags = {}, + badges = {}, + } + for i = 1, 16 do + save.badges["BADGE_" .. i] = true + end + if gen == 4 then + data.gen4_version = + { family = (version == "heartgold" or version == "soulsilver") and "hgss" or "platinum" } + local row = { index = 0, firstArrival = 1, fly = { map = "DEST", x = 3, y = 4 } } + data.field.healLocations = { row } + save.flags[G4Fly.rowFlagKey(data, row)] = true + elseif gen == 5 then + data.maps.Z001 = data.maps.DEST + data.gen5_town_map = + { locations = { { zone = 1, flyPoint = true, spawn = { main = true, x = 3, z = 4 } } } } + save.gen5FlyZones = { ["1"] = true } + end + local calls = {} + local ow = { + isOverworld = true, + map = { id = "HOME", def = data.maps.HOME }, + player = { cellX = 2, cellY = 3, facing = "up" }, + scriptMoves = {}, + pendingScripts = {}, + runner = { + isRunning = function() + return false + end, + }, + } + ow.player.facingCell = function(p) + return p.cellX, p.cellY - 1 + end + local game + ow.partyKnows = function(_, move) + if move == "FLY" then + return game.save.party[1] + end + end + ow.flyTo = function(_, id, mon) + calls[#calls + 1] = { kind = "fly", id = id, mon = mon } + ow.flyAnim = {} + end + ow.gen4FlyTo = function(_, dest, mon) + calls[#calls + 1] = { kind = "fly", id = dest.map, x = dest.x, y = dest.y, mon = mon } + ow.flyAnim = {} + end + local stack = { states = { ow } } + function stack:top() + return self.states[#self.states] + end + function stack:push(state) + self.states[#self.states + 1] = state + end + function stack:pop() + return table.remove(self.states) + end + game = { data = data, save = save, overworld = ow, stack = stack } + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = 2, + entry = "main.lua", + }), + ["mods/probe/main.lua"] = "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolved = 0 + loader._game = function() + resolved = resolved + 1 + return game + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + return loader.apis.probe, game, ow, calls, function() + return resolved + end +end +for _, version in ipairs(GameVersion.ORDER) do + local api, game, ow, calls, resolved = boot(version) + local label = version .. ": " + T.eq(resolved(), 0, label .. "building API does not construct Game") + if api.supports then + for _, name in ipairs({ + "canReorderParty", + "reorderParty", + "availableFieldActions", + "useFieldAction", + "canFly", + "flyTo", + }) do + T.eq(api:supports("world", name), true, label .. "capability " .. name) + end + T.eq(resolved(), 0, label .. "capability probes do not construct Game") + end + local world = api.world + local before = Merge.deepCopy(game) + T.eq(world:canReorderParty(), true, label .. "free-roam party can reorder") + T.eq(world:canFly(), true, label .. "native flight checks pass") + T.same(game, before, label .. "availability does not alter save/world") + local p = game.save.party + local a, b = p[1], p[2] + T.eq(world:reorderParty(1, 2), true, label .. "swap accepted") + T.eq(game.save.party, p, label .. "party list identity retained") + T.eq(p[1], b, label .. "fainted party member may move") + T.eq(p[2], a, label .. "Pokemon identity retained") + T.eq(world:reorderParty(1, 2), true, label .. "swap back accepted") + T.eq(world:reorderParty(1, 1), true, label .. "same-slot no-op accepted") + for _, slot in ipairs({ 0, -1, 3, 7, 1.5, "1", math.huge, 0 / 0 }) do + before = Merge.deepCopy(game) + local ok, reason = world:reorderParty(slot, 1) + T.check(ok == nil and reason == "invalid party slot", label .. "invalid slot refused") + T.same(game, before, label .. "bad swap atomic") + end + local locks = { + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "frameScriptThisTick", + "_modScriptLock", + } + for _, lock in ipairs(locks) do + ow[lock] = true + before = Merge.deepCopy(game) + T.eq(world:canReorderParty(), false, label .. lock .. " blocks reorder probe") + T.eq(world:canFly(), false, label .. lock .. " blocks fly probe") + T.eq(world:reorderParty(1, 2), nil, label .. lock .. " blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. lock .. " blocks flight") + T.eq(world:useFieldAction("surf"), nil, label .. lock .. " blocks field use") + T.same(world:availableFieldActions(), {}, label .. lock .. " suppresses actions") + T.same(game, before, label .. lock .. " refusal leaves game unchanged") + ow[lock] = nil + end + for _, lock in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do + ow.player[lock] = true + before = Merge.deepCopy(game) + T.eq(world:reorderParty(1, 2), nil, label .. "player " .. lock .. " blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. "player " .. lock .. " blocks fly") + T.same(game, before, label .. "player " .. lock .. " no mutation") + ow.player[lock] = nil + end + game.stack:push({ isBattle = true }) + T.eq(world:reorderParty(1, 2), nil, label .. "battle/screen above world blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. "battle/screen blocks fly") + game.stack:pop() + ow.runner.isRunning = function() + return true + end + T.eq(world:reorderParty(1, 2), nil, label .. "running VM blocks swap") + ow.runner.isRunning = function() + return false + end + ow.pendingScripts[1] = {} + T.eq(world:flyTo("DEST"), nil, label .. "pending scene blocks flight") + ow.pendingScripts = {} + ow.scriptMoves[1] = {} + T.eq(world:reorderParty(1, 2), nil, label .. "scripted movement blocks swap") + ow.scriptMoves = {} + game.linkSession = {} + T.eq(world:flyTo("DEST"), nil, label .. "link lock blocks flight") + game.linkSession = nil + game.linkNet = { closed = false } + T.eq(world:reorderParty(1, 2), nil, label .. "live link net blocks swap") + game.linkNet = nil + local destination = GameVersion.generation() == 5 and "Z001" or "DEST" + before = Merge.deepCopy(game) + T.eq(world:flyTo("MISSING"), nil, label .. "unknown destination refused") + T.eq(world:flyTo({}), nil, label .. "non-string destination refused") + T.same(game, before, label .. "invalid destinations leave save/world unchanged") + T.eq(#calls, 0, label .. "no gameplay handlers ran on refusal") + T.eq(world:flyTo(destination), true, label .. "flight accepted") + T.eq(#calls, 1, label .. "one native departure") + T.eq(calls[1].id, destination, label .. "native destination selected") + T.eq(calls[1].mon, a, label .. "native carrier identity retained") + T.eq(world:flyTo(destination), nil, label .. "in-flight repeat refused") + T.eq(#calls, 1, label .. "repeat does not replay departure") + ow.flyAnim = nil + a.isEgg = true + T.eq(world:canFly(), false, label .. "egg cannot carry flight") + a.isEgg = nil + if GameVersion.generation() == 4 then + game.save.flags.FLAG_G4_0961 = true + T.eq(world:canFly(), false, label .. "native partner restriction retained") + game.save.flags.FLAG_G4_0961 = nil + if version == "heartgold" or version == "soulsilver" then + game.save.hgssRocketCostume = true + T.eq(world:canFly(), false, label .. "Rocket costume restriction retained") + game.save.hgssRocketCostume = nil + end + end + game.save = nil + T.eq(world:canReorderParty(), false, label .. "no save probe safe") + T.eq(world:reorderParty(1, 2), nil, label .. "no save swap safe") + T.eq(world:canFly(), false, label .. "no save fly probe safe") + T.eq(world:flyTo(destination), nil, label .. "no save fly safe") +end + +-- Exercise the actual native carrier/badge lookup and departure methods, +-- including their input lock and animation/landing state, without graphics. +local OW = require("src.world.OverworldController") +love = {} -- native sound entry points expect a host table, but need no audio device +local gameUpvalue, oldGame +for i = 1, 30 do + local name, value = debug.getupvalue(OW.flyTo, i) + if name == "Game" then + gameUpvalue = i + oldGame = value + break + end +end +assert(gameUpvalue, "native Game binding") +for _, version in ipairs(GameVersion.ORDER) do + local api, game, ow = boot(version) + debug.setupvalue(OW.flyTo, gameUpvalue, game) + ow.partyKnows = OW.partyKnows + ow.flyTo = OW.flyTo + ow.gen4FlyTo = OW.gen4FlyTo + ow.closeToMap = OW.closeToMap + ow.clearBikeFlags = function() end + ow.hasFlyBird = function() + return false + end + ow.syncSurfingPikachu = function() end + local gen = GameVersion.generation() + if gen <= 2 then + game.data.constants.hmBadges = { FLY = { badge = "FLY_ALLOWED" } } + game.save.flags.FLY_ALLOWED = true + elseif gen == 3 then + game.data.constants.gen3FieldMoves = { FLY = { badgeFlag = 0x820 } } + game.save.flags.FLAG_G3_0820 = true + end + local destination = gen == 5 and "Z001" or "DEST" + T.eq(api.world:canFly(), true, version .. " actual native eligibility") + local before = Merge.deepCopy(game) + if gen <= 2 then + game.save.flags.FLY_ALLOWED = nil + elseif gen == 3 then + game.save.flags.FLAG_G3_0820 = nil + elseif gen == 4 then + for k in pairs(game.save.badges) do + game.save.badges[k] = nil + end + end + if gen < 5 then + T.eq(api.world:canFly(), false, version .. " native missing badge refuses flight") + T.eq(api.world:flyTo(destination), nil, version .. " missing badge has no departure") + T.eq(ow.flyAnim, nil, version .. " denied departure has no animation") + end + game.save = Merge.deepCopy(before.save) + if gen <= 3 then + game.save.visited.DEST = nil + elseif gen == 4 then + game.save.flags = {} + else + game.save.gen5FlyZones = {} + end + T.eq(api.world:canFly(), false, version .. " unvisited/locked destinations unavailable") + T.eq(api.world:flyTo(destination), nil, version .. " locked destination refuses native handler") + game.save = Merge.deepCopy(before.save) + -- Validate the landing before any departure-side state is changed. + if gen <= 3 then + game.data.field.flyWarps.DEST.x = -1 + elseif gen == 4 then + game.data.field.healLocations[1].fly.x = -1 + else + game.data.gen5_town_map.locations[1].spawn.x = -1 + end + T.eq(api.world:flyTo(destination), nil, version .. " invalid landing refused") + T.eq(ow.flyAnim, nil, version .. " invalid landing leaves animation absent") + if gen <= 3 then + game.data.field.flyWarps.DEST.x = 3 + elseif gen == 4 then + game.data.field.healLocations[1].fly.x = 3 + else + game.data.gen5_town_map.locations[1].spawn.x = 3 + end + game.save.onBike = true + ow.player.surfing = true + T.eq(api.world:flyTo(destination), true, version .. " native departure accepted") + T.eq(ow.flyAnim.frames, 48, version .. " native flight timing preserved") + T.eq(ow.flyDest.map, destination, version .. " native landing map preserved") + T.eq(ow.flyDest.x, 3, version .. " native landing x preserved") + T.eq(ow.flyDest.y, 4, version .. " native landing y preserved") + T.eq(ow.player.inputLocked, true, version .. " native input lock applied") + T.eq(ow.player.surfing, false, version .. " native surfing cleared") + T.eq(game.save.onBike, false, version .. " native bicycle state cleared") +end +for _, version in ipairs({ "red", "gold", "emerald" }) do + local api, game, ow = boot(version) + debug.setupvalue(OW.flyTo, gameUpvalue, game) + local gen = GameVersion.generation() + ow.partyKnows = OW.partyKnows + ow.beginTeleportOut = OW.beginTeleportOut + ow.syncSurfingPikachu = function() end + ow.escapePoint = function() + return { map = "DEST", x = 3, y = 4 } + end + game.save.party[1].moves = { { id = "DIG" }, { id = "TELEPORT" } } + ow.map.def.tileset = "CAVERN" + ow.map.def.environment = 4 + ow.map.def.allowEscaping = true + T.eq(api.world:useFieldAction("dig"), true, version .. " actual native Dig starts") + T.eq(ow.teleportOut.frames, 48, version .. " native Dig animation timing") + T.eq(ow.teleportOut.escape, gen >= 2 and true or nil, version .. " native Dig escape destination semantics") + T.eq(ow.player.inputLocked, true, version .. " native Dig input lock") + ow.teleportOut = nil + ow.player.inputLocked = false + ow.player.spinning = false + ow.map.def.tileset = "OVERWORLD" + ow.map.def.environment = 1 + T.eq(api.world:useFieldAction("teleport"), true, version .. " actual native Teleport starts") + T.eq(ow.teleportOut.frames, 48, version .. " native Teleport animation timing") + T.eq(ow.teleportOut.escape, nil, version .. " native Teleport uses last heal point") +end +debug.setupvalue(OW.flyTo, gameUpvalue, oldGame) +love = nil + +-- Field dispatch is tested separately from presentation, using native IDs +-- and the exact overworld methods called by the existing party menus. +for _, version in ipairs({ "red", "gold", "emerald", "platinum", "black" }) do + local api, game, ow, calls = boot(version) + local world = api.world + local gen = GameVersion.generation() + local mon = game.save.party[1] + ow.partyKnows = function() + return mon + end + ow.useSurfFieldMove = function() + return "ok" + end + ow.useCutFieldMove = function() + return "ok" + end + ow.trySurf = function(_, x, y, close, carrier) + calls[#calls + 1] = { kind = "surf", carrier = carrier, x = x, y = y } + game.stack:push({ isText = true }) + end + ow.tryCut = function(_, x, y) + calls[#calls + 1] = { kind = "cut", x = x, y = y } + game.stack:push({ isText = true }) + return true + end + ow.beginTeleportOut = function(_, done, opts) + calls[#calls + 1] = { kind = "escape", escape = opts.escape } + ow.teleportOut = {} + end + ow.escapePoint = function() + return { map = "DEST", x = 3, y = 4 } + end + local before = Merge.deepCopy(game) + local list = world:availableFieldActions() + T.same(game, before, version .. " field availability is data-only with native pure gates") + local names = {} + for _, row in ipairs(list) do + names[row.id] = true + end + T.eq(names.surf, gen <= 3 and true or nil, version .. " verified surf coverage") + T.eq(names.cut, gen <= 2 and true or nil, version .. " verified cut coverage") + T.eq(names.teleport, gen <= 3 and true or nil, version .. " verified teleport coverage") + if list[1] then + list[1].id = "MUTATED" + T.check(world:availableFieldActions()[1].id ~= "MUTATED", version .. " action rows detached") + end + T.eq(world:useFieldAction("unknown"), nil, version .. " unknown action refused") + T.eq(world:useFieldAction("surf", { sourceSlot = 1 }), nil, version .. " unsupported options refused") + if gen <= 3 then + T.eq(world:useFieldAction("surf"), true, version .. " native surf started") + T.eq(calls[#calls].carrier, mon, version .. " surf uses validated carrier") + T.eq(world:useFieldAction("surf"), nil, version .. " active dialogue prevents replay") + game.stack:pop() + ow.player.surfing = true + T.eq(world:useFieldAction("surf"), nil, version .. " no invented dismount action") + ow.player.surfing = false + T.eq(world:useFieldAction("teleport"), true, version .. " native Teleport started") + T.eq(calls[#calls].escape, nil, version .. " Teleport keeps heal-point semantics") + ow.teleportOut = nil + ow.map.def.tileset = "CAVERN" + ow.map.def.environment = 4 + ow.map.def.allowEscaping = true + T.eq(world:useFieldAction("dig"), true, version .. " native Dig started") + T.eq( + calls[#calls].escape, + gen >= 2 and true or nil, + version .. " Dig keeps generation-specific destination" + ) + ow.teleportOut = nil + if gen <= 2 then + T.eq(world:useFieldAction("cut"), true, version .. " native Cut started") + game.stack:pop() + end + else + T.eq(world:useFieldAction("surf"), nil, version .. " unsupported script adapter is explicit") + T.eq(#calls, 0, version .. " unsupported field dispatch has no effects") + end +end + +-- Deferred Gen 3 cut-ins own the request until completion; callbacks may be +-- invoked twice or outlive a replaced map/save, but effects must not replay. +local api, game, ow, calls = boot("emerald") +local world = api.world +local finish +Gen3Show.show = function(g, mon, done) + finish = done + g.stack:push({ isCutin = true }) + return true +end +T.eq(world:flyTo("DEST"), true, "cut-in queued") +T.eq(#calls, 0, "no departure before cut-in finishes") +T.eq(world:reorderParty(1, 2), nil, "cut-in blocks competing action") +game.stack:pop() +finish() +finish() +T.eq(#calls, 1, "cut-in completion departs once") +ow.flyAnim = nil +T.eq(world:flyTo("DEST"), true, "second independent request queued") +game.stack:pop() +game.save = Merge.deepCopy(game.save) +finish() +T.eq(#calls, 1, "save replacement cancels pending effect") +T.eq(world:canReorderParty(), true, "cancelled request releases its lock") +T.eq(world:flyTo("DEST"), true, "request can resume after cancellation") +game.stack:pop() +ow.map = { id = "OTHER", def = game.data.maps.HOME } +finish() +T.eq(#calls, 1, "map replacement cancels pending effect") +T.eq(world:canReorderParty(), true, "map cancellation releases lock") +T.eq(world:flyTo("DEST"), true, "abandoned presentation queued") +game.stack:pop() -- simulate native UI being dismissed without completion +T.eq(world:canReorderParty(), true, "abandoned cut-in does not leave a permanent API lock") +finish() +T.eq(#calls, 1, "abandoned callback cannot later run") +Gen3Show.show = function(g, mon, done) + done() + error("after completion") +end +local ok, err = pcall(function() + world:flyTo("DEST") +end) +T.check( + not ok and tostring(err):find("after completion", 1, true) ~= nil, + "native presentation error is propagated" +) +T.eq(#calls, 2, "presentation failure never retries a completed departure") +Gen3Show.show = function() + return false +end +ow.flyAnim = nil +-- Hooks cannot recursively start a second action or move the source unnoticed. +ow.partyKnows = function() + T.eq(world:reorderParty(1, 2), nil, "nested mutation during eligibility is refused") + return game.save.party[1] +end +T.eq(world:canFly(), true, "recursive capability probe terminates") +ow.partyKnows = function() + ow.player.cellX = ow.player.cellX + 1 + return game.save.party[1] +end +T.eq(world:flyTo("DEST"), nil, "eligibility hook changing source position cancels action") +T.eq(#calls, 2, "changed source never reaches native handler") + +local nestedApi, nestedGame, nestedWorld = boot("red") +nestedWorld.flyTo = function() + T.eq(nestedApi.world:reorderParty(1, 2), nil, "native dispatch callback cannot reorder concurrently") + T.eq(nestedApi.world:flyTo("DEST"), nil, "native dispatch callback cannot start a second flight") + nestedWorld.flyAnim = {} +end +T.eq(nestedApi.world:flyTo("DEST"), true, "guarded native dispatch still starts once") +nestedWorld.flyAnim = nil +local nativeError = { native = true } +nestedWorld.flyTo = function() + error(nativeError) +end +local okNative, errNative = pcall(function() + nestedApi.world:flyTo("DEST") +end) +T.check(not okNative and errNative == nativeError, "native error identity retained") +T.eq(nestedApi.world:canReorderParty(), true, "throwing handler releases only the API dispatch lock") + +local empty = WorldAPI.new(nil, "empty") +T.eq(empty:reorderParty(1, 2), nil, "missing game mutation safe") +T.eq(empty:canFly(), false, "missing game probe safe") +T.same(empty:availableFieldActions(), {}, "missing game has no field actions") +for _, name in ipairs({ + "canReorderParty", + "reorderParty", + "availableFieldActions", + "useFieldAction", + "canFly", + "flyTo", +}) do + T.check(Actions.supports(name), "action capability " .. name) +end +T.eq(Actions.supports("party"), false, "reader capability remains separate") +GameVersion.set(saved[1]) +Runtime.install(saved[2], saved[3], saved[4]) +love = saved[5] +Gen3Show.show = saved[6] +T.finish() From 59348147b706bad091d095ef247c100b6438b2d9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:44:35 +0200 Subject: [PATCH 007/138] Allow capability tests to follow additive native flight support --- tests/engine/mod_supports.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua index 7e3bf96f5..5d4562cd8 100644 --- a/tests/engine/mod_supports.lua +++ b/tests/engine/mod_supports.lua @@ -5,6 +5,7 @@ local T = require('tests.harness') local GameVersion = require('src.core.GameVersion') local Loader = require('src.mods.Loader') local Runtime = require('src.mods.Runtime') +local WorldAPI = require('src.world.WorldAPI') local Schemas = require('src.mods.Schemas') local Catalog = require('tests.modkit.catalog') local originalEvents, originalHooks, originalErrors = Runtime.events, Runtime.hooks, Runtime.errors @@ -72,7 +73,9 @@ for _, version in ipairs(GameVersion.ORDER) do registries = registries and yes(api, 'content', name) end T.check(registries, version .. ': all API-2 registration surfaces available') - T.check(no(api, 'world', 'flyTo') and no(api, 'hooks', 'core.logic_speed') + T.eq(yes(api, 'world', 'flyTo'), type(WorldAPI.flyTo) == 'function', + version .. ': additive native flight interface is reported when installed') + T.check(no(api, 'hooks', 'core.logic_speed') and no(api, 'hooks', 'item.use') and no(api, 'events', 'egg.hatched'), version .. ': absent Gen1Recomp API-3 features are not advertised') T.check(yes(api, 'events', 'mod.probe.ready') and yes(api, 'events', 'mod.other.ready'), From 52b0055ef9679e31e9cc0f24069c87e50c1f4401 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:05:23 +0200 Subject: [PATCH 008/138] Add detached read-only battle snapshots for mods --- docs/battle-snapshot-api.md | 119 ++++++ docs/modding.md | 9 + src/battle/BattleState.lua | 2 + src/mods/BattleSnapshots.lua | 472 +++++++++++++++++++++++ src/mods/Loader.lua | 20 +- src/mods/UnifiedIds.lua | 85 +++++ tests/engine/battle_snapshots.lua | 613 ++++++++++++++++++++++++++++++ 7 files changed, 1316 insertions(+), 4 deletions(-) create mode 100644 docs/battle-snapshot-api.md create mode 100644 src/mods/BattleSnapshots.lua create mode 100644 src/mods/UnifiedIds.lua create mode 100644 tests/engine/battle_snapshots.lua diff --git a/docs/battle-snapshot-api.md b/docs/battle-snapshot-api.md new file mode 100644 index 000000000..2e9266704 --- /dev/null +++ b/docs/battle-snapshot-api.md @@ -0,0 +1,119 @@ +# Battle snapshot API + +```lua +local battle = mod.battle -- safe in the mod entry chunk, before Game exists +local snapshot, reason = battle:snapshot() +if snapshot then + local actor = snapshot.player + if actor then print(actor.name, actor.hp, actor.maxHp) end +end +``` + +Available to API 1 and API 2 mods on all supported Gen 1–5 versions. Neither +`content_format` nor `callback_format` is required. This reads the shared +BattleState in the current Game's stack, including beneath another screen. +A read before Game is loaded also returns `nil, "no battle"` without +bootstrapping Game. Outside battle it returns `nil, "no battle"`. A UI screen merely holding a +`.battle` reference does not count as another battle. + +If the capability-query extension is installed, +`mod:supports("battle", "snapshot")` returns true without constructing Game. +`submit` and other battle capabilities return false with a reason. + +## Records + +All returned tables, including nested records and `native` diagnostics, are +copies. They carry no engine objects, functions, userdata, threads or +metatables. Unknown native Pokemon extension fields are copied as data; +cycles are preserved. These diagnostics are not a portable write format or +necessarily JSON-serializable. Changing a snapshot never changes battle/save +state or future reads. + +| Field | Meaning | +| --- | --- | +| `revision` | Positive, monotonically increasing within this facade; see below | +| `kind`, `phase`, `result` | Battle kind (including ghost/safari/oldman variants), native phase and optional outcome | +| `turn` | Native resolved-turn count; zero before the first turn | +| `double` | Whether this battle is configured as a double | +| `active` | Player position the menu currently selects for (0 or 2) | +| `player` | Occupied battler at `active`, or nil | +| `enemy` | Occupied opponent-left battler (1), or nil; use `battlers` for all foes | +| `battlers` | Array of occupied battler records in position order | +| `party` | Battle-specific player party when supplied (link copies), otherwise save party order, with one-based `slot` and `active` | +| `moves` | Current actor's battle moves, including Transform/Mimic overrides | +| `items` | Positive inventory quantities; acquisition order, then sorted unlisted IDs | +| `catchable` | Wild battle, one living foe, no ghost/noCatch/result restriction | +| `prompt` | Description of the battle screen's input phase; see below | +| `message` | Optional array of currently revealed text lines; unknown glyphs leave it unavailable | +| `messageSource` | Optional current message source text, which can include unrevealed text | +| `safariBalls` | Optional native remaining Safari ball count | +| `menuIndex`, `moveIndex`, `moveSwapIndex` | Optional one-based native menu/move/reorder cursors | +| `targets` | During targetSelect, ordered positional IDs from the native selector; otherwise empty | +| `targetIndex` | Optional one-based cursor into `targets` | +| `mimicMoves`, `mimicIndex` | Optional Mimic options and one-based cursor | + +Battler IDs are **0 player-left, 1 opponent-left, 2 player-right, 3 +opponent-right**. Array indices are not battler IDs. Empty positions are +omitted; an enemy at 3 never masquerades as enemy-left. `player` follows the +second player chooser, including after the left flank leaves the field. + +Pokemon records expose `species`, optional `item`, `nickname`, `name`, +`level`, `hp`, `maxHp`, `status`, `isEgg`, `stats`, `moves`, and copied `native` +diagnostics. Species/move/item IDs use canonical string names from this boot's +registries. Numeric DS IDs resolve through the imported names and aliases; +unresolved IDs use their string form. Pristine IDs remain stable across +presentation-name patches; definitions registered by later mods are visible. +Status codes SLP/PSN/BRN/FRZ/PAR/TOX become +sleep/poison/burn/freeze/paralyze/toxic; unknown values are retained. + +Stats use `hp`, `attack`, `defense`, `speed`, `special`, `specialAttack`, +`specialDefense`; Gen 1's special supplies both split stats. `maxHp` comes +from stored `stats.hp`. HP and status are logical state, not interpolated HUD +values. Battlers additionally expose `id`, `side` (player/enemy), `active`, +`absent` (false for occupied rows), `ally`, optional one-based `partyIndex`, +`displaySpecies`, `battleStats`, `types`, and `stages`. Original species and +stored stats stay separate from Transform's displayed species/current stats. +NPC allies are not added to the player's party. + +Moves expose one-based `slot`, portable `id`, optional `name`, `pp`, `maxPp`, +`ppUps`, `type`, `power`, `accuracy`. Max PP follows the native base PP plus +clamped 0–3 PP Ups, each adding floor(base PP / 5). Definition fields are base +values, not damage/accuracy predictions. Only the top-level actor `moves` +also expose `disabled`: true for Disable, no PP, Taunt or Torment; false if +none applies on Gen 1–4. In BW this field is omitted unless a common +restriction is known, because additional native Choice/Imprison/Gravity/ +Heal Block controllers may refuse the move. Nil is unknown, not false. +Mimic options retain their original move `slot` even if options are compacted. + +Items expose `id`, optional `name`, `count`, `ball`, `catchable`, optional +`pocket`. This is an **inventory view**, not a list of currently usable items; +it can include badges/key items. Ball classification follows imported ball +metadata. `catchable` describes the target restriction, not permission to use +an item: noItems/frontier rules, party capacity and the active bag controller +still own item eligibility. No `needsTarget`, catchChance, hitChance, +displayPower, effectiveness or guessed preview is produced. + +## Prompts and revisions + +`prompt` is `menu`, `moves`, `target`, `mimic`, `safari`, `advance`, or +`locked`. Intro, automatic demos/Palace, conservative Arena menu handling, +known automatic battler locks and every covered battle are locked. Message +advance is reported only when native manual text waits have finished their +pre-advance delay. Party, bag and choice screens above battle stay locked; +this API does not inspect or drive those screens. Link controllers remain locked. +Visible messages decode the native revealed glyph buffer through the imported +charmap and already-loaded Unicode glyph map; this never initializes a font. A prompt is descriptive, +not an action-permission token. There is no battle input/submission API. + +Identical successive snapshot records retain their revision. A change to +any exposed data advances it once at the next read; animation clocks and +interpolated HP alone do not. A different battle or Game also advances it, +even with identical records. Observing `no battle` invalidates the previous +record, so re-entry advances it. Unobserved transitions between reads cannot +be detected if the same Game and battle objects are reused. Each mod has its +own counter; revisions are not save data or globally synchronized IDs. + +Reading constructs no Pokemon or screen, loads no gameplay/graphics module, +executes no action, emits no event/hook and consumes no RNG. Only facade +bookkeeping and loader-local ID indexes change. The API provides an observer +boundary, not hidden-information filtering or a write-back transaction. diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..dbbfadc75 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -152,6 +152,15 @@ Three rules worth knowing: Returning `nil` from `drawWorld` is a normal answer meaning "not this frame"; the engine draws the vanilla world instead. +## Read-only battle snapshots + +`mod.battle:snapshot()` returns a detached common battle view on Gen 1–5, +including singles and doubles, or `nil, "no battle"`. It can be captured in +an entry chunk before Game exists. See [Battle snapshot API](battle-snapshot-api.md) +for records, revisions, prompt semantics and intentionally omitted previews. +It reads state only; there is no `submit` method. When capability queries are +installed, `mod:supports("battle", "snapshot")` reports this interface. + ## Battle sprite scaling The enemy's front pic draws at 1x and the player's back pic at 2x, the way diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 9688aa08f..c72bad08e 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -53,6 +53,8 @@ local TEXT_AREA_ROWS = { 112, 128 } local BattleState = {} BattleState.__index = BattleState BattleState.isOpaque = true +-- Data readers can identify the shared Gen 1-5 battle without loading this module. +BattleState.isBattleState = true -- pokered prints the battle lines itself (engine/battle/core.asm and the -- move-effect banks), and the importer extracts every one of them, so the diff --git a/src/mods/BattleSnapshots.lua b/src/mods/BattleSnapshots.lua new file mode 100644 index 000000000..79a0aa417 --- /dev/null +++ b/src/mods/BattleSnapshots.lua @@ -0,0 +1,472 @@ +-- A data-only view of the shared Gen 1-5 battle. Never call battle/menu, +-- item-effect or preview helpers here: some of those consume RNG or write state. +local Ids = require("src.mods.UnifiedIds") +local Snapshots = {} + +local STATUS = + { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local STATS = { + hp = "hp", + attack = "attack", + defense = "defense", + speed = "speed", + special = "special", + specialAttack = "spatk", + specialDefense = "spdef", +} + +local function copy(value, seen) + local kind = type(value) + if kind ~= "table" then + if kind == "string" or kind == "boolean" or kind == "number" then + return value + end + return nil + end + seen = seen or {} + if seen[value] then + return seen[value] + end + local out = {} + seen[value] = out + for key, v in pairs(value) do + if type(key) == "string" or type(key) == "number" then + out[key] = copy(v, seen) + end + end + return out +end + +local function equal(a, b, seen) + if type(a) ~= type(b) then + return false + end + if type(a) ~= "table" then + return a == b or (a ~= a and b ~= b) + end + seen = seen or {} + if seen[a] then + return seen[a] == b + end + seen[a] = b + for k, v in pairs(a) do + if not equal(v, b[k], seen) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end + +local function stats(raw) + local out = {} + for public, native in pairs(STATS) do + out[public] = (raw or {})[native] + end + out.specialAttack = out.specialAttack or out.special + out.specialDefense = out.specialDefense or out.special + return out +end + +local function at(battle, pos) + -- Constructors fill these aliases before enter()/syncSides populates the + -- positional arrays. A cleared alias also truthfully represents a faint. + if pos == 0 then + return battle.player + end + if pos == 1 then + return battle.enemy + end + local side = battle.sides and battle.sides[pos % 2 + 1] + return side and side.battlers and side.battlers[math.floor(pos / 2) + 1] +end + +local function activeSlot(battle) + if not battle.double then + return 0 + end + local pos = battle.choosingSlot + if (pos == 0 or pos == 2) and at(battle, pos) then + return pos + end + for _, id in ipairs({ 0, 2 }) do + local b = at(battle, id) + if b and not b.ally then + return id + end + end + return 0 +end + +local function moveView(move, slot, data, ids) + local id = type(move) == "table" and move.id or move + local def = (data.moves or {})[id] or {} + local ups = type(move) == "table" and math.min(3, math.max(0, math.floor(tonumber(move.ppUps) or 0))) or 0 + return { + slot = slot, + id = ids:read("moves", id), + name = def.name, + pp = type(move) == "table" and move.pp or nil, + maxPp = def.pp and (def.pp + ups * math.floor(def.pp / 5)) or nil, + ppUps = ups, + type = def.type, + power = def.power, + accuracy = def.accuracy, + } +end + +local function monView(raw, data, ids) + if not raw then + return nil + end + local out = { + species = ids:read("pokemon", raw.species), + item = ids:read("items", raw.item), + nickname = raw.nickname, + name = raw.name or raw.nickname or ((data.pokemon or {})[raw.species] or {}).name, + level = raw.level, + hp = raw.hp, + maxHp = raw.stats and raw.stats.hp, + status = STATUS[raw.status] or raw.status, + isEgg = raw.isEgg == true, + stats = stats(raw.stats), + moves = {}, + native = copy(raw), + } + for slot, move in ipairs(raw.moves or {}) do + out.moves[slot] = moveView(move, slot, data, ids) + end + return out +end + +local function battlerView(b, pos, party, data, ids) + if not (b and b.mon) then + return nil + end + local out = monView(b.mon, data, ids) + out.name = b.name or out.name + out.id, out.side, out.active, out.absent = pos, pos % 2 == 0 and "player" or "enemy", true, false + out.displaySpecies = ids:read("pokemon", b.species or b.mon.species) + out.battleStats, out.types, out.stages = + stats(b.curStats or b.mon.stats), copy(b.curTypes), copy(b.stages or {}) + out.ally = b.ally == true + for index, mon in ipairs(party) do + if mon == b.mon then + out.partyIndex = index + break + end + end + out.moves = {} + for slot, move in ipairs(b.curMoves or b.mon.moves or {}) do + out.moves[slot] = moveView(move, slot, data, ids) + end + return out +end + +-- Prompt describes the battle screen only. Covered screens and automatic +-- controllers stay locked; no attempt is made to inspect or drive their UI. +local function prompt(b, covered, actor) + if + covered + or b.result + or b.waitingUI + or b.demo + or b.pokedudeDemo + or b.palace + or b.kind == "link" + or (b.arena and b.phase == "menu") + then + return "locked" + end + if b.phase == "menu" then + if b.safari then + return (b.safari.balls or 0) > 0 and "safari" or "locked" + end + if + not (actor and actor.mon and (actor.mon.hp or 0) > 0) + or actor.ally + or actor.mustRecharge + or actor.charging + or actor.rageMove + or (actor.thrashTurns and actor.thrashTurns > 0) + then + return "locked" + end + -- Sky Drop's controller in BW owns the menu; unknown native locks are + -- not advertised as actionable (this API never submits input). + if b.bw and actor.bwSkyDropped then + return "locked" + end + return "menu" + end + if b.phase == "moveSelect" then + return "moves" + end + if b.phase == "targetSelect" then + return "target" + end + if b.phase == "mimicSelect" then + return "mimic" + end + if + b.phase == "messages" + and b.current + and not b.current.choice + and ((b.msgWaiting and (b.msgPreWait or 0) <= 0) or (b.msgPrompt and (b.msgPromptWait or 0) <= 0)) + then + return "advance" + end + return "locked" +end + +-- Decode only glyphs already revealed by the native message clock. Unknown +-- glyphs leave the optional visible message unavailable, never reveal ahead. +local function visibleMessage(battle, covered, data) + if covered or battle.phase ~= "messages" or not battle.shown then + return nil + end + local reverse = {} + local function add(rows) + for _, row in ipairs(rows or {}) do + if type(row.code) == "number" and type(row.seq) == "string" and reverse[row.code] == nil then + reverse[row.code] = row.seq + end + end + end + add(data.font and data.font.charmap) + local pages, names = data.font and data.font.pages or {}, {} + for name in pairs(pages) do + names[#names + 1] = name + end + table.sort(names) + for _, name in ipairs(names) do + add(type(pages[name]) == "table" and pages[name].charmap) + end + local unicode = package.loaded["src.translation.UnicodeFont"] + local chars = type(unicode) == "table" and unicode.chars or {} + local scaffold = data.font + and type(data.font.source) == "string" + and data.font.source:find("Gen2 scaffold", 1, true) + local out = {} + for i, codes in ipairs(battle.shown) do + local line = {} + for _, code in ipairs(codes) do + local seq = chars[code] or reverse[code] + if scaffold and type(code) == "number" and code >= 0 and code <= 255 then + seq = string.char(code) + end + if not seq then + return nil + end + line[#line + 1] = seq + end + out[i] = table.concat(line) + end + return out +end + +local function kind(battle) + -- The same variants as BattleState:battleKind(), without a method call. + if battle.ghost then + return "ghost" + end + if battle.safari then + return "safari" + end + if battle.demo and not battle.dudeDemo then + return "oldman" + end + return battle.kind +end + +local function build(game, battle, covered, ids) + local data, save = battle.data or game.data or {}, game.save or {} + local party, enemyParty = battle.playerParty or save.party or {}, battle.enemyParty or {} + local active = activeSlot(battle) + local actor = at(battle, active) + local out = { + kind = kind(battle), + phase = battle.phase, + double = battle.double == true, + active = active, + turn = battle.turnCount or 0, + prompt = prompt(battle, covered, actor), + message = visibleMessage(battle, covered, data), + messageSource = battle.current and battle.current.text, + safariBalls = battle.safari and battle.safari.balls, + menuIndex = battle.menuIndex, + moveIndex = battle.moveIndex, + moveSwapIndex = battle.moveSwapIndex, + result = battle.result, + party = {}, + battlers = {}, + moves = {}, + items = {}, + targets = {}, + } + local foes = 0 + local rows = {} + for pos = 0, (out.double and 3 or 1) do + local b = at(battle, pos) + local row = battlerView( + b, + pos, + pos % 2 == 0 and (b and b.ally and battle.ally and battle.ally.party or party) or enemyParty, + data, + ids + ) + rows[pos] = row + if row then + out.battlers[#out.battlers + 1] = row + if pos % 2 == 1 and (row.hp or 0) > 0 then + foes = foes + 1 + end + end + end + out.player, out.enemy = rows[active], rows[1] + out.catchable = battle.kind == "wild" + and not battle.ghost + and not battle.noCatch + and not battle.result + and foes == 1 + for slot, raw in ipairs(party) do + local row = monView(raw, data, ids) + row.slot, row.active = slot, false + for _, pos in ipairs({ 0, 2 }) do + local b = at(battle, pos) + if b and not b.ally and b.mon == raw then + row.active = true + end + end + out.party[slot] = row + end + for slot, move in ipairs(actor and (actor.curMoves or actor.mon.moves) or {}) do + local row = moveView(move, slot, data, ids) + local def = (data.moves or {})[move.id] or {} + -- The three explicit guards used by BattleState's move selector. This + -- is a local menu restriction, not a guarantee a move will execute. + row.disabled = actor.disabledSlot == slot + or (move.pp or 0) <= 0 + or (actor.tauntTurns ~= nil and (def.power or 0) == 0) + or (actor.tormented and actor.lastMove ~= nil and move.id == actor.lastMove) + or false + -- BW adds Choice/Imprison/Gravity/Heal Block checks through native + -- controllers. Do not report an unverified false for those moves. + if battle.bw and not row.disabled then + row.disabled = nil + end + out.moves[slot] = row + end + -- Inventory quantities, not a use-eligibility result. Keep acquisition + -- order, then append positive unlisted IDs deterministically. + local inventory, seen, remaining = save.inventory or {}, {}, {} + local function item(id) + if seen[id] then + return + end + seen[id] = true + local count = inventory[id] + if type(count) ~= "number" or count <= 0 then + return + end + local def = (data.items or {})[id] or {} + local balls = data.balls or {} + local ball = def.ball ~= nil and def.ball ~= false or balls[id] ~= nil or balls[tostring(id)] ~= nil + out.items[#out.items + 1] = { + id = ids:read("items", id), + name = def.name, + count = count, + ball = ball, + catchable = ball and out.catchable, + pocket = def.pocket, + } + end + for _, id in ipairs(save.bagOrder or {}) do + item(id) + end + for id in pairs(inventory) do + if not seen[id] then + remaining[#remaining + 1] = id + end + end + table.sort(remaining, function(a, b) + return type(a) == type(b) and a < b or type(a) < type(b) + end) + for _, id in ipairs(remaining) do + item(id) + end + if battle.phase == "targetSelect" then + for _, target in ipairs(battle.targetChoices or {}) do + for pos = 0, 3 do + if at(battle, pos) == target then + out.targets[#out.targets + 1] = pos + break + end + end + end + out.targetIndex = battle.targetIndex + end + if battle.phase == "mimicSelect" then + out.mimicMoves, out.mimicIndex = {}, battle.mimicIndex + for index, move in ipairs(battle.mimicMoves or {}) do + out.mimicMoves[index] = moveView(move, move.slot or index, data, ids) + end + end + return out +end + +function Snapshots.new(gameProvider, loader) + -- Ids keeps alias metadata on the loader, never on gameplay records. + local initial = Ids.new(loader) + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + initial:entries(kind) + end + local facade = {} + local lastGame, lastBattle, previous, revision = nil, nil, nil, 0 + function facade:snapshot() + local game = gameProvider() + local states = game and game.stack and game.stack.states or {} + local battle, index + for i = #states, 1, -1 do + if states[i].isBattleState == true then + battle, index = states[i], i + break + end + end + if not battle then + lastGame, lastBattle, previous = nil, nil, nil + return nil, "no battle" + end + local out = copy(build(game, battle, index ~= #states, Ids.new(loader))) + if game ~= lastGame or battle ~= lastBattle or not equal(out, previous) then + revision = revision + 1 + end + lastGame, lastBattle, previous = game, battle, out + local result = copy(out) + result.revision = revision + return result + end + return facade +end +-- Run after construction so an optional capability provider and its other +-- surface wrappers are already installed. Standalone main has no provider. +function Snapshots.withCapabilities(api) + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "battle" then + if name == "snapshot" then + return true + end + return false, "unknown battle capability: " .. tostring(name) + end + return supports(self, surface, name) + end + end + return api +end +return Snapshots diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..08e71b498 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -21,6 +21,7 @@ local Events = require("src.mods.Events") local Hooks = require("src.mods.Hooks") local ModStorage = require("src.mods.Storage") local Runtime = require("src.mods.Runtime") +local BattleSnapshots = require("src.mods.BattleSnapshots") local ModGens = require("src.mods.ModGens") local ModSeams = require("src.mods.ModSeams") -- Module-name aliases for mods written against the Gold port's per-generation @@ -841,6 +842,8 @@ function Loader:_api(mod) if okN then hostInfo = info end end end + -- Capture pristine ID aliases before mod merges; the facade resolves Game only on a read. + local battle = BattleSnapshots.new(function() return loader:_game(true) end, loader) local engineVersion do local okE, V = pcall(require, "src.core.Version") @@ -868,6 +871,7 @@ function Loader:_api(mod) -- returns the same module table the engine holds, so a mod that mutates -- one is changing the engine, exactly as it always was. engineRequire = engineRequire, + battle = battle, -- 1 to 5. The one field worth reaching for before anything else. generation = hostGen, -- the version id the launcher started: "red", "crystal", "emerald", @@ -1120,8 +1124,10 @@ function Loader:_api(mod) return data end) -- mod.world / mod.game / mod.storage all materialize on first touch, for - -- the same reason: a headless load must not drag the world stack in, and - -- the Game the facade acts on is still being wired when the entry chunk + -- the same reason: a headless load must not drag the world stack in. + -- Battle capability queries can also be installed before Game exists. + BattleSnapshots.withCapabilities(api) + -- The Game the facade acts on is still being wired when the entry chunk -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. local world, storage @@ -1148,8 +1154,14 @@ end -- the live Game. An injected reference wins so a headless caller can hand -- over a stub; otherwise the boot singleton, whose stack and overworld fill -- in after this loader returns -- holding the table keeps the facade live. -function Loader:_game() - return self.game or engineRequire("src.core.Game") +function Loader:_game(loadedOnly) + if self.game then return self.game end + -- Observational facades must not bootstrap Game from an early/headless read. + if loadedOnly then + local game = package.loaded["src.core.Game"] + return type(game) == "table" and game or nil + end + return engineRequire("src.core.Game") end function Loader:_loadMod(mod) diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua new file mode 100644 index 000000000..50866a636 --- /dev/null +++ b/src/mods/UnifiedIds.lua @@ -0,0 +1,85 @@ +-- Resolve portable names through this boot's own data, never a hard-coded +-- National Dex/item-number table (ROM hacks can assign entirely different IDs). +local Ids = {} +local function slug(name) + if type(name) ~= "string" then return nil end + name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M") + :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2") + return name ~= "" and name or nil +end +local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" } +local function canonical(key, row, kind) + if kind == "pokemon" or kind == "moves" or kind == "items" then + if type(key) ~= "string" or key:match("^SPECIES_%d+$") + or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then + return aliases[slug(row.name)] or slug(row.name) + end + return aliases[key] or key + end + -- Trainer classes, individual trainers and map IDs are game-specific. + return type(key) == "number" and tostring(key) or key +end + +function Ids.new(loader) + local indexes = {} + local function index(kind) + if indexes[kind] then return indexes[kind] end + local out = { public = {}, native = {} } + indexes[kind] = out + local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind)) + -- Preserve identity/name metadata before the loader merges alias keys + -- into separate result tables. Presentation-name patches cannot rename IDs. + loader._unifiedIdBase = loader._unifiedIdBase or {} + local base = loader._unifiedIdBase[kind] + if not base then + base = {} + for key, row in pairs(registry.base and registry.base() or {}) do + if type(row) == "table" then + base[key] = { identity = row, id = canonical(key, row, kind) } + end + end + loader._unifiedIdBase[kind] = base + end + local groups = {} + for key, row in registry:each() do + if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then + -- Aliases pointing at one pristine record form one identity even + -- after an earlier native mod patched only one of its keys. + local identity = base[key] and base[key].identity or row + local group = groups[identity] + if not group then + local id = base[key] and base[key].id or canonical(key, row, kind) + if id then + group = { id = id, key = key, keys = {} } + groups[identity] = group + local previous = out.public[id] + -- false is an ambiguity marker, never last-writer-wins. + if previous ~= nil then out.public[id] = false else out.public[id] = group end + end + end + if group then + group.keys[#group.keys + 1] = key + out.native[key] = group + if type(key) == "number" then group.key = key end + end + end + end + return out + end + return { + resolve = function(_, kind, id) + local found = index(kind).public[id] + if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end + if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end + return found + end, + read = function(_, kind, id) + if id == nil or id == false or id == 0 or id == "" then return nil end + local found = index(kind).native[id] + return found and found.id or tostring(id) + end, + entries = function(_, kind) return index(kind).public end, + } +end +return Ids diff --git a/tests/engine/battle_snapshots.lua b/tests/engine/battle_snapshots.lua new file mode 100644 index 000000000..27c992c5f --- /dev/null +++ b/tests/engine/battle_snapshots.lua @@ -0,0 +1,613 @@ +-- ROM-free contract tests through the real mod loader and BattleState. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local BattleState = require("src.battle.BattleState") +local GameVersion = require("src.core.GameVersion") +local Merge = require("src.mods.Merge") +local Json = require("src.link.Json") +local oldVersion, oldLove = GameVersion.get(), love +love = nil + +-- Exercise the real loader resolver, including Lua 5.1's in-progress marker. +local cachedGame = package.loaded["src.core.Game"] +package.loaded["src.core.Game"] = nil +T.eq(Loader._game({}, true), nil, "loaded-only lookup does not bootstrap Game") +T.eq(package.loaded["src.core.Game"], nil, "lookup leaves module cache untouched") +package.loaded["src.core.Game"] = true +T.eq(Loader._game({}, true), nil, "in-progress Game is unavailable") +local injected = {} +T.eq(Loader._game({ game = injected }, true), injected, "explicit Game takes precedence") +package.loaded["src.core.Game"] = injected +T.eq(Loader._game({}, true), injected, "live cached Game is available") +package.loaded["src.core.Game"] = cachedGame + +local function boot(version, level) + GameVersion.set(version) + local ds = GameVersion.generation() >= 4 + local s, m, item = ds and 25 or "PIKACHU", ds and 33 or "TACKLE", ds and 4 or "POKE_BALL" + local species, move = + { name = "Pikachu", types = { "ELECTRIC" } }, + { name = "Tackle", pp = 35, power = 40, accuracy = 95, type = "NORMAL" } + local data = { + pokemon = { [s] = species }, + moves = { [m] = move }, + items = { + [item] = { name = "Poké Ball", ball = "POKE_BALL" }, + Z_EXTRA = { name = "Extra" }, + A_EXTRA = { name = "Earlier" }, + }, + } + if ds then + data.pokemon.SPECIES_25 = species + data.moves.MOVE_33 = move + end + local function mon(nick) + return { + species = s, + nickname = nick, + level = 7, + hp = 19, + status = "PSN", + stats = { hp = 23, attack = 10, defense = 11, special = 12, speed = 13 }, + moves = { { id = m, pp = 29, ppUps = 1 } }, + isEgg = false, + extension = { flag = false }, + } + end + local a, b, e, f = mon("Left"), mon("Right"), mon("Foe"), mon("Other foe") + if GameVersion.generation() > 1 then + for _, raw in ipairs({ a, b, e, f }) do + raw.stats.spatk, raw.stats.spdef = 14, 15 + end + end + local party = { a, b } + local function battler(raw) + return { + mon = raw, + name = raw.nickname, + species = raw.species, + curStats = raw.stats, + curTypes = { "ELECTRIC" }, + curMoves = raw.moves, + stages = {}, + } + end + local game = { + data = data, + save = { + party = party, + inventory = { + [item] = 3, + Z_EXTRA = 2, + A_EXTRA = 1, + ZERO = 0, + }, + bagOrder = { item, item, "ZERO" }, + }, + stack = { states = {} }, + } + local btl = setmetatable({ + game = game, + data = data, + kind = "wild", + phase = "menu", + queue = {}, + player = battler(a), + enemy = battler(e), + enemyParty = { e, f }, + turnCount = 0, + sides = { { battlers = {} }, { battlers = {} } }, + }, BattleState) + btl:placeBattler(0, btl.player) + btl:placeBattler(1, btl.enemy) + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = level or 2, + entry = "main.lua", + }), + ["mods/probe/main.lua"] = [[return function(mod) + assert(type(mod.battle.snapshot)=="function") + mod.exports.api=mod + mod.content.moves:register("MOVE_9000", {id="MOVE_9000", name="Late move", pp=10, power=10, + accuracy=100, type="NORMAL", effect="NO_ADDITIONAL_EFFECT"}) + end]], + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + return (p == "mods" or p == "mods/probe") and { type = "directory" } or files[p] and { type = "file" } + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolves = 0 + loader._game = function() + resolves = resolves + 1 + return game + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + return loader.apis.probe, loader, game, btl, battler, { s, m, item }, function() + return resolves + end +end + +-- Freeze the whole graph as table identities + raw entries, including cycles. +local function freeze(root) + local graph = {} + local function visit(value) + if type(value) ~= "table" or graph[value] then + return + end + local entries = {} + graph[value] = entries + for key, child in pairs(value) do + entries[key] = child + visit(child) + end + end + visit(root) + return function() + for tbl, entries in pairs(graph) do + for key, value in pairs(entries) do + if tbl[key] ~= value then + return false + end + end + for key in pairs(tbl) do + if entries[key] == nil then + return false + end + end + end + return true + end +end + +local function forbidden() + error("snapshot crossed into gameplay") +end +local oldRandom = math.random +math.random = forbidden +for _, version in ipairs(GameVersion.ORDER) do + local api, loader, game, b, battler, ids, resolves = boot(version) + local label = version .. ": " + T.eq(resolves(), 0, label .. "entry can capture facade before Game exists") + if api.supports then + T.eq(api:supports("battle", "snapshot"), true, label .. "snapshot capability") + T.eq(api:supports("battle", "submit"), false, label .. "no input submission") + T.eq(resolves(), 0, label .. "probe does not resolve Game") + end + T.eq(api.battle, api.battle, label .. "stable facade") + T.eq(api.battle.submit, nil, label .. "no mutation method") + local no, reason = api.battle:snapshot() + T.eq(no, nil, label .. "no battle") + T.eq(reason, "no battle", label .. "no battle reason") + game.stack.states = { { battle = b, phase = "menu", player = b.player, enemy = b.enemy } } + T.eq(api.battle:snapshot(), nil, label .. "a UI holding battle is not battle") + game.stack.states = { b } + b.rng = forbidden + b.menuLockedAction, b.fightLockedAction, b.menuBattler, b.playerHasPP = + forbidden, forbidden, forbidden, forbidden + b.catchAttempt, b.ballDef, b.moveDef, b.choosingSlotNow = forbidden, forbidden, forbidden, forbidden + b.volatile, b.enemyAction, b.update, b.draw = forbidden, forbidden, forbidden, forbidden + local unchanged = freeze(game) + local snap = api.battle:snapshot() + T.check(unchanged(), label .. "complete game graph unchanged") + T.eq(snap.revision, 1, label .. "first revision") + T.eq(snap.prompt, "menu", label .. "menu phase") + T.eq(snap.double, false, label .. "single battle") + T.eq(snap.active, 0, label .. "left actor") + T.eq(snap.kind, "wild", label .. "wild kind") + T.eq(snap.turn, 0, label .. "before first turn") + T.eq(snap.catchable, true, label .. "single wild target") + T.eq(#snap.battlers, 2, label .. "both occupied slots") + T.eq(snap.player.species, "PIKACHU", label .. "portable species") + T.eq(snap.player.name, "Left", label .. "battler display name") + T.eq(snap.player.hp, 19, label .. "logical HP") + T.eq(snap.player.maxHp, 23, label .. "max HP") + T.eq(snap.player.status, "poison", label .. "portable status") + T.eq(snap.player.stats.specialAttack, GameVersion.generation() == 1 and 12 or 14, label .. "special attack") + T.eq( + snap.player.stats.specialDefense, + GameVersion.generation() == 1 and 12 or 15, + label .. "special defense" + ) + T.eq(snap.player.partyIndex, 1, label .. "party slot by identity") + T.eq(snap.party[1].active, true, label .. "lead active") + T.eq(snap.party[2].active, false, label .. "bench inactive") + T.eq(snap.party[2].slot, 2, label .. "party slot one based") + T.eq(snap.moves[1].id, "TACKLE", label .. "portable move") + T.eq(snap.moves[1].maxPp, 42, label .. "PP Up maximum") + T.eq(snap.moves[1].pp, 29, label .. "current PP") + T.eq(snap.moves[1].disabled, false, label .. "unrestricted move") + T.eq(snap.moves[1].accuracy, 95, label .. "base accuracy") + T.eq(#snap.items, 3, label .. "positive inventory deduplicated") + T.eq(snap.items[1].id, "POKE_BALL", label .. "portable ball and bag order") + T.eq(snap.items[1].count, 3, label .. "inventory quantity") + T.eq(snap.items[1].ball, true, label .. "ball classification") + T.eq(snap.items[2].id, "A_EXTRA", label .. "unlisted inventory sorted") + T.eq(snap.items[3].id, "Z_EXTRA", label .. "stable inventory order") + T.eq(snap.items[1].catchChance, nil, label .. "no guessed catch preview") + T.eq(snap.items[1].needsTarget, nil, label .. "no guessed item eligibility") + T.same(api.battle:snapshot(), snap, label .. "unchanged reads stable") + snap.player.hp = 0 + snap.player.native.hp = 0 + snap.player.native.extension.flag = true + snap.moves[1].pp = 0 + snap.items[1].count = 0 + snap.party[1].stats.hp = 0 + snap.player.types[1] = "WATER" + snap.player.stages.attack = 6 + local clean = api.battle:snapshot() + T.eq(clean.revision, 1, label .. "caller writes do not alter revision") + T.eq(clean.player.hp, 19, label .. "caller writes isolated") + T.eq(clean.player.native.extension.flag, false, label .. "false preserved in diagnostics") + T.eq(clean.moves[1].pp, 29, label .. "move tables isolated") + T.eq(clean.items[1].count, 3, label .. "item tables isolated") + T.eq(clean.party[1].stats.hp, 23, label .. "party stats isolated") + T.eq(clean.player.types[1], "ELECTRIC", label .. "types isolated") + T.eq(clean.player.stages.attack, nil, label .. "stages isolated") + + b.frame, b.player.shownHP, b.player.picAnim = 99, 12, { tick = 5 } + T.eq(api.battle:snapshot().revision, 1, label .. "animation ticks excluded") + b.player.mon.hp = 18 + local changed = api.battle:snapshot() + T.eq(changed.revision, 2, label .. "HP changes revision") + T.eq(changed.player.hp, 18, label .. "new logical HP visible") + T.eq(clean.player.hp, 19, label .. "old result remains detached") + b.turnCount = 1 + T.eq(api.battle:snapshot().turn, 1, label .. "turn number updates") + local rev = api.battle:snapshot().revision + game.stack.states[#game.stack.states + 1] = { battle = b } + T.eq(api.battle:snapshot().prompt, "locked", label .. "covered battle locked") + T.check(api.battle:snapshot().revision > rev, label .. "coverage changes revision") + table.remove(game.stack.states) + local cases = { + { "intro", "locked" }, + { "messages", "locked" }, + { "moveSelect", "moves" }, + { "targetSelect", "target" }, + { "mimicSelect", "mimic" }, + { "finished", "locked" }, + } + for _, c in ipairs(cases) do + b.phase = c[1] + T.eq(api.battle:snapshot().prompt, c[2], label .. "phase " .. c[1]) + end + b.phase = "messages" + b.current = { text = "Current text" } + b.msgPrompt = true + b.msgPromptWait = 3 + T.eq(api.battle:snapshot().prompt, "locked", label .. "pre-advance delay") + b.msgPromptWait = 0 + T.eq(api.battle:snapshot().prompt, "advance", label .. "manual message advance") + T.eq(api.battle:snapshot().messageSource, "Current text", label .. "current message source") + T.eq(api.battle:snapshot().message, nil, label .. "no invented visible message") + game.data.font = + { charmap = { { code = 128, seq = "A" }, { code = 129, seq = "B" }, { code = 130, seq = "C" } } } + b.shown = { { 128, 129 }, { 130 } } + local text = api.battle:snapshot() + T.same(text.message, { "AB", "C" }, label .. "only revealed glyphs in visible message") + b.shown[2][2] = 128 + T.check(api.battle:snapshot().revision > text.revision, label .. "revealed glyph changes revision") + T.same(text.message, { "AB", "C" }, label .. "older visible message detached") + b.shown[1][1] = 999999 + T.eq(api.battle:snapshot().message, nil, label .. "unknown glyph is not guessed") + game.data.font.pages = { extra = { charmap = { { code = 999999, seq = "Ω" } } } } + T.same(api.battle:snapshot().message, { "ΩB", "CA" }, label .. "font page charmap read") + local unicode = package.loaded["src.translation.UnicodeFont"] + package.loaded["src.translation.UnicodeFont"] = { chars = { [-1] = "漢" } } + b.shown = { { -1 } } + T.same(api.battle:snapshot().message, { "漢" }, label .. "already loaded Unicode mapping read") + package.loaded["src.translation.UnicodeFont"] = unicode + game.data.font = { source = "Gen2 scaffold" } + b.shown = { { 65, 66 } } + T.same(api.battle:snapshot().message, { "AB" }, label .. "scaffold ASCII glyphs read") + game.stack.states[2] = { battle = b } + T.eq(api.battle:snapshot().message, nil, label .. "covered screen has no guessed visible message") + game.stack.states[2] = nil + b.shown = nil + b.current.choice = function() end + T.eq(api.battle:snapshot().prompt, "locked", label .. "choice is its own controller") + b.current = nil + b.msgPrompt = nil + b.phase = "menu" + for _, flag in ipairs({ "waitingUI", "demo", "pokedudeDemo", "palace", "arena" }) do + b[flag] = true + T.eq(api.battle:snapshot().prompt, "locked", label .. flag .. " locked") + b[flag] = nil + end + for _, flag in ipairs({ "mustRecharge", "charging", "rageMove", "thrashTurns" }) do + b.player[flag] = 1 + T.eq(api.battle:snapshot().prompt, "locked", label .. flag .. " locked") + b.player[flag] = nil + end + b.bw = true + b.player.bwSkyDropped = true + T.eq(api.battle:snapshot().prompt, "locked", label .. "Sky Drop lock") + b.bw = nil + b.player.bwSkyDropped = nil + b.safari = { balls = 3 } + T.eq(api.battle:snapshot().kind, "safari", label .. "Safari battle kind") + T.eq(api.battle:snapshot().safariBalls, 3, label .. "Safari ball count") + T.eq(api.battle:snapshot().prompt, "safari", label .. "Safari prompt") + b.safari.balls = 0 + T.eq(api.battle:snapshot().prompt, "locked", label .. "no Safari balls") + b.safari = nil + b.demo = true + T.eq(api.battle:snapshot().kind, "oldman", label .. "automatic capture demo kind") + b.demo = nil + b.ghost = true + T.eq(api.battle:snapshot().kind, "ghost", label .. "ghost kind") + b.ghost = nil + b.menuIndex = 2 + b.moveIndex = 3 + b.moveSwapIndex = 1 + local cursors = api.battle:snapshot() + T.eq(cursors.menuIndex, 2, label .. "menu cursor") + T.eq(cursors.moveIndex, 3, label .. "move cursor") + T.eq(cursors.moveSwapIndex, 1, label .. "reorder cursor") + b.menuIndex = 1 + T.check(api.battle:snapshot().revision > cursors.revision, label .. "cursor changes revision") + for _, flag in ipairs({ "ghost", "noCatch", "result" }) do + b[flag] = true + T.eq(api.battle:snapshot().catchable, false, label .. flag .. " uncatchable") + b[flag] = nil + end + b.kind = "trainer" + T.eq(api.battle:snapshot().catchable, false, label .. "trainer uncatchable") + b.kind = "wild" + b.phase = "moveSelect" + for _, guard in ipairs({ "disable", "pp", "taunt", "torment" }) do + if guard == "disable" then + b.player.disabledSlot = 1 + end + if guard == "pp" then + b.player.curMoves[1].pp = 0 + end + if guard == "taunt" then + b.player.tauntTurns = 2 + game.data.moves[ids[2]].power = 0 + end + if guard == "torment" then + b.player.tormented = true + b.player.lastMove = ids[2] + end + T.eq(api.battle:snapshot().moves[1].disabled, true, label .. guard .. " menu restriction") + b.player.disabledSlot = nil + b.player.curMoves[1].pp = 29 + b.player.tauntTurns = nil + game.data.moves[ids[2]].power = 40 + b.player.tormented = nil + b.player.lastMove = nil + end + -- Native alias keys may be split during merging; a name patch must not + -- rename a portable ID. Later registrations still appear in fresh reads. + game.data.pokemon[ids[1]].name = "Renamed presentation" + T.eq(api.battle:snapshot().player.species, "PIKACHU", label .. "name patches preserve identity") + local originalMoves = b.player.curMoves + b.player.curMoves = { { id = "MOVE_9000", pp = 8 } } + T.eq(api.battle:snapshot().moves[1].id, "LATE_MOVE", label .. "late mod ID resolved") + b.player.curMoves = originalMoves + b.bw = { field = {}, sides = { {}, {} } } + T.eq(api.battle:snapshot().moves[1].disabled, nil, label .. "BW eligibility remains unknown") + b.player.disabledSlot = 1 + T.eq(api.battle:snapshot().moves[1].disabled, true, label .. "BW known common restriction") + b.player.disabledSlot = nil + b.bw = nil + b.player.curMoves[1].ppUps = 99 + T.eq(api.battle:snapshot().moves[1].maxPp, 56, label .. "PP Ups clamped as native engine") + b.player.curMoves[1].ppUps = -1 + T.eq(api.battle:snapshot().moves[1].maxPp, 35, label .. "negative PP Ups clamped") + b.player.curMoves[1].ppUps = 1 + -- Transform changes the in-battle view while the stored party keeps its moves/stats. + game.data.moves.COPIED = { name = "Copied", pp = 10, power = 50, type = "WATER" } + b.player.curMoves = { { id = "COPIED", pp = 5 } } + b.player.curStats = { hp = 23, attack = 90, special = 80 } + b.player.species = "COPIED_SPECIES" + b.player.curTypes = { "WATER" } + local transformed = api.battle:snapshot() + T.eq(transformed.moves[1].id, "COPIED", label .. "current transformed move") + T.eq(transformed.party[1].moves[1].id, "TACKLE", label .. "stored party move") + T.eq(transformed.player.battleStats.attack, 90, label .. "current transformed stats") + T.eq(transformed.player.stats.attack, 10, label .. "base stats preserved") + T.eq(transformed.player.displaySpecies, "COPIED_SPECIES", label .. "display species separate") + T.eq(transformed.player.species, "PIKACHU", label .. "original species preserved") + -- All four native positional slots, including a missing left flank. + b.double = true + b:placeBattler(2, battler(game.save.party[2])) + b:placeBattler(3, battler(b.enemyParty[2])) + b.choosingSlot = 2 + b.phase = "menu" + local double = api.battle:snapshot() + T.eq(double.double, true, label .. "doubles flag") + T.eq(#double.battlers, 4, label .. "four occupied battlers") + T.eq(double.active, 2, label .. "second chooser") + T.eq(double.player.name, "Right", label .. "player follows chooser") + T.eq(double.player.partyIndex, 2, label .. "second chooser party index") + T.eq(double.catchable, false, label .. "cannot aim at two wild foes") + T.eq(double.party[2].active, true, label .. "both player slots active") + b.phase = "targetSelect" + b.targetChoices = { b.enemy, b.sides[2].battlers[2], b.player } + b.targetIndex = 2 + T.same(api.battle:snapshot().targets, { 1, 3, 0 }, label .. "target IDs, not array positions") + T.eq(api.battle:snapshot().targetIndex, 2, label .. "target cursor") + b:placeBattler(0, nil) + b:placeBattler(1, nil) + b.choosingSlot = nil + b.phase = "menu" + local missing = api.battle:snapshot() + T.eq(missing.active, 2, label .. "right flank after left faint") + T.eq(missing.enemy, nil, label .. "missing enemy left not silently aliased") + T.eq(missing.catchable, true, label .. "one wild foe remaining") + T.eq(#missing.battlers, 2, label .. "missing slots omitted") + T.eq(missing.battlers[1].id, 2, label .. "battler identity survives sparse slots") + b.phase = "mimicSelect" + b.mimicMoves = { { id = ids[2], slot = 3 } } + b.mimicIndex = 1 + local mimic = api.battle:snapshot() + T.eq(mimic.mimicMoves[1].id, "TACKLE", label .. "portable mimic ID") + T.eq(mimic.mimicMoves[1].slot, 3, label .. "original mimic move slot") + b.phase = "menu" + local ally = b.sides[1].battlers[2] + ally.ally = true + b.ally = { party = { ally.mon } } + local withAlly = api.battle:snapshot() + T.eq(withAlly.party[2].active, false, label .. "NPC ally not player active party") + T.eq(withAlly.battlers[1].ally, true, label .. "NPC ally marked") + T.eq(withAlly.prompt, "locked", label .. "NPC controlled actor menu locked") + ally.ally = nil + b.ally = nil + b.kind = "link" + b.playerParty = { game.save.party[2] } + local link = api.battle:snapshot() + T.eq(#link.party, 1, label .. "link uses clamped battle party") + T.eq(link.party[1].nickname, "Right", label .. "link party is actual participant") + T.eq(link.prompt, "locked", label .. "link controller owns input") + b.kind = "wild" + b.playerParty = nil + -- Snapshot diagnostics discard executable values and remain cycle-safe. + local raw = game.save.party[2] + raw.extension.self = raw.extension + raw.extension.fn = forbidden + raw.extension[{}] = "hidden" + setmetatable(raw.extension, { __index = { secret = true } }) + local cyc = api.battle:snapshot() + T.eq(cyc.party[2].native.extension.self, cyc.party[2].native.extension, label .. "cycles copied") + T.eq(cyc.party[2].native.extension.fn, nil, label .. "function dropped") + T.eq(getmetatable(cyc.party[2].native.extension), nil, label .. "metatable dropped") + T.eq(cyc.party[2].native.extension.secret, nil, label .. "no inherited value") + T.eq(api.battle:snapshot().revision, cyc.revision, label .. "cyclic record stable revision") + local last = cyc.revision + game.stack.states = {} + T.eq(api.battle:snapshot(), nil, label .. "battle exit") + game.stack.states = { b } + T.check(api.battle:snapshot().revision > last, label .. "re-entry invalidates revision") + last = api.battle:snapshot().revision + local replacement = setmetatable(Merge.deepCopy(b), BattleState) + game.stack.states = { replacement } + T.check(api.battle:snapshot().revision > last, label .. "identical new battle invalidates revision") + -- A previously captured facade follows the loader's current Game. + local other = { data = game.data, save = game.save, stack = game.stack } + loader._game = function() + return other + end + last = api.battle:snapshot().revision + loader._game = function() + return game + end + T.check(api.battle:snapshot().revision > last, label .. "Game replacement invalidates revision") +end +math.random = oldRandom + +-- Exercise constructors and a native damage action with the committed fixture +-- dataset, rather than asserting only hand-built record shapes. +love = require("tests.love_stub") +local Fixtures = require("tests.modkit.fixtures") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local function native(version, trainer) + local api, loader = boot(version) + love = require("tests.love_stub") + math.randomseed(12345) + local data = Fixtures.fresh() + Font.load(data) + local save = SaveData.newGame() + save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) } + local game = { + data = data, + save = save, + input = { + wasPressed = function() + return false + end, + }, + stack = { + states = {}, + push = function() end, + pop = function() end, + top = function(self) + return self.states[#self.states] + end, + }, + } + local b = trainer and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + or BattleState.newWild(game, "FIXMON_C", 10) + game.stack.states = { b } + loader._game = function() + return game + end + local calls = 0 + b.rng = function(_, high) + calls = calls + 1 + return high + end + return api, game, b, function() + return calls + end +end +for _, version in ipairs(GameVersion.ORDER) do + for _, trainer in ipairs({ false, true }) do + local api, game, b, calls = native(version, trainer) + local unchanged = freeze(game) + local rngBefore = calls() + local oldLoveRandom, oldLuaRandom = love.math.random, math.random + love.math.random, math.random = forbidden, forbidden + local Runtime = require("src.mods.Runtime") + local oldEmit, oldCall = Runtime.emit, Runtime.call + Runtime.emit, Runtime.call = forbidden, forbidden + local loaded = {} + for name in pairs(package.loaded) do + loaded[name] = true + end + local s = api.battle:snapshot() + for _ = 1, 10 do + T.eq(api.battle:snapshot().revision, s.revision, version .. ": repeated native snapshot stable") + end + local noNewModules = true + for name in pairs(package.loaded) do + if not loaded[name] then + noNewModules = false + end + end + T.check(noNewModules, version .. ": snapshot loads no modules") + Runtime.emit, Runtime.call = oldEmit, oldCall + T.check(unchanged(), version .. ": native constructor graph unchanged") + T.eq(calls(), rngBefore, version .. ": native RNG unchanged") + T.eq(s.kind, trainer and "trainer" or "wild", version .. ": native constructor kind") + T.eq(s.player.species, "FIXMON_A", version .. ": real Pokemon record") + T.eq(s.player.maxHp, b.player.mon.stats.hp, version .. ": constructor HP") + T.eq(s.moves[1].id, b.player.curMoves[1].id, version .. ": constructor moves") + T.eq(s.catchable, not trainer, version .. ": constructor catchability") + love.math.random, math.random = oldLoveRandom, oldLuaRandom + -- The same native action on an independently constructed control battle + -- must produce the same HP, PP, queue and RNG consumption. + local _, _, control, controlCalls = native(version, trainer) + b:performMove(b.player, b.enemy, b.player.curMoves[1]) + control:performMove(control.player, control.enemy, control.player.curMoves[1]) + T.eq(b.enemy.mon.hp, control.enemy.mon.hp, version .. ": action damage unaffected") + T.eq(b.player.curMoves[1].pp, control.player.curMoves[1].pp, version .. ": action PP unaffected") + T.eq(calls(), controlCalls(), version .. ": action RNG consumption unaffected") + T.eq(#b.queue, #control.queue, version .. ": action queue unaffected") + local later = api.battle:snapshot() + T.eq(later.enemy.hp, b.enemy.mon.hp, version .. ": snapshot follows native damage") + T.check(later.revision > s.revision, version .. ": native damage changes revision") + end +end +local api = boot("crystal", 1) +T.eq(type(api.battle.snapshot), "function", "API 1 mods also get additive reader") +GameVersion.set(oldVersion) +love = oldLove +T.finish("battle snapshots") From 904490919a64c3a4ba72914baecbb696c76cbd6f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:34:07 +0200 Subject: [PATCH 009/138] Add revision-bound public battle actions through native commands --- docs/battle-actions-api.md | 96 ++++ docs/battle-snapshot-api.md | 13 +- docs/modding.md | 4 +- src/battle/BattleCommands.lua | 355 ++++++++++++++ src/battle/BattleState.lua | 127 +---- src/mods/BattleIntents.lua | 178 +++++++ src/mods/BattleSnapshots.lua | 127 ++++- tests/engine/battle_actions.lua | 758 ++++++++++++++++++++++++++++++ tests/engine/battle_snapshots.lua | 4 +- 9 files changed, 1533 insertions(+), 129 deletions(-) create mode 100644 docs/battle-actions-api.md create mode 100644 src/battle/BattleCommands.lua create mode 100644 src/mods/BattleIntents.lua create mode 100644 tests/engine/battle_actions.lua diff --git a/docs/battle-actions-api.md b/docs/battle-actions-api.md new file mode 100644 index 000000000..f0e232773 --- /dev/null +++ b/docs/battle-actions-api.md @@ -0,0 +1,96 @@ +# Battle action API + +The action extension builds on `mod.battle:snapshot()` and is available to +API 1 and API 2 mods on the shared Gen 1–5 battle engine. No manifest opt-in +is required. If capability queries are installed, +`mod:supports("battle", "submit")` reports that this interface exists. It does +not promise the current battle accepts a particular command. + +```lua +local nextIntentId = 1 -- keep increasing for this captured facade +local snapshot = mod.battle:snapshot() +if snapshot and snapshot.prompt == "menu" then + local ok, reason = mod.battle:submit({ + id = nextIntentId, + revision = snapshot.revision, + kind = "menu", + choice = "fight", + }) + nextIntentId = nextIntentId + 1 + if not ok then mod.log:info("Battle command refused: %s", reason) end +end +``` + +Always fetch a fresh snapshot before the next command. Do not hold a revision +across a frame, other input, a native callback, another mod's command, a screen +change or a Game/battle replacement. The engine rechecks the live context; +returning to an apparently identical screen does not revive an old command. + +## Intent records + +Intents must be plain tables without a metatable. Every record contains +`id`, `revision`, `kind` and only the fields listed below. Integers must be +finite; `id` and `revision` are positive safe integers up to 2^53−1. Extra +fields, unknown kinds, strings in numeric fields and missing arguments are +rejected. The facade copies accepted input fields before calling native code. + +| `kind` | Extra field | Meaning | +| --- | --- | --- | +| `menu` | `choice`: fight / party / item / run | Select an action on the uncovered regular battle menu | +| `move` | `slot`: 1–4 | Select a current battle move; may enter the native target selector | +| `target` | `target`: 0–3 | Select a currently offered, occupied positional battler ID | +| `back` | none | Cancel move selection/reorder or targeting; in a double's second action menu undo the first choice when native B permits it | +| `safari` | `action`: ball / bait / rock / run | Select a native Safari command; `rock` is Platinum's mud button | +| `mimic` | `index`: 1–4 | Select a current Mimic **option index**, not the source move slot | + +Positions are 0 player-left, 1 opponent-left, 2 player-right, 3 opponent-right. +These are not indices into `snapshot.battlers` or `snapshot.targets`. Target +choices are checked again against the native selector and live field. A +pending move must still be the same current move and still pass move checks. + +`move` uses the actual chooser, including the right flank and Transform's +current move list. Native Disable, PP, Taunt, Torment and BW Choice/Imprison/ +Gravity/Heal Block restrictions are checked before accepting the request. +Mimic uses its queued user, including a right-flank user during turn resolution, +and preserves the native copy/restoration bookkeeping. + +`menu` opens the existing bag/party flow. It does not select a party member, +use an inventory item or throw a normal bag ball directly. Those screens keep +their own controller. Native forced FIGHT/Struggle, ghost refusals, trainer +RUN refusals and bug-contest item behavior are preserved. A valid menu choice +can therefore be accepted and produce a native refusal message. + +## Context, replay and errors + +`submit(intent)` returns `true` when admitted and dispatched; otherwise +`nil, reason`. Accepted means the native command path ran, not that a turn +succeeded, a move hit, a ball caught or a battle finished. + +An intent must refer to a snapshot actually returned by this facade and still +be current. Each facade owns its own monotonically increasing ID sequence; +IDs survive battle and Game changes. Once native dispatch starts, that ID is +consumed. Equal or lower IDs are refused as replayed. Skipping IDs is allowed. +A rejection before dispatch does not consume its ID; correct the problem and +read a new snapshot before retrying. + +All public facades on one Game share an in-progress guard and an invalidation +epoch outside gameplay/save state. Another mod or a callback cannot submit +recursively during validation or execution. Snapshots observed during execution +are locked and cannot authorize a command after it finishes. Accepted calls +invalidate other mods' old snapshots even if exposed data happens to stay the +same. These counters are local process bookkeeping, not link/save data. + +Native errors are caught, reported and clear the guard. **An entered native +call is not retried or rolled back.** Its ID stays consumed even if native +code throws after a partial effect or returns a refusal. Fetch the current +snapshot and use a new ID for any deliberate next action. Validation errors +also clear the guard. Errors do not automatically drive subsequent frames or +drain the turn queue. + +No battle, covered menus, mismatched phases, finished battles, fainted/NPC +choosers, automatic locks, stale targets, demos/tutorials, link/spectator, +Palace and Arena controllers are refused. This API does not advance text, +drive a covered party/bag/choice screen, perform a forced replacement, or +bypass a script/controller. Ordinary keyboard/touch input continues through +the same extracted native dispatch methods and retains its existing messages, +queueing, RNG, targeting and per-version behavior. diff --git a/docs/battle-snapshot-api.md b/docs/battle-snapshot-api.md index 2e9266704..e6e1cfa3c 100644 --- a/docs/battle-snapshot-api.md +++ b/docs/battle-snapshot-api.md @@ -18,7 +18,9 @@ bootstrapping Game. Outside battle it returns `nil, "no battle"`. A UI screen me If the capability-query extension is installed, `mod:supports("battle", "snapshot")` returns true without constructing Game. -`submit` and other battle capabilities return false with a reason. +When the [battle action extension](battle-actions-api.md) is installed, +`mod:supports("battle", "submit")` also returns true. Other battle capabilities +return false with a reason. ## Records @@ -103,9 +105,14 @@ pre-advance delay. Party, bag and choice screens above battle stay locked; this API does not inspect or drive those screens. Link controllers remain locked. Visible messages decode the native revealed glyph buffer through the imported charmap and already-loaded Unicode glyph map; this never initializes a font. A prompt is descriptive, -not an action-permission token. There is no battle input/submission API. +not an action-permission token. The [battle action extension](battle-actions-api.md) supplies separately validated +submission; the snapshot's descriptive prompt is not its permission check. -Identical successive snapshot records retain their revision. A change to +Identical successive snapshot records and action contexts retain their revision. +The action extension also tracks native controller/selector identity and private +control flags, so replacement objects can invalidate an otherwise identical +record. Accepted public actions invalidate all facades on that Game, including +snapshots observed during native callbacks. A change to any exposed data advances it once at the next read; animation clocks and interpolated HP alone do not. A different battle or Game also advances it, even with identical records. Observing `no battle` invalidates the previous diff --git a/docs/modding.md b/docs/modding.md index dbbfadc75..023f89f5d 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -158,7 +158,9 @@ frame"; the engine draws the vanilla world instead. including singles and doubles, or `nil, "no battle"`. It can be captured in an entry chunk before Game exists. See [Battle snapshot API](battle-snapshot-api.md) for records, revisions, prompt semantics and intentionally omitted previews. -It reads state only; there is no `submit` method. When capability queries are +The snapshot reads state only. The [Battle action API](battle-actions-api.md) +adds validated `mod.battle:submit(intent)` for the native menu, move, target, +back, Safari and Mimic paths. When capability queries are installed, `mod:supports("battle", "snapshot")` reports this interface. ## Battle sprite scaling diff --git a/src/battle/BattleCommands.lua b/src/battle/BattleCommands.lua new file mode 100644 index 000000000..9c0778a86 --- /dev/null +++ b/src/battle/BattleCommands.lua @@ -0,0 +1,355 @@ +-- Native command dispatch shared by BattleState input and mod.battle intents. +-- Keep gameplay mutations here, after the mod facade's admission checks. +local Strings = require("src.core.Strings") +local Targeting = require("src.battle.Targeting") +local Commands, Methods = {}, {} +Commands.methods = Methods + +function Methods.chooseMenu(self, choice) + -- BattleMenu_Pack `.contest` (core.asm:4990): `ld a, PARK_BALL / + -- ld [wCurItem], a / call DoItemEffect`. No pack, no submenu -- the + -- third slot IS the ball, so the item action becomes the throw. + if choice == "item" and self.bugContest then + self:bugContestBall() + return true + end + if choice == "fight" and self.ghost then + self:say(Strings("%s is too\nscared to move!", self.player.name)) + self.phase = "messages" + self.afterQueue = "menu" + self:act(function() + self:executeAction(self.enemy, self.player, self:enemyAction()) + end) + self:act(function() + self:endOfTurn() + end) + elseif choice == "fight" then + -- After the menu: own trapping/Bide or foe Wrap skips the move + -- list and forces the locked action (core.asm:320-329) + local fightLock = self:fightLockedAction(self.player) + if fightLock then + self:resolveTurn(fightLock) + return true + end + local who = self:menuBattler() + if not self:playerHasPP(who) then + -- _NoMovesLeftText, then Struggle engages + self:say(Strings("%s has no\nmoves left!", who.name)) + self:chooseAction({ id = "STRUGGLE", pp = 1, struggle = true }) + return true + end + self.phase = "moveSelect" + self.moveIndex = math.min(self.moveIndex, #who.curMoves) + self.moveSwapIndex = nil + elseif choice == "run" then + self:tryRun() + elseif choice == "item" then + self:openItems() + else + self:openParty() + end + return true +end + +function Methods.chooseMove(self, index) + local chooser = self:menuBattler() + local moves = chooser.curMoves + self.moveIndex = index + local mv = moves[self.moveIndex] + local mvDef = mv and self.data.moves[mv.id] + if chooser.disabledSlot == self.moveIndex then + self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) + self.phase = "messages" + self.afterQueue = "menu" + -- TAUNT and TORMENT refuse the move at SELECTION rather than after the + -- fact, the way DISABLE already does, so the player is never told to + -- pick again for something the game was never going to allow. + elseif self.bw and require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true) then + -- BLACK AND WHITE: the Choice lock, Imprison, Gravity, Heal Block + self:say(require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true)) + self.phase = "messages" + self.afterQueue = "menu" + elseif self:tauntBlocks(chooser, mvDef) then + self:say(Strings("%s can't use\n%s after the\ntaunt!", chooser.name, mvDef and mvDef.name or mv.id)) + self.phase = "messages" + self.afterQueue = "menu" + elseif self:tormentBlocks(chooser, mvDef) then + self:say(Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!", chooser.name)) + self.phase = "messages" + self.afterQueue = "menu" + elseif mv.pp <= 0 then + self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) + self.phase = "messages" + self.afterQueue = "menu" + elseif Targeting.needsChoice(self, self:choosingBattler(), mvDef) then + -- WHICH OF THE TWO, when the move takes one and there are two. + -- + -- Emerald's selector cycles over the legal slots and names the + -- candidate; nothing else in the menu changes. A single battle + -- never gets here, because needsChoice answers false whenever there + -- is only one thing to aim at -- and on the Game Boy cartridges the + -- moves carry no target byte at all, so it answers false for every + -- move in the game. + self.pendingMove = mv + self.targetChoices = Targeting.choices(self, self:menuBattler()) + self.targetIndex = 1 + self.phase = "targetSelect" + else + self:chooseAction(mv) + end + return true +end + +function Methods.chooseTarget(self, index) + local list = self.targetChoices or {} + self.targetIndex = index + local mv = self.pendingMove + local pick = list[self.targetIndex] + self.pendingMove, self.targetChoices = nil, nil + self.chosenTargets = self.chosenTargets or {} + local who = self:menuBattler() + if who and who.position then + self.chosenTargets[who.position] = pick + end + self:chooseAction(mv) + return true +end + +function Methods.chooseMimic(self, index) + local moves = self.mimicMoves + self.mimicIndex = index + local pick = moves[self.mimicIndex] + local ctx = self.mimicCtx + self.mimicMoves, self.mimicCtx = nil, nil + self.phase = "messages" + self.nextInsert = 0 -- the copy's anim + text go to the queue head + self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot) + return true +end + +local SAFARI_INDEX = { ball = 1, bait = 2, rock = 3, run = 4 } +function Methods.chooseSafari(self, action) + self.menuIndex = SAFARI_INDEX[action] + self:safariAction(action) + return true +end + +local function backToFirst(self) + if not self:isDouble() or self:choosingSlotNow() ~= 2 then + return nil + end + local left = self:battlerAt(0) + local pending = self.pendingActions and self.pendingActions[0] + if + left + and not left.ally + and (left.mon and left.mon.hp or 0) > 0 + and pending + and not self:menuLockedAction(left) + then + return left + end +end +function Methods.cancelMove(self) + if self.phase == "moveSelect" then + self.moveSwapIndex, self.phase = nil, "menu" + elseif self.phase == "targetSelect" then + self.pendingMove, self.targetChoices = nil, nil + self.phase = "moveSelect" + elseif self.phase == "menu" and backToFirst(self) then + self.pendingActions[0] = nil + if self.chosenTargets then + self.chosenTargets[0] = nil + end + self.choosingSlot, self.menuIndex, self.moveIndex = 0, 1, 1 + pcall(function() + require("src.core.Sound").play(self.game.data, "SEQ_SE_DP_DECIDE") + end) + else + return nil, "nothing to cancel" + end + return true +end + +local function moveReason(b, actor, slot, move) + if not move or not b.data.moves[move.id] then + return "invalid move slot" + end + if actor.disabledSlot == slot then + return "move is disabled" + end + if (move.pp or 0) <= 0 then + return "move has no PP" + end + local def = b.data.moves[move.id] + if b.bw and require("src.battle.Gen5MoveRules").moveBlocked(b, actor, def, true) then + return "move is blocked by native BW rules" + end + if b:tauntBlocks(actor, def) then + return "move is blocked by Taunt" + end + if b:tormentBlocks(actor, def) then + return "move is blocked by Torment" + end +end + +-- Admission checks are read-only. Native refusal messages still belong to +-- chooseMove when driven by ordinary input; invalid API requests do not act. +function Commands.reason(b, intent) + if b.kind == "link" or b.spectating or b.demo or b.dudeDemo or b.pokedudeDemo or b.palace or b.arena then + return "battle controller owns input" + end + if b.result or b.dead or b.waitingUI then + return "battle is not ready" + end + if intent.kind == "safari" then + if b.phase ~= "menu" or not b.safari then + return "safari menu is not active" + end + if (b.safari.balls or 0) <= 0 then + return "no safari balls remain" + end + return + end + if b.safari then + return "safari controller owns input" + end + local actor = intent.kind == "mimic" and b.mimicCtx and b.mimicCtx.user or b:menuBattler() + if not (actor and actor.mon) or actor.ally or (actor.mon.hp or 0) <= 0 then + return "player battler is not ready" + end + if + intent.kind ~= "mimic" + and ( + b:menuLockedAction(actor) + or actor.mustRecharge + or actor.charging + or actor.rageMove + or (actor.thrashTurns and actor.thrashTurns > 0) + ) + then + return "player battler is locked" + end + if intent.kind == "menu" then + if b.phase ~= "menu" then + return "battle menu is not active" + end + local acting = b.player or b:activeOn(1) + if not acting or (acting.mon.hp or 0) <= 0 or b:menuLockedAction(acting) then + return "battle menu is not ready" + end + elseif intent.kind == "move" then + if b.phase ~= "moveSelect" then + return "move menu is not active" + end + if b.moveSwapIndex then + return "move reorder is active" + end + return moveReason(b, actor, intent.slot, (actor.curMoves or {})[intent.slot]) + elseif intent.kind == "target" then + if b.phase ~= "targetSelect" then + return "target menu is not active" + end + local move, slot = b.pendingMove, nil + for index, mv in ipairs(actor.curMoves or {}) do + if mv == move then + slot = index + break + end + end + if not slot then + return "pending move is no longer current" + end + local reason = moveReason(b, actor, slot, move) + if reason then + return reason + end + if not Targeting.needsChoice(b, actor, b.data.moves[move.id]) then + return "target choice is no longer needed" + end + local pick = b:battlerAt(intent.target) + if not (pick and pick.mon and (pick.mon.hp or 0) > 0) then + return "target is not on the field" + end + local offered = false + for _, target in ipairs(b.targetChoices or {}) do + if target == pick then + offered = true + break + end + end + if not offered then + return "target is not in the current selector" + end + for _, target in ipairs(Targeting.choices(b, actor)) do + if target == pick then + return + end + end + return "target is no longer eligible" + elseif intent.kind == "back" then + if b.phase == "moveSelect" or b.phase == "targetSelect" or (b.phase == "menu" and backToFirst(b)) then + return + end + return "nothing to cancel" + elseif intent.kind == "mimic" then + if b.phase ~= "mimicSelect" then + return "mimic menu is not active" + end + local ctx, pick = b.mimicCtx, (b.mimicMoves or {})[intent.index] + if not ctx or not pick or ctx.user ~= actor or not ctx.target or not ctx.moveInst then + return "invalid mimic context" + end + local targetPresent = false + for pos = 0, 3 do + if b:battlerAt(pos) == ctx.target then + targetPresent = true + end + end + if not targetPresent or #(actor.curMoves or {}) == 0 then + return "invalid mimic context" + end + local onField = false + for _, pos in ipairs({ 0, 2 }) do + if b:battlerAt(pos) == actor then + onField = true + end + end + if not onField then + return "mimic user is no longer on the field" + end + local source = (ctx.target.curMoves or {})[pick.slot] + if not source or source.id ~= pick.id then + return "mimic option is no longer current" + end + else + return "unknown battle intent" + end +end + +local DISPATCH = { + menu = "chooseMenu", + move = "chooseMove", + target = "chooseTarget", + back = "cancelMove", + safari = "chooseSafari", + mimic = "chooseMimic", +} +function Commands.dispatch(b, intent) + -- Ordinary input already performs this at its menu boundary. + if b.phase == "menu" and not b.safari then + b:clearTurnFlinches() + end + if intent.kind == "target" then + local pick = b:battlerAt(intent.target) + for index, target in ipairs(b.targetChoices or {}) do + if target == pick then + return b:chooseTarget(index) + end + end + return nil, "target is not in the current selector" + end + local value = intent.choice or intent.slot or intent.action or intent.index + return b[DISPATCH[intent.kind]](b, value) +end +return Commands diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index c72bad08e..c2f51594a 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -38,6 +38,7 @@ local WideBattle = require("src.battle.WideBattle") local Gen3Battle = require("src.battle.Gen3Battle") local Gen4Battle = require("src.battle.Gen4Battle") local GameVersion = require("src.core.GameVersion") +local BattleCommands = require("src.battle.BattleCommands") -- FORWARD-DECLARED, because they are defined next to the other doubles -- helpers two thousand lines down but read from `stepHPDrain` up here. A @@ -55,6 +56,8 @@ BattleState.__index = BattleState BattleState.isOpaque = true -- Data readers can identify the shared Gen 1-5 battle without loading this module. BattleState.isBattleState = true +-- Input and public intents share the exact same native command dispatch. +for name, method in pairs(BattleCommands.methods) do BattleState[name] = method end -- pokered prints the battle lines itself (engine/battle/core.asm and the -- move-effect banks), and the importer extracts every one of them, so the @@ -4408,7 +4411,7 @@ function BattleState:update(dt) Gen4Battle.actionMove(self.menuIndex, dir, self.gen4MenuColumn) end if input:wasPressed("a") then - self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) + self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex]) end return end @@ -4434,7 +4437,7 @@ function BattleState:update(dt) end self.menuIndex = row * 2 + col + 1 if input:wasPressed("a") then - self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) + self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex]) end return end @@ -4514,60 +4517,10 @@ function BattleState:update(dt) -- asked (fainted, or locked into a move it cannot choose). if input:wasPressed("b") and self:isDouble() and self:choosingSlotNow() == BattleState.POS.PLAYER_RIGHT then - local left = self:battlerAt(BattleState.POS.PLAYER_LEFT) - local pend = self.pendingActions and self.pendingActions[BattleState.POS.PLAYER_LEFT] - if left and not left.ally and (left.mon and left.mon.hp or 0) > 0 and pend - and not self:menuLockedAction(left) then - self.pendingActions[BattleState.POS.PLAYER_LEFT] = nil - if self.chosenTargets then self.chosenTargets[BattleState.POS.PLAYER_LEFT] = nil end - self.choosingSlot = BattleState.POS.PLAYER_LEFT - self.menuIndex, self.moveIndex = 1, 1 - pcall(function() require("src.core.Sound").play(self.game.data, "SEQ_SE_DP_DECIDE") end) - return - end + if self:cancelMove() then return end end if input:wasPressed("a") then - local choice = self:menuActions()[self.menuIndex] - -- BattleMenu_Pack `.contest` (core.asm:4990): `ld a, PARK_BALL / - -- ld [wCurItem], a / call DoItemEffect`. No pack, no submenu -- the - -- third slot IS the ball, so the item action becomes the throw. - if choice == "item" and self.bugContest then - self:bugContestBall() - return - end - if choice == "fight" and self.ghost then - self:say(Strings("%s is too\nscared to move!", self.player.name)) - self.phase = "messages" - self.afterQueue = "menu" - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) - self:act(function() self:endOfTurn() end) - elseif choice == "fight" then - -- After the menu: own trapping/Bide or foe Wrap skips the move - -- list and forces the locked action (core.asm:320-329) - local fightLock = self:fightLockedAction(self.player) - if fightLock then - self:resolveTurn(fightLock) - return - end - local who = self:menuBattler() - if not self:playerHasPP(who) then - -- _NoMovesLeftText, then Struggle engages - self:say(Strings("%s has no\nmoves left!", who.name)) - self:chooseAction({ id = "STRUGGLE", pp = 1, struggle = true }) - return - end - self.phase = "moveSelect" - self.moveIndex = math.min(self.moveIndex, #who.curMoves) - self.moveSwapIndex = nil - elseif choice == "run" then - self:tryRun() - elseif choice == "item" then - self:openItems() - else - self:openParty() - end + self:chooseMenu(self:menuActions()[self.menuIndex]) end return end @@ -4593,58 +4546,14 @@ function BattleState:update(dt) self.moveSwapIndex = self.moveIndex end elseif input:wasPressed("b") then - self.moveSwapIndex = nil - self.phase = "menu" + self:cancelMove() elseif input:wasPressed("a") then if self.moveSwapIndex then self:swapMoves(self.moveSwapIndex, self.moveIndex) self.moveSwapIndex = nil return end - local mv = moves[self.moveIndex] - local mvDef = mv and self.data.moves[mv.id] - if chooser.disabledSlot == self.moveIndex then - self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) - self.phase = "messages" - self.afterQueue = "menu" - -- TAUNT and TORMENT refuse the move at SELECTION rather than after the - -- fact, the way DISABLE already does, so the player is never told to - -- pick again for something the game was never going to allow. - elseif self.bw and require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true) then - -- BLACK AND WHITE: the Choice lock, Imprison, Gravity, Heal Block - self:say(require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true)) - self.phase = "messages" - self.afterQueue = "menu" - elseif self:tauntBlocks(chooser, mvDef) then - self:say(Strings("%s can't use\n%s after the\ntaunt!", - chooser.name, mvDef and mvDef.name or mv.id)) - self.phase = "messages" - self.afterQueue = "menu" - elseif self:tormentBlocks(chooser, mvDef) then - self:say(Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!", - chooser.name)) - self.phase = "messages" - self.afterQueue = "menu" - elseif mv.pp <= 0 then - self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) - self.phase = "messages" - self.afterQueue = "menu" - elseif Targeting.needsChoice(self, self:choosingBattler(), mvDef) then - -- WHICH OF THE TWO, when the move takes one and there are two. - -- - -- Emerald's selector cycles over the legal slots and names the - -- candidate; nothing else in the menu changes. A single battle - -- never gets here, because needsChoice answers false whenever there - -- is only one thing to aim at -- and on the Game Boy cartridges the - -- moves carry no target byte at all, so it answers false for every - -- move in the game. - self.pendingMove = mv - self.targetChoices = Targeting.choices(self, self:menuBattler()) - self.targetIndex = 1 - self.phase = "targetSelect" - else - self:chooseAction(mv) - end + self:chooseMove(self.moveIndex) end return end @@ -4662,16 +4571,9 @@ function BattleState:update(dt) elseif input:wasPressed("right") or input:wasPressed("down") then self.targetIndex = self.targetIndex < #list and self.targetIndex + 1 or 1 elseif input:wasPressed("b") then - self.pendingMove, self.targetChoices = nil, nil - self.phase = "moveSelect" + self:cancelMove() elseif input:wasPressed("a") then - local mv = self.pendingMove - local pick = list[self.targetIndex] - self.pendingMove, self.targetChoices = nil, nil - self.chosenTargets = self.chosenTargets or {} - local who = self:menuBattler() - if who and who.position then self.chosenTargets[who.position] = pick end - self:chooseAction(mv) + self:chooseTarget(self.targetIndex) end return end @@ -4692,12 +4594,7 @@ function BattleState:update(dt) elseif input:wasPressed("down") then self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1 elseif input:wasPressed("a") then - local pick = moves[self.mimicIndex] - local ctx = self.mimicCtx - self.mimicMoves, self.mimicCtx = nil, nil - self.phase = "messages" - self.nextInsert = 0 -- the copy's anim + text go to the queue head - self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot) + self:chooseMimic(self.mimicIndex) end return end diff --git a/src/mods/BattleIntents.lua b/src/mods/BattleIntents.lua new file mode 100644 index 000000000..5daa69311 --- /dev/null +++ b/src/mods/BattleIntents.lua @@ -0,0 +1,178 @@ +-- Data-only intent validation and private cross-facade submission state. +local Intents = {} +local states = setmetatable({}, { __mode = "k" }) +local FIELDS = { + menu = { choice = true }, + move = { slot = true }, + target = { target = true }, + back = {}, + safari = { action = true }, + mimic = { index = true }, +} +local MENU = { fight = true, party = true, item = true, run = true } +local SAFARI = { ball = true, bait = true, rock = true, run = true } +local function integer(v, lo, hi) + return type(v) == "number" and v == v and v % 1 == 0 and v >= lo and v <= hi +end +function Intents.normalize(raw) + if type(raw) ~= "table" or getmetatable(raw) ~= nil then + return nil, "intent must be a plain table" + end + if not integer(raw.id, 1, 9007199254740991) then + return nil, "intent id must be a positive safe integer" + end + if not integer(raw.revision, 1, 9007199254740991) then + return nil, "invalid battle revision" + end + local fields = type(raw.kind) == "string" and FIELDS[raw.kind] + if not fields then + return nil, "unknown battle intent" + end + for key in pairs(raw) do + if key ~= "id" and key ~= "revision" and key ~= "kind" and not fields[key] then + return nil, "unknown battle intent field" + end + end + local out = { id = raw.id, revision = raw.revision, kind = raw.kind } + if raw.kind == "menu" then + if type(raw.choice) ~= "string" or not MENU[raw.choice] then + return nil, "unknown battle menu choice" + end + out.choice = raw.choice + elseif raw.kind == "move" then + if not integer(raw.slot, 1, 4) then + return nil, "invalid move slot" + end + out.slot = raw.slot + elseif raw.kind == "target" then + if not integer(raw.target, 0, 3) then + return nil, "target must be a battler id 0-3" + end + out.target = raw.target + elseif raw.kind == "safari" then + if type(raw.action) ~= "string" or not SAFARI[raw.action] then + return nil, "invalid safari action" + end + out.action = raw.action + elseif raw.kind == "mimic" then + if not integer(raw.index, 1, 4) then + return nil, "invalid mimic index" + end + out.index = raw.index + end + return out +end +function Intents.state(game) + local state = states[game] + if not state then + state = { epoch = 0, busy = false } + states[game] = state + end + return state +end + +local BATTLE_FLAGS = { + "waitingUI", + "dead", + "demo", + "dudeDemo", + "pokedudeDemo", + "palace", + "arena", + "spectating", + "bugContest", + "noItems", + "gen4Safari", +} +local BATTLER_FLAGS = { + "disabledSlot", + "tauntTurns", + "tormented", + "lastMove", + "mustRecharge", + "charging", + "thrashTurns", + "thrashMove", + "rageMove", + "trappingTurns", + "bideTurns", + "encoreTurns", + "encoreMove", + "bwSkyDropped", + "bwChoiceLock", + "bwImprison", + "bwHealBlock", + "bwEmbargo", +} +-- Reference identity matters as well as record values: an identical replacement +-- Pokemon, move slot, selector or covering screen must invalidate old commands. +-- refs are compared shallowly; values are copied by the snapshot owner. +function Intents.context(game, b, top, at) + local refs = { + game = game, + battle = b, + top = top, + stack = game.stack, + queue = b.queue, + current = b.current, + targets = b.targetChoices, + mimic = b.mimicCtx, + mimicMoves = b.mimicMoves, + pendingMove = b.pendingMove, + pending = b.pendingActions, + chosen = b.chosenTargets, + safari = b.safari, + } + local values = { queueLength = #(b.queue or {}), flags = {}, battlers = {} } + for _, key in ipairs(BATTLE_FLAGS) do + values.flags[key] = type(b[key]) == "table" and true or b[key] + end + values.bw = type(b.bw) == "table" + and { gravity = b.bw.field and b.bw.field.gravity, magicRoom = b.bw.field and b.bw.field.magicRoom } + or b.bw + if b.mimicCtx then + refs.mimicUser, refs.mimicTarget, refs.mimicMove = b.mimicCtx.user, b.mimicCtx.target, b.mimicCtx.moveInst + end + for i, move in ipairs(b.mimicMoves or {}) do + refs["mimicOption" .. i] = move + end + for pos = 0, 3 do + local actor = at(b, pos) + refs["b" .. pos] = actor + if actor then + refs["mon" .. pos] = actor.mon + refs["moves" .. pos] = actor.curMoves + local flags = {} + for _, key in ipairs(BATTLER_FLAGS) do + flags[key] = type(actor[key]) == "table" and true or actor[key] + end + values.battlers[pos] = flags + for slot, mv in ipairs(actor.curMoves or {}) do + refs["move" .. pos .. ":" .. slot] = mv + end + end + refs["pending" .. pos] = b.pendingActions and b.pendingActions[pos] + refs["chosen" .. pos] = b.chosenTargets and b.chosenTargets[pos] + end + for i, target in ipairs(b.targetChoices or {}) do + refs["target" .. i] = target + end + return refs, values +end +function Intents.sameRefs(a, b) + if not a or not b then + return a == b + end + for key, value in pairs(a) do + if b[key] ~= value then + return false + end + end + for key, value in pairs(b) do + if a[key] ~= value then + return false + end + end + return true +end +return Intents diff --git a/src/mods/BattleSnapshots.lua b/src/mods/BattleSnapshots.lua index 79a0aa417..1fce0a816 100644 --- a/src/mods/BattleSnapshots.lua +++ b/src/mods/BattleSnapshots.lua @@ -1,6 +1,19 @@ --- A data-only view of the shared Gen 1-5 battle. Never call battle/menu, --- item-effect or preview helpers here: some of those consume RNG or write state. +-- Detached Gen 1-5 snapshots and revision-bound native command admission. +-- Snapshot building stays data-only; submit validates before native dispatch. local Ids = require("src.mods.UnifiedIds") +local Intents = require("src.mods.BattleIntents") +local Commands = require("src.battle.BattleCommands") +local Runtime = require("src.mods.Runtime") +-- Native work requested through a facade remains engine work. The buses keep +-- their existing callback/error attribution; lazy requires must not be blamed +-- on the requesting mod by the dev-mode permission shim. +local function enginePcall(fn, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local ok, a, b = pcall(fn, ...) + Runtime.currentMod = owner + return ok, a, b +end local Snapshots = {} local STATUS = @@ -86,6 +99,13 @@ local function at(battle, pos) end local function activeSlot(battle) + if battle.phase == "mimicSelect" and battle.mimicCtx then + for _, pos in ipairs({ 0, 2 }) do + if at(battle, pos) == battle.mimicCtx.user then + return pos + end + end + end if not battle.double then return 0 end @@ -427,7 +447,8 @@ function Snapshots.new(gameProvider, loader) end local facade = {} local lastGame, lastBattle, previous, revision = nil, nil, nil, 0 - function facade:snapshot() + local lastRefs, lastControl, lastEpoch, offeredRevision, lastIntentId = nil, nil, nil, nil, 0 + local function refresh() local game = gameProvider() local states = game and game.stack and game.stack.states or {} local battle, index @@ -438,18 +459,108 @@ function Snapshots.new(gameProvider, loader) end end if not battle then - lastGame, lastBattle, previous = nil, nil, nil - return nil, "no battle" + lastGame, lastBattle, previous, lastRefs, lastControl, offeredRevision = nil, nil, nil, nil, nil, nil + return nil, nil, nil, "no battle" end + local state = Intents.state(game) local out = copy(build(game, battle, index ~= #states, Ids.new(loader))) - if game ~= lastGame or battle ~= lastBattle or not equal(out, previous) then + local refs, control = Intents.context(game, battle, states[#states], at) + control = copy(control) + if + game ~= lastGame + or battle ~= lastBattle + or not equal(out, previous) + or not Intents.sameRefs(refs, lastRefs) + or not equal(control, lastControl) + or state.epoch ~= lastEpoch + then revision = revision + 1 end lastGame, lastBattle, previous = game, battle, out - local result = copy(out) + lastRefs, lastControl, lastEpoch = refs, control, state.epoch + return game, battle, index, state + end + function facade:snapshot() + local game, battle, _, state = refresh() + if not battle then + return nil, "no battle" + end + local result = copy(previous) result.revision = revision + if state.busy then + result.prompt = "locked" + end + offeredRevision = revision return result end + function facade:submit(raw) + local intent, reason = Intents.normalize(raw) + if not intent then + return nil, reason + end + if intent.id <= lastIntentId then + return nil, "replayed intent" + end + local game, battle, index, state = refresh() + if not battle then + return nil, "no battle" + end + if state.busy then + return nil, "battle action is already in progress" + end + if intent.revision ~= revision then + return nil, "stale battle context" + end + if offeredRevision ~= revision then + return nil, "fresh battle snapshot required" + end + if battle.game ~= game then + return nil, "battle does not belong to current Game" + end + if index ~= #game.stack.states then + return nil, "battle menu is covered" + end + -- The native admission helpers are read-only. Lock during validation too, + -- so a mod callback/overridden helper cannot recursively enter submission. + local refs, control = lastRefs, lastControl + state.busy = true + local checked, refusal = enginePcall(Commands.reason, battle, intent) + if not checked or refusal then + state.busy = false + return nil, checked and refusal or "battle validation failed" + end + local fresh, currentGame, currentBattle = pcall(refresh) + if not fresh then + state.busy = false + return nil, "battle validation failed" + end + if + game ~= currentGame + or battle ~= currentBattle + or revision ~= intent.revision + or not Intents.sameRefs(refs, lastRefs) + or not equal(control, lastControl) + then + state.busy = false + return nil, "stale battle context" + end + -- Consume BEFORE native code: a callback or partial native failure cannot + -- replay this ID. Both boundaries invalidate snapshots held by other mods, + -- including snapshots observed halfway through an accepted action. + lastIntentId, offeredRevision = intent.id, nil + state.epoch = state.epoch + 1 + local ok, accepted, err = enginePcall(Commands.dispatch, battle, intent) + state.epoch = state.epoch + 1 + state.busy = false + if not ok then + local printable, message = pcall(tostring, accepted) + return nil, "battle action failed; intent consumed" .. (printable and ": " .. message or "") + end + if not accepted then + return nil, err or "battle action did not complete; intent consumed" + end + return true + end return facade end -- Run after construction so an optional capability provider and its other @@ -459,7 +570,7 @@ function Snapshots.withCapabilities(api) local supports = api.supports function api:supports(surface, name) if surface == "battle" then - if name == "snapshot" then + if name == "snapshot" or name == "submit" then return true end return false, "unknown battle capability: " .. tostring(name) diff --git a/tests/engine/battle_actions.lua b/tests/engine/battle_actions.lua new file mode 100644 index 000000000..2c6799081 --- /dev/null +++ b/tests/engine/battle_actions.lua @@ -0,0 +1,758 @@ +-- Public intents exercised through real Loader, BattleState and native input. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Loader = require("src.mods.Loader") +local BattleState = require("src.battle.BattleState") +local GameVersion = require("src.core.GameVersion") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") +local Json = require("src.link.Json") +local Merge = require("src.mods.Merge") +local Commands = require("src.battle.BattleCommands") +local Intents = require("src.mods.BattleIntents") +local oldVersion = GameVersion.get() + +local function boot(version, trainer, level) + GameVersion.set(version) + math.randomseed(12345) + local data = T.fixtures.fresh() + local files = {} + for _, id in ipairs({ "probe", "other" }) do + files["mods/" .. id .. "/manifest.json"] = + Json.encode({ id = id, name = id, version = "1.0.0", api = level or 2, entry = "main.lua" }) + files["mods/" .. id .. "/main.lua"] = [[return function(mod) + assert(type(mod.battle.submit)=="function") + assert(mod.battle:snapshot()==nil) + end]] + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe", "other" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" or p == "mods/other" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local game = { data = data, stack = { states = {} }, save = SaveData.newGame() } + loader.game = game + assert(loader:load(data), table.concat(loader.errors, "; ")) + Font.load(data) + game.save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) } + game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + game.stack.top = function(stack) + return stack.states[#stack.states] + end + game.stack.push = function(stack, state) + stack.states[#stack.states + 1] = state + end + game.stack.pop = function(stack) + return table.remove(stack.states) + end + local b = trainer and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + or BattleState.newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase = "menu" + b.gen4CursorActive = true + b.frame = 0 + b.queue = {} + b.menuIndex = 1 + b.moveIndex = 1 + local rolls = 0 + b.rng = function(lo, hi) + rolls = rolls + 1 + return lo + end + game.stack.states = { b } + return loader.apis.probe.battle, + game, + b, + loader.apis.other.battle, + loader, + function() + return rolls + end +end +local function intent(api, id, kind, fields) + local s = assert(api:snapshot()) + local out = { id = id, revision = s.revision, kind = kind } + for k, v in pairs(fields or {}) do + out[k] = v + end + return out +end +local function send(api, id, kind, fields) + return api:submit(intent(api, id, kind, fields)) +end +local function freeze(root) + local graph = {} + local function visit(value) + if type(value) ~= "table" or graph[value] then + return + end + local row = { values = {}, mt = getmetatable(value) } + graph[value] = row + for key, item in pairs(value) do + row.values[key] = item + visit(item) + end + end + visit(root) + return function() + for tbl, row in pairs(graph) do + if getmetatable(tbl) ~= row.mt then + return false + end + for key, value in pairs(row.values) do + if tbl[key] ~= value then + return false + end + end + for key in pairs(tbl) do + if row.values[key] == nil then + return false + end + end + end + return true + end +end +local function forbidden() + error("unexpected native side effect") +end +local function reject(api, game, raw, fragment, label) + local battler = game.stack.states[1] + local oldRng = battler and battler.rng + if battler and battler.isBattleState then + battler.rng = forbidden + end + local unchanged = freeze(game) + local oldLua, oldLove, oldEmit, oldCall = math.random, love.math.random, Runtime.emit, Runtime.call + math.random, love.math.random, Runtime.emit, Runtime.call = forbidden, forbidden, forbidden, forbidden + local ok, reason = api:submit(raw) + math.random, love.math.random, Runtime.emit, Runtime.call = oldLua, oldLove, oldEmit, oldCall + T.eq(ok, nil, label .. ": rejected") + T.check( + type(reason) == "string" and reason:find(fragment, 1, true) ~= nil, + label .. ": reason " .. tostring(reason) + ) + T.check(unchanged(), label .. ": whole game graph unchanged") + if battler and battler.isBattleState then + battler.rng = oldRng + end +end +local function view(api) + local s = assert(api:snapshot()) + s.revision = nil + return s +end +local function press(game, b, key) + game.input.wasPressed = function(_, name) + return name == key + end + b:update(1 / 60) + game.input.wasPressed = function() + return false + end +end + +for _, version in ipairs(GameVersion.ORDER) do + local label = version .. ": " + -- Identical native input and API commands, including actual turn planning. + local api, game, b, other, loader, rolls = boot(version) + local control, cgame, cb, _, _, crolls = boot(version) + T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "FIGHT accepted") + press(cgame, cb, "a") + T.same(view(api), view(control), label .. "FIGHT matches native A dispatch") + T.eq(send(api, 2, "move", { slot = 1 }), true, label .. "move accepted") + press(cgame, cb, "a") + T.same(view(api), view(control), label .. "move matches native A dispatch") + T.eq(rolls(), crolls(), label .. "native RNG consumption matches") + T.eq(#b.queue, #cb.queue, label .. "native action queue matches") + T.eq(b.turnCount, 1, label .. "exactly one native turn planned") + local function drain(g, bb) + g.input.wasPressed = function() + return true + end + g.input.isDown = function() + return true + end + for frame = 1, 5000 do + if not bb:updateQueue() then + return true + end + end + return false + end + T.check(drain(game, b), label .. "API native turn queue completes") + T.check(drain(cgame, cb), label .. "input native turn queue completes") + T.eq(b.enemy.mon.hp, cb.enemy.mon.hp, label .. "native damage identical after queue") + T.eq(b.player.mon.hp, cb.player.mon.hp, label .. "enemy retaliation identical after queue") + T.eq(b.player.curMoves[1].pp, cb.player.curMoves[1].pp, label .. "PP consumption identical after queue") + T.eq(rolls(), crolls(), label .. "full native turn RNG identical") + if loader.apis.probe.supports then + T.eq(loader.apis.probe:supports("battle", "submit"), true, label .. "submit capability") + end + -- Menu opening does not bypass the party/bag controller or consume items. + for _, choice in ipairs({ "party", "item", "run" }) do + local a, g, bb = boot(version) + local c, cg, cc = boot(version) + T.eq(send(a, 1, "menu", { choice = choice }), true, label .. choice .. " accepted") + local indices = cc:menuActions() + for i, value in ipairs(indices) do + if value == choice or (choice == "party" and value == "pkmn") then + cc.menuIndex = i + end + end + press(cg, cc, "a") + -- Native cursor is selected by the player; the public API addresses the + -- action by name, so it need not move that display cursor. + local sv, cv = view(a), view(c) + sv.menuIndex = nil + cv.menuIndex = nil + T.same(sv, cv, label .. choice .. " matches native input") + T.eq(#bb.queue, #cc.queue, label .. choice .. " native queue") + end + api, game, b = boot(version) + local first = assert(api:snapshot()) + local bads = { false, {}, setmetatable({ id = 1, revision = first.revision, kind = "back" }, {}) } + for _, raw in ipairs(bads) do + reject(api, game, raw, "", label .. "malformed intent") + end + for _, value in ipairs({ 0, -1, 1.5, math.huge, -math.huge, 0 / 0, "1", true, 9007199254740992 }) do + reject( + api, + game, + { id = value, revision = first.revision, kind = "menu", choice = "fight" }, + "integer", + label .. "unsafe ID" + ) + reject( + api, + game, + { id = 1, revision = value, kind = "menu", choice = "fight" }, + "revision", + label .. "unsafe revision" + ) + end + local malformed = { + { kind = "mystery" }, + { kind = "menu", choice = "catch" }, + { kind = "menu", choice = {} }, + { kind = "move", slot = "1" }, + { kind = "move", slot = 0 }, + { kind = "move", slot = 5 }, + { kind = "target", target = -1 }, + { kind = "target", target = 4 }, + { kind = "mimic", index = 0 }, + { kind = "mimic", index = 5 }, + { kind = "safari", action = "mud" }, + { kind = "safari", action = false }, + { kind = "back", extra = true }, + { kind = "move", slot = 1, idOverride = 3 }, + } + for _, fields in ipairs(malformed) do + local raw = { id = 1, revision = first.revision } + for k, v in pairs(fields) do + raw[k] = v + end + reject(api, game, raw, "", label .. "strict intent fields") + end + local current = intent(api, 1, "menu", { choice = "fight" }) + T.eq(api:submit(current), true, label .. "rejected IDs were not consumed") + reject(api, game, current, "replayed", label .. "accepted ID cannot replay") + current.id = 0 + reject(api, game, current, "integer", label .. "ID zero is invalid") + current = intent(api, 3, "move", { slot = 1 }) + b.player.mon.hp = b.player.mon.hp - 1 + reject(api, game, current, "stale", label .. "changed HP invalidates revision") + current = intent(api, 3, "move", { slot = 1 }) + b.player.curMoves[1] = Merge.deepCopy(b.player.curMoves[1]) + reject(api, game, current, "stale", label .. "identical replacement move invalidates revision") + current = intent(api, 3, "move", { slot = 1 }) + b.player.mon = Merge.deepCopy(b.player.mon) + reject(api, game, current, "stale", label .. "identical replacement Pokemon invalidates revision") + current = intent(api, 3, "move", { slot = 1 }) + game.stack.states[2] = { battle = b } + reject(api, game, current, "stale", label .. "new covering screen invalidates revision") + reject( + api, + game, + intent(api, 3, "move", { slot = 1 }), + "covered", + label .. "fresh covered snapshot still cannot act" + ) + game.stack.states[2] = nil + for _, phase in ipairs({ "intro", "messages", "waitRemote", "finished" }) do + b.phase = phase + reject( + api, + game, + intent(api, 3, "menu", { choice = "fight" }), + "active", + label .. "wrong phase " .. phase + ) + end + b.phase = "menu" + for _, flag in ipairs({ "demo", "dudeDemo", "pokedudeDemo", "spectating", "palace", "arena" }) do + b[flag] = true + reject( + api, + game, + intent(api, 3, "menu", { choice = "fight" }), + "controller", + label .. "automatic controller " .. flag + ) + b[flag] = nil + end + for _, flag in ipairs({ "result", "dead", "waitingUI" }) do + b[flag] = true + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "not ready " .. flag) + b[flag] = nil + end + b.kind = "link" + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "controller", label .. "link refused") + b.kind = "wild" + for _, flag in ipairs({ "mustRecharge", "charging", "rageMove", "thrashTurns" }) do + b.player[flag] = 1 + reject( + api, + game, + intent(api, 3, "menu", { choice = "fight" }), + "locked", + label .. flag .. " automatic lock" + ) + b.player[flag] = nil + end + b.player.ally = true + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "NPC ally refused") + b.player.ally = nil + b.player.mon.hp = 0 + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "fainted chooser refused") + b.player.mon.hp = 30 + b.phase = "moveSelect" + for _, guard in ipairs({ "pp", "disable", "taunt", "torment", "swap" }) do + local mv = b.player.curMoves[1] + local def = game.data.moves[mv.id] + local power = def.power + if guard == "pp" then + mv.pp = 0 + end + if guard == "disable" then + b.player.disabledSlot = 1 + end + if guard == "taunt" then + b.player.tauntTurns = 2 + def.power = 0 + end + if guard == "torment" then + b.player.tormented = true + b.player.lastMove = mv.id + end + if guard == "swap" then + b.moveSwapIndex = 1 + end + reject(api, game, intent(api, 3, "move", { slot = 1 }), "", label .. guard .. " rejected before action") + mv.pp = 20 + b.player.disabledSlot = nil + b.player.tauntTurns = nil + b.player.tormented = nil + b.player.lastMove = nil + b.moveSwapIndex = nil + def.power = power + end + reject(api, game, intent(api, 3, "move", { slot = 4 }), "slot", label .. "missing slot refused") + b.moveSwapIndex = 1 + T.eq(send(api, 3, "back"), true, label .. "back cancels reorder") + T.eq(b.moveSwapIndex, nil, label .. "reorder marker cleared") + T.eq(b.phase, "menu", label .. "back to menu") + reject(api, game, intent(api, 4, "back"), "nothing", label .. "single menu has no back action") + game.stack.states = {} + reject(api, game, { id = 4, revision = 1, kind = "back" }, "no battle", label .. "outside battle refused") +end +local function doubles(api, game, b) + math.randomseed(54321) + b.double = true + b:placeBattler(2, BattleState.makeBattler(game.data, game.save.party[2], true, game.save)) + local foe = Pokemon.new(game.data, "FIXMON_B", 10) + b.enemyParty = b.enemyParty or { b.enemy.mon } + b.enemyParty[2] = foe + b:placeBattler(3, BattleState.makeBattler(game.data, foe, false)) + b:syncSides() +end +for _, version in ipairs(GameVersion.ORDER) do + local label = version .. " doubles: " + local api, game, b = boot(version) + local control, cgame, cb = boot(version) + doubles(api, game, b) + doubles(control, cgame, cb) + T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "first FIGHT") + press(cgame, cb, "a") + T.eq(send(api, 2, "move", { slot = 1 }), true, label .. "first move enters targets") + press(cgame, cb, "a") + T.eq(b.phase, "targetSelect", label .. "native targeting phase") + reject(api, game, intent(api, 3, "target", { target = 0 }), "selector", label .. "cannot target own slot") + local offered = intent(api, 3, "target", { target = 3 }) + b.targetChoices = { unpack(b.targetChoices) } + reject(api, game, offered, "stale", label .. "selector identity changed") + T.eq(send(api, 3, "target", { target = 3 }), true, label .. "position 3 selected") + cb.targetIndex = 2 + press(cgame, cb, "a") + T.same(view(api), view(control), label .. "position maps to correct native cursor") + T.eq(b.chosenTargets[0], b:battlerAt(3), label .. "chosen actual right foe") + T.eq(b.choosingSlot, 2, label .. "second chooser") + T.eq(send(api, 4, "back"), true, label .. "undo first selection from second menu") + press(cgame, cb, "b") + T.same(view(api), view(control), label .. "back matches native B") + T.eq(b.pendingActions[0], nil, label .. "pending first action cleared") + T.eq(b.chosenTargets[0], nil, label .. "pending target cleared") + -- Native B from target returns to moves, then from moves to action menu. + T.eq(send(api, 5, "menu", { choice = "fight" }), true, label .. "fight again") + T.eq(send(api, 6, "move", { slot = 1 }), true, label .. "target again") + T.eq(send(api, 7, "back"), true, label .. "cancel target") + T.eq(b.pendingMove, nil, label .. "pending move cleared") + T.eq(b.targetChoices, nil, label .. "target selector cleared") + T.eq(b.phase, "moveSelect", label .. "back to moves") + T.eq(send(api, 8, "back"), true, label .. "back to menu") + T.eq(send(api, 9, "menu", { choice = "fight" }), true, label .. "choose first action") + T.eq(send(api, 10, "move", { slot = 1 }), true, label .. "select first move") + local stale = intent(api, 11, "target", { target = 1 }) + b.pendingMove = Merge.deepCopy(b.pendingMove) + reject(api, game, stale, "stale", label .. "pending move identity changed") + reject(api, game, intent(api, 11, "target", { target = 1 }), "pending", label .. "orphaned pending move") + b.pendingMove = b.player.curMoves[1] + b.pendingMove.pp = 0 + reject(api, game, intent(api, 11, "target", { target = 1 }), "PP", label .. "pending move rechecks PP") + b.pendingMove.pp = 20 + b:battlerAt(1).mon.hp = 0 + reject(api, game, intent(api, 11, "target", { target = 1 }), "field", label .. "fainted target refused") + b:battlerAt(1).mon.hp = 30 + T.eq(send(api, 11, "target", { target = 1 }), true, label .. "first target") + T.eq(send(api, 12, "menu", { choice = "fight" }), true, label .. "second FIGHT") + T.eq(send(api, 13, "move", { slot = 1 }), true, label .. "second move") + T.eq(send(api, 14, "target", { target = 0 }), true, label .. "target position zero supported") + T.eq(b.chosenTargets[2], b.player, label .. "second actor aimed at left partner") + T.eq(b.turnCount, 1, label .. "second chooser resolves one whole turn") + -- A surviving right flank still owns a usable menu. + api, game, b = boot(version) + doubles(api, game, b) + b:placeBattler(0, nil) + T.eq(api:snapshot().active, 2, label .. "missing-left chooser") + T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "right-only FIGHT") + T.eq(b.phase, "moveSelect", label .. "right-only moves") + -- Mimic belongs to its queued user, even when that is the right flank. + b.phase = "mimicSelect" + local user = b:battlerAt(2) + local target = b.enemy + b.mimicCtx = { user = user, target = target, moveInst = user.curMoves[1] } + b.mimicMoves = { { slot = 1, id = target.curMoves[1].id } } + b.mimicIndex = 1 + T.eq(api:snapshot().active, 2, label .. "Mimic uses queued right-flank user") + local picked = intent(api, 2, "mimic", { index = 1 }) + b.mimicCtx.target = b:battlerAt(3) + reject(api, game, picked, "stale", label .. "Mimic source identity changed") + b.mimicCtx.target = target + T.eq(send(api, 2, "mimic", { index = 1 }), true, label .. "native Mimic copy") + T.eq(user.curMoves[1].id, target.curMoves[1].id, label .. "Mimic installs selected source") + T.eq(b.phase, "messages", label .. "Mimic resumes queue") + T.eq(#b.mimicRestores, 1, label .. "native restoration bookkeeping preserved") +end + +-- Safari uses its native model, including Platinum's own action implementation. +for _, version in ipairs({ "red", "firered", "platinum", "heartgold" }) do + for _, action in ipairs({ "ball", "bait", "rock", "run" }) do + local api, game, b = boot(version) + local control, cgame, cb = boot(version) + for _, battle in ipairs({ b, cb }) do + battle.safari = { balls = 5 } + battle.safariCatchRate = 80 + battle.baitFactor = 0 + battle.escapeFactor = 0 + if version == "platinum" then + battle.gen4Safari = require("src.battle.Gen4Safari").init() + end + end + T.eq(send(api, 1, "safari", { action = action }), true, version .. ": Safari " .. action) + cb.menuIndex = ({ ball = 1, bait = 2, rock = 3, run = 4 })[action] + press(cgame, cb, "a") + T.same(view(api), view(control), version .. ": Safari matches native " .. action) + T.eq(b.safari.balls, action == "ball" and 4 or 5, version .. ": ball spent only for throw") + local old = intent(api, 2, "safari", { action = "ball" }) + reject(api, game, old, "", version .. ": cannot repeat Safari action during messages") + b.phase = "menu" + b.result = nil + b.safari.balls = 0 + reject( + api, + game, + intent(api, 2, "safari", { action = "run" }), + "balls", + version .. ": depleted Safari belongs to automatic exit" + ) + b.safari.balls = 1 + reject( + api, + game, + intent(api, 2, "menu", { choice = "fight" }), + "safari", + version .. ": regular menu cannot bypass Safari" + ) + end +end + +-- BW eligibility goes through the native read-only rules, not the snapshot's +-- deliberately partial disabled flag. +for _, version in ipairs({ "black", "white" }) do + for _, guard in ipairs({ "choice", "imprison", "gravity", "heal", "sky" }) do + local api, game, b = boot(version) + b.phase = "moveSelect" + b.bw = { field = {}, sides = { {}, {} } } + local mv = b.player.curMoves[1] + local def = game.data.moves[mv.id] + if guard == "choice" then + game.data.items.CHOICE = { name = "Choice", bwPocket = 1, holdEffect = 55 } + b.player.items = game.data.items + b.player.mon.item = "CHOICE" + b.player.bwChoiceLock = "FIX_CUT" + b.player.curMoves[2] = { id = "FIX_CUT", pp = 3 } + elseif guard == "imprison" then + b.enemy.bwImprison = true + b.enemy.curMoves = { { id = mv.id, pp = 2 } } + elseif guard == "gravity" then + b.bw.field.gravity = 3 + def.gravityBanned = true + elseif guard == "heal" then + b.player.bwHealBlock = 3 + def.healMove = true + else + b.player.bwSkyDropped = b.enemy + end + reject( + api, + game, + intent(api, 1, "move", { slot = 1 }), + "", + version .. ": native BW restriction " .. guard + ) + end +end + +-- Cross-facade atomicity, consumption and recovery are deliberately tested +-- with native boundary overrides; the real-input cases above verify gameplay. +do + local api, game, b, other = boot("crystal") + reject( + api, + game, + { id = 1, revision = 1, kind = "menu", choice = "fight" }, + "snapshot", + "unobserved context" + ) + local native = b.chooseMenu + local executions = 0 + local nested, mid + local otherRequest = intent(other, 1, "menu", { choice = "fight" }) + b.chooseMenu = function(self, choice) + executions = executions + 1 + nested = { other:submit(otherRequest) } + mid = other:snapshot() + return native(self, choice) + end + T.eq(send(api, 1, "menu", { choice = "fight" }), true, "outer command accepted") + T.eq(nested[1], nil, "reentrant other mod rejected") + T.check(nested[2]:find("progress", 1, true) ~= nil, "reentrant rejection reason") + T.eq(mid.prompt, "locked", "mid-dispatch snapshot is locked") + reject( + other, + game, + { id = 1, revision = mid.revision, kind = "back" }, + "stale", + "mid-dispatch revision cannot act after return" + ) + T.eq(executions, 1, "one native dispatch") + T.eq(send(other, 1, "back"), true, "other facade has independent intent ID sequence") + b.chooseMenu = function() + executions = executions + 1 + return true + end + local held = intent(other, 2, "menu", { choice = "fight" }) + T.eq(send(api, 2, "menu", { choice = "fight" }), true, "same-shaped accepted native command") + reject(other, game, held, "stale", "shared epoch invalidates unchanged records across mods") + local raw = intent(api, 3, "menu", { choice = "fight" }) + b.chooseMenu = function() + executions = executions + 1 + b.phase = "messages" + error("native boom") + end + local accepted, err = api:submit(raw) + T.eq(accepted, nil, "native exception reported") + T.check( + err:find("consumed", 1, true) ~= nil and err:find("native boom", 1, true) ~= nil, + "native error explains consumed ID" + ) + reject(api, game, raw, "replayed", "partial failure cannot replay") + T.eq(Intents.state(game).busy, false, "error clears shared busy flag") + b.phase = "menu" + b.chooseMenu = native + T.eq(send(api, 4, "menu", { choice = "fight" }), true, "new request recovers after native failure") +end + +do + local api, game, b, other, loader = boot("red") + local original = b.menuBattler + local request = intent(api, 1, "menu", { choice = "fight" }) + local nested + local otherRequest = intent(other, 1, "menu", { choice = "fight" }) + b.menuBattler = function(self) + nested = { other:submit(otherRequest) } + return original(self) + end + T.eq(api:submit(request), true, "validation rejects recursion while outer intent succeeds") + T.eq(nested[1], nil, "validation reentry rejected") + T.eq(send(api, 2, "back"), true, "validation reentry leaves facade usable") + b.menuBattler = function(self) + game.stack.states[2] = {} + return original(self) + end + local ok, err = api:submit(intent(api, 3, "menu", { choice = "fight" })) + T.eq(ok, nil, "preflight context change refused") + T.check(err:find("stale", 1, true) ~= nil, "preflight context change reason") + -- The intentionally overridden validator above changed the stack itself; + -- test the refusal separately from the normal no-side-effects contract. + game.stack.states[2] = nil + b.menuBattler = original + T.eq(Intents.state(game).busy, false, "preflight failure clears busy flag") + local raw = intent(api, 3, "menu", { choice = "fight" }) + b.menuBattler = function(self) + raw.choice = "run" + raw.id = 999 + return original(self) + end + T.eq(api:submit(raw), true, "intent copied before native validation") + T.eq(b.phase, "moveSelect", "caller mutation cannot change dispatch choice") + b.menuBattler = original + local _, newGame, newBattle = boot("blue") + loader.game = newGame + reject( + api, + newGame, + { id = 3, revision = raw.revision, kind = "back" }, + "replayed", + "consumption survives Game replacement" + ) + T.eq(send(api, 4, "menu", { choice = "fight" }), true, "captured facade follows new Game") + T.eq(newBattle.phase, "moveSelect", "action reaches replacement Game") +end + +-- Accepted calls can enter normal refusal/forced-action paths too. They do +-- not reinterpret a native trainer RUN, no-PP Struggle or trapped FIGHT. +for _, mode in ipairs({ "struggle", "trapping", "ghost", "trainerRun" }) do + local api, game, b = boot("crystal", mode == "trainerRun") + local control, cgame, cb = boot("crystal", mode == "trainerRun") + for _, bb in ipairs({ b, cb }) do + if mode == "struggle" then + for _, mv in ipairs(bb.player.curMoves) do + mv.pp = 0 + end + end + if mode == "trapping" then + bb.player.trappingTurns = 2 + end + if mode == "ghost" then + bb.ghost = true + end + end + local choice = mode == "trainerRun" and "run" or "fight" + T.eq(send(api, 1, "menu", { choice = choice }), true, mode .. ": public selection accepted") + cb.menuIndex = mode == "trainerRun" and 4 or 1 + press(cgame, cb, "a") + local actual, expected = view(api), view(control) + actual.menuIndex = nil + expected.menuIndex = nil + T.same(actual, expected, mode .. ": preserves native branch") + T.eq(#b.queue, #cb.queue, mode .. ": native queue preserved") +end + +do + local api, game, b = boot("red") + local raw = intent(api, 1, "menu", { choice = "fight" }) + local rev = raw.revision + raw.extra = "typo" + reject(api, game, raw, "field", "invalid field is observational") + T.eq(api:snapshot().revision, rev, "pure invalid intent does not advance revision") + raw.extra = nil + local native = b.chooseMenu + b.chooseMenu = function() + return nil, "native declined" + end + local ok, err = api:submit(raw) + T.eq(ok, nil, "native refusal returned") + T.eq(err, "native declined", "native refusal reason retained") + reject(api, game, raw, "replayed", "native invocation consumes even when it declines") + b.chooseMenu = native + T.eq(send(api, 2, "menu", { choice = "fight" }), true, "native decline clears busy lock") + local _, otherGame = boot("blue") + game.stack.states[1] = otherGame.stack.states[1] + reject(api, game, intent(api, 3, "back"), "Game", "foreign Game battle cannot be controlled") +end +do + local api, game, b = boot("red") + local owner = Runtime.currentMod + Runtime.currentMod = "probe" + b.chooseMenu = function() + T.eq(Runtime.currentMod, nil, "native dispatch uses engine attribution") + error("scope failure") + end + local ok = send(api, 1, "menu", { choice = "fight" }) + T.eq(ok, nil, "scoped native error returned") + T.eq(Runtime.currentMod, "probe", "native error restores caller attribution") + Runtime.currentMod = owner +end +do + local api, game, b, other, loader = boot("emerald") + local callbackCount, nested, mid = 0, nil, nil + local held = intent(other, 1, "menu", { choice = "fight" }) + loader.apis.probe.events:on("battle.turn_started", function() + callbackCount = callbackCount + 1 + T.eq(Runtime.currentMod, nil, "real event uses native engine scope") + mid = other:snapshot() + nested = { other:submit(held) } + end) + T.eq(send(api, 1, "menu", { choice = "fight" }), true, "real event test selects FIGHT") + T.eq(send(api, 2, "move", { slot = 1 }), true, "real event test plans native turn") + T.eq(callbackCount, 1, "one native turn-start callback") + T.eq(nested[1], nil, "real event cannot recursively submit") + T.eq(mid.prompt, "locked", "real event sees locked in-flight snapshot") + reject( + other, + game, + { id = 1, revision = mid.revision, kind = "back" }, + "stale", + "real event snapshot expires after dispatch" + ) + T.eq(Runtime.currentMod, nil, "real native event restores engine/caller scope") +end +local api = boot("red", false, 1) +T.eq(send(api, 1, "menu", { choice = "fight" }), true, "API 1 mod can use additive public actions") +GameVersion.set(oldVersion) +T.finish("battle actions") diff --git a/tests/engine/battle_snapshots.lua b/tests/engine/battle_snapshots.lua index 27c992c5f..acf55273d 100644 --- a/tests/engine/battle_snapshots.lua +++ b/tests/engine/battle_snapshots.lua @@ -185,11 +185,11 @@ for _, version in ipairs(GameVersion.ORDER) do T.eq(resolves(), 0, label .. "entry can capture facade before Game exists") if api.supports then T.eq(api:supports("battle", "snapshot"), true, label .. "snapshot capability") - T.eq(api:supports("battle", "submit"), false, label .. "no input submission") + T.eq(api:supports("battle", "submit"), true, label .. "additive input submission") T.eq(resolves(), 0, label .. "probe does not resolve Game") end T.eq(api.battle, api.battle, label .. "stable facade") - T.eq(api.battle.submit, nil, label .. "no mutation method") + T.eq(type(api.battle.submit), "function", label .. "additive submit method") local no, reason = api.battle:snapshot() T.eq(no, nil, label .. "no battle") T.eq(reason, "no battle", label .. "no battle reason") From 418b55ff7359643dcb2e06c2fecfd4f2d620756b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:00:08 +0200 Subject: [PATCH 010/138] Add revision-bound public party, bag and dialogue UI actions --- docs/modding.md | 8 + docs/ui-actions-api.md | 128 ++++++ src/mods/Loader.lua | 8 +- src/mods/UIActions.lua | 241 +++++++++++ src/render/TextBox.lua | 62 ++- src/ui/BagMenu.lua | 7 +- src/ui/ChoiceBox.lua | 28 +- src/ui/Gen3PartyMenu.lua | 47 ++- src/ui/Gen4PartyMenu.lua | 40 +- src/ui/ListMenu.lua | 29 +- src/ui/Menu.lua | 39 +- src/ui/PartyMenu.lua | 778 ++++++++++++++++++----------------- src/ui/UICommands.lua | 346 ++++++++++++++++ tests/engine/ui_actions.lua | 792 ++++++++++++++++++++++++++++++++++++ 14 files changed, 2084 insertions(+), 469 deletions(-) create mode 100644 docs/ui-actions-api.md create mode 100644 src/mods/UIActions.lua create mode 100644 src/ui/UICommands.lua create mode 100644 tests/engine/ui_actions.lua diff --git a/docs/modding.md b/docs/modding.md index 023f89f5d..1e6175c38 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -230,6 +230,14 @@ The console understands these verbs (anything else is evaluated as Lua): - `trace PAT | trace off` — trace events/hooks matching a glob pattern. - `help` — list the verbs. +## Public party, bag and dialogue actions + +`mod.ui:snapshot()` returns detached options for the current native screen; +`mod.ui:submit(intent)` selects a row, goes back, changes bag pocket or advances +ready text through its native flow. See [Public UI actions](ui-actions-api.md) +for revision/ID requirements, per-generation menu behavior and supported screens. +The existing `mod.ui` list-building helpers retain their original signatures. + ## Tool input and title-menu hooks Tool mods that need to act once per game logic tick can wrap `input.step`. diff --git a/docs/ui-actions-api.md b/docs/ui-actions-api.md new file mode 100644 index 000000000..caa7c4d36 --- /dev/null +++ b/docs/ui-actions-api.md @@ -0,0 +1,128 @@ +# Public UI actions + +`mod.ui:snapshot()` and `mod.ui:submit(intent)` drive the **currently open** +native party, bag or dialogue screen. They are additive for API 1 and 2, with +no manifest opt-in. Existing `mod.ui.insertBefore`, `insertAfter`, step-list +helpers and their dot-call signatures remain available. Each mod receives its +own facade. If the optional capability-query feature is installed, +`mod:supports("ui", "snapshot")` and `mod:supports("ui", "submit")` return true. +This describes the interface, not whether the current screen accepts an action. + +The battle API can open the native party/bag menu. This API then selects its +rows and follows the native target, item, confirmation and message flow. It +does not open a menu from arbitrary world/script states. + +## Snapshot and intents + +A snapshot returns a detached table, or `nil, reason` if the top screen is +absent or unsupported. It never searches through a covering screen. Reading +early does not bootstrap Game, load a screen, poll input or render graphics. + +| Field | Meaning | +| --- | --- | +| `revision` | Positive per-facade context revision; obtain it from this facade | +| `kind` | `party`, `party_actions`, `bag`, `bag_actions`, `text` or `choice` | +| `rows` | Ordered options, including disabled placeholders; empty for text | +| `index` | Native selected row, where applicable | +| `locked` | Animation, typing, controller or script currently owns input | +| `canBack`, `canAdvance`, `canPocket` | Which non-selection intents are offered | +| `pocket` | Optional native pocket identifier; diagnostic, not portable | +| `page` | DS battle bag stage: `menu`, `pocket` or `use` | + +Every row has `index` and `enabled`. Depending on the native screen it can also +carry a string `label`/`action`, portable `item`/`species` ID, item `count`, or +`partyIndex`, `hp`, `maxHp`. Missing fields remain absent. The current dataset +resolves IDs, including numeric DS IDs and ROM-hack aliases. Labels can be +localized; native action strings are diagnostic, not a common semantic action +enumeration. There are no callbacks, native objects or future dialogue pages +in the public record. + +`select.index` is **1-based in the returned rows**. It is not always a party +slot or item number: party confirmation/cancel rows, DS list headers, GBA +blank action cells, and DS battle-bag navigation buttons retain their native +positions. Disabled rows cannot be submitted. In field bags the rows cover the +current pocket in native order; HGSS/BW scroll/page cursors are positioned for +the selected row. DS battle bags expose only their current page's six item +slots plus previous/next/back buttons. + +Intents must be plain tables with exactly the fields below: + +```lua +{ id = 1, revision = snapshot.revision, kind = "select", index = 2 } +{ id = 2, revision = snapshot.revision, kind = "back" } +{ id = 3, revision = snapshot.revision, kind = "advance" } +{ id = 4, revision = snapshot.revision, kind = "pocket", direction = 1 } +``` + +`id` is a strictly increasing positive safe integer **per UI facade**; +`revision` must come from a fresh snapshot returned by that facade. Pocket +`direction` is exactly `-1` or `1`. Unknown fields/kinds, malformed integers, +missing/disabled rows, stale contexts and unavailable actions return +`nil, reason` before native dispatch. Failed admission does not consume the ID. +Keep the ID counter across menu/Game changes. + +```lua +local nextId = 0 +local function chooseRow(index) + local snapshot, why = mod.ui:snapshot() + if not snapshot then return nil, why end + local row = snapshot.rows[index] + if snapshot.locked or not row or not row.enabled then + return nil, "row is not currently available" + end + nextId = nextId + 1 + return mod.ui:submit({ + id = nextId, revision = snapshot.revision, kind = "select", index = index, + }) +end +``` + +Take a new snapshot after each accepted action or native input/update. Revisions +track native screen/option/callback identity, live party/inventory and relevant +selection/control state. A private per-Game guard/epoch serializes UI and battle +submissions, rejects callback reentry and expires other mods' old and in-flight +snapshots. A snapshot obtained during dispatch reports `locked = true`. + +## Native behavior and limits + +- Party lists/pickers, swap/field/item submenus, forced replacement and team + selection retain their native eligibility, callback and cancellation paths. + `back` uses that screen's B behavior; it may clear a pending swap or submenu + instead of closing. The underlying battle decides how a cancelled forced + replacement is reopened. GB/GBA/DS each keep their own return semantics. +- Bags preserve native pocket order, item-use eligibility, target pickers and + consumption. The classic bag's own action menu and GBA item action grid are + supported; unrelated generic menus/lists are not inferred from their shape. + DS battle-bag presses complete on subsequent native updates, including their + page/category return paths. `true` does not mean an item has already run. +- `advance` handles a ready manual page/line break or final text, a bag's + result message, or a DS party notice. Typing and page/scroll holds are never + skipped. Multi-page question/auto/stay text can advance its intermediate + manual pages; the final script-owned page stays with its controller. +- TextBox's native choice creates the separate YES/NO screen on the next + native update. `select` chooses YES/NO; `back` selects NO. The native answer + hold and delayed callback are preserved, so the answer cannot be replayed + while pending. +- Scripts, no-input/tutorial states, healing fills, pending answer/DS battle + button animations, item sorting and quantity entry are locked. QuantityBox, + summary screens, PC/shop-specific auxiliary screens, TM cases/berry pouches, + arbitrary custom menus and custom screen subclasses have no adapter here; + they return `nil, reason`. This is not a generic UI automation API. + +`true` means a native action was admitted and entered. It may open another +screen, display a native refusal or schedule work. IDs are consumed **before** +native code. An error after partial native effects returns +`nil, "UI action failed; intent consumed: ..."`; the guard is released, but no +rollback or automatic retry occurs. Inspect a fresh snapshot before continuing. +The read/validation boundary is not a security sandbox for mods that directly +replace native engine methods or mutate Game themselves. + +## Reproducible tests + +Run `luajit tests/engine/ui_actions.lua` and `LUA=luajit bash scripts/test.sh`. +The focused suite uses real Loader/native menus, Pokémon, item effects and text +updates with the ROM-free fixture data across all 15 supported version IDs. +It checks native A/B parity, copied reads, full-graph immutability on refusal, +identity/revision/replay checks, callbacks and recovery after partial errors. +DS UI fixtures include native pocket/answer/press delays. Imported-ROM gameplay +and rendered cartridge scenes require a separate ROM-backed run. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 08e71b498..7a2d130fc 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -22,6 +22,7 @@ local Hooks = require("src.mods.Hooks") local ModStorage = require("src.mods.Storage") local Runtime = require("src.mods.Runtime") local BattleSnapshots = require("src.mods.BattleSnapshots") +local UIActions = require("src.mods.UIActions") local ModGens = require("src.mods.ModGens") local ModSeams = require("src.mods.ModSeams") -- Module-name aliases for mods written against the Gold port's per-generation @@ -918,9 +919,9 @@ function Loader:_api(mod) hooks = { wrap = function(_, name, callback, priority) return loader.hooks:wrap(name, callback, priority, modId) end }, - -- the widget toolkit facade (12 4.5) is one shared surface, not - -- per-mod state; each widget inside it loads on first touch - ui = ModUI, + -- Per-mod action/revision state, with the existing lazy widget builders. + -- Reading the facade early must not construct Game or a native screen. + ui = UIActions.new(function() return loader:_game(true) end, loader, ModUI), -- namespaced per mod; M11 backs these with save.modData / -- options.modOptions, the shape mods compile against is already final save = { @@ -1127,6 +1128,7 @@ function Loader:_api(mod) -- the same reason: a headless load must not drag the world stack in. -- Battle capability queries can also be installed before Game exists. BattleSnapshots.withCapabilities(api) + UIActions.withCapabilities(api) -- The Game the facade acts on is still being wired when the entry chunk -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. diff --git a/src/mods/UIActions.lua b/src/mods/UIActions.lua new file mode 100644 index 000000000..533730961 --- /dev/null +++ b/src/mods/UIActions.lua @@ -0,0 +1,241 @@ +-- Per-mod, detached UI snapshots and revision-bound native actions. +local Commands = require("src.ui.UICommands") +local Intents = require("src.mods.BattleIntents") +local Ids = require("src.mods.UnifiedIds") +local Runtime = require("src.mods.Runtime") +local Actions = {} +local function copy(v, seen) + if type(v) ~= "table" then + if type(v) == "string" or type(v) == "number" or type(v) == "boolean" then + return v + end + return nil + end + seen = seen or {} + if seen[v] then + return seen[v] + end + local out = {} + seen[v] = out + for k, value in pairs(v) do + if type(k) == "string" or type(k) == "number" then + out[k] = copy(value, seen) + end + end + return out +end +local function equal(a, b, seen) + if type(a) ~= type(b) then + return false + end + if type(a) ~= "table" then + return a == b or (a ~= a and b ~= b) + end + seen = seen or {} + if seen[a] then + return seen[a] == b + end + seen[a] = b + for k, v in pairs(a) do + if not equal(v, b[k], seen) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end +local function engine(fn, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local ok, a, b, c = pcall(fn, ...) + Runtime.currentMod = owner + return ok, a, b, c +end +local function integer(n) + return type(n) == "number" and n >= 1 and n <= 9007199254740991 and n % 1 == 0 +end +local function normalize(raw) + if type(raw) ~= "table" or getmetatable(raw) ~= nil then + return nil, "intent must be a plain table" + end + if not integer(raw.id) or not integer(raw.revision) then + return nil, "invalid UI id or revision" + end + local fields = ({ select = { index = true }, back = {}, advance = {}, pocket = { direction = true } })[raw.kind] + if not fields then + return nil, "unknown UI intent" + end + for k in pairs(raw) do + if k ~= "id" and k ~= "revision" and k ~= "kind" and not fields[k] then + return nil, "unknown UI intent field" + end + end + if raw.kind == "select" and not integer(raw.index) then + return nil, "invalid UI row" + end + if raw.kind == "pocket" and raw.direction ~= -1 and raw.direction ~= 1 then + return nil, "invalid pocket direction" + end + return { + id = raw.id, + revision = raw.revision, + kind = raw.kind, + index = raw.index, + direction = raw.direction, + } +end +function Actions.new(provider, loader, builders) + local ids = Ids.new(loader) + ids:entries("pokemon") + ids:entries("items") + local facade = setmetatable({}, { __index = builders }) + local revision, consumed = 0, 0 + local previous, oldRefs, oldControls, oldEpoch, offered + local function refresh() + local game = provider() + local states = game and game.stack and game.stack.states + local top = states and states[#states] + if not top then + previous, oldRefs, oldControls, oldEpoch, offered = nil, nil, nil, nil, nil + return nil, "no UI screen" + end + local ok, view, refs, kind = engine(Commands.describe, game, top, Ids.new(loader)) + if not ok or not view then + previous, oldRefs, oldControls, oldEpoch, offered = nil, nil, nil, nil, nil + return nil, ok and refs or "UI snapshot failed" + end + refs.game = game + -- Capture live eligibility, target and inventory changes even when the + -- native screen has not rebuilt its labels yet. Never return this data. + local controls = copy({ + party = type(top.party) == "table" and top.party or game.save and game.save.party, + inventory = game.save and game.save.inventory, + bagOrder = game.save and game.save.bagOrder, + select = top.select, + menu = top.menu, + submenu = top.submenu, + swapFrom = top.swapFrom, + switchFrom = top.switchFrom, + softboiledFrom = top.softboiledFrom, + hpTransfer = top.hpTransfer, + hpTransferFrom = top.hpTransferFrom, + forceSwitch = top.forceSwitch, + pickOnly = top.pickOnly, + keepOpen = top.keepOpen, + pick = top.pick, + store = top.store, + sell = top.sell, + registeredItem = game.save and game.save.registeredItem, + cur = top.cur, + mem = top.mem, + page = top.page, + top = top.top, + done = top.done, + waiting = top.waiting, + shown = top.shown, + contAdvance = top.contAdvance, + lineIndex = top.lineIndex, + pageIndex = top.pageIndex, + pending = top.pending, + trash = top.trash, + moving = top.moving, + lockPocket = top.lockPocket, + resultMessage = top.resultMessage, + message = top.message, + selectNotice = top.selectNotice, + }) + local state = Intents.state(game) + if + not equal(view, previous) + or not Intents.sameRefs(refs, oldRefs) + or not equal(controls, oldControls) + or state.epoch ~= oldEpoch + then + revision = revision + 1 + end + previous, oldRefs, oldControls, oldEpoch = view, refs, controls, state.epoch + return { game = game, top = top, view = view, kind = kind, state = state } + end + function facade:snapshot() + local ctx, err = refresh() + if not ctx then + return nil, err + end + local out = copy(ctx.view) + out.revision = revision + if ctx.state.busy then + out.locked, out.canBack, out.canAdvance, out.canPocket = true, false, false, false + end + offered = revision + return out + end + function facade:submit(raw) + local intent, err = normalize(raw) + if not intent then + return nil, err + end + if intent.id <= consumed then + return nil, "replayed UI intent" + end + local ctx + ctx, err = refresh() + if not ctx then + return nil, err + end + local state, view = ctx.state, ctx.view + if state.busy then + return nil, "UI action is already in progress" + end + if intent.revision ~= revision then + return nil, "stale UI context" + end + if offered ~= revision then + return nil, "fresh UI snapshot required" + end + if view.locked then + return nil, "UI is locked" + end + if intent.kind == "select" then + if not view.rows[intent.index] or not view.rows[intent.index].enabled then + return nil, "unavailable UI row" + end + elseif intent.kind == "back" and not view.canBack then + return nil, "UI cannot go back" + elseif intent.kind == "advance" and not view.canAdvance then + return nil, "UI cannot advance" + elseif intent.kind == "pocket" and not view.canPocket then + return nil, "UI cannot change pocket" + end + -- Native work can raise after partial effects. Consume before dispatch; + -- the shared epoch also expires snapshots held by battle/other UI facades. + consumed, offered = intent.id, nil + state.busy, state.epoch = true, state.epoch + 1 + local ok, failure = engine(Commands.dispatch, ctx.top, ctx.kind, view, intent) + state.busy, state.epoch = false, state.epoch + 1 + if not ok then + local printable, message = pcall(tostring, failure) + return nil, "UI action failed; intent consumed" .. (printable and ": " .. message or "") + end + return true + end + return facade +end +function Actions.withCapabilities(api) + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "ui" then + if name == "snapshot" or name == "submit" then + return true + end + return false, "unknown UI capability: " .. tostring(name) + end + return supports(self, surface, name) + end + end +end +return Actions diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 07f605295..359964018 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -420,6 +420,44 @@ function TextBox:beginLine() table.insert(self.shown, {}) end +-- Only the manual input branches; typing, automatic text and holds still +-- belong to update(), including the delay after a page/line advance. +function TextBox:advance() + if (self.holdFrames or 0) > 0 or (self.preWait or 0) > 0 then + return + end + if self.done then + if self.stay or self.auto or self.choice then + return + end + require("src.core.Sound").play(self.game.data, "Press_AB") + self.game.stack:pop() + if self.onDone then + self.onDone() + end + elseif self.waiting then + require("src.core.Sound").play(self.game.data, "Press_AB") + self.waiting = false + if self.contAdvance then + -- ContText / ManualTextScroll: keep the box, scroll one line + self.contAdvance = false + self.lineIndex = self.lineIndex + 1 + self:beginLine() + -- ScrollTextUpOneLine is 5 blocking frames and, as its own comment + -- says, is "always called twice in a row" (home/text.asm:280-305) + self.holdFrames = self.scrollHoldFrames or Timing.TEXT_SCROLL_PAIR + else + self.shown = {} + self.pageIndex = self.pageIndex + 1 + self.lineIndex = 1 + self:beginLine() + -- ClearScreenArea then DelayFrames 20: the box sits empty before the + -- next page starts typing (home/text.asm:236-240) + self.holdFrames = Timing.TEXT_PAGE_CLEAR + end + end +end + function TextBox:update(dt) local input = self.game.input self.blink = (self.blink + 1) % 60 @@ -519,9 +557,7 @@ function TextBox:update(dt) return end if input:wasPressed("a") or input:wasPressed("b") then - require("src.core.Sound").play(self.game.data, "Press_AB") - self.game.stack:pop() - if self.onDone then self.onDone() end + self:advance() end return end @@ -534,25 +570,7 @@ function TextBox:update(dt) return end if input:wasPressed("a") or input:wasPressed("b") then - require("src.core.Sound").play(self.game.data, "Press_AB") - self.waiting = false - if self.contAdvance then - -- ContText / ManualTextScroll: keep the box, scroll one line - self.contAdvance = false - self.lineIndex = self.lineIndex + 1 - self:beginLine() - -- ScrollTextUpOneLine is 5 blocking frames and, as its own comment - -- says, is "always called twice in a row" (home/text.asm:280-305) - self.holdFrames = self.scrollHoldFrames or Timing.TEXT_SCROLL_PAIR - else - self.shown = {} - self.pageIndex = self.pageIndex + 1 - self.lineIndex = 1 - self:beginLine() - -- ClearScreenArea then DelayFrames 20: the box sits empty before the - -- next page starts typing (home/text.asm:236-240) - self.holdFrames = Timing.TEXT_PAGE_CLEAR - end + self:advance() end return end diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 4d229471d..4020375b6 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -1234,11 +1234,14 @@ function BagMenu.new(game, opts) options[#options + 1] = { label = Strings("QUIT"), onSelect = function() end } end local th = #options * 2 + 1 - game.stack:push(Menu.new(game, options, gen2 + local actions = Menu.new(game, options, gen2 and { tx = 0, ty = 12 - th, tw = 7, th = th } - or { tx = 13, ty = 10, tw = 7, th = th })) + or { tx = 13, ty = 10, tw = 7, th = th }) + actions.publicUIKind = "bag_actions" + game.stack:push(actions) end, }) + list.publicUIKind, list.publicUIBattle = "bag", battle if gen2 then list.sgbPalettes = packPalettes end return list end diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index 1f9f41f81..ba7edbdd0 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -30,6 +30,18 @@ function ChoiceBox.new(game, onChoose, opts) return self end +function ChoiceBox:choose(index) + if self.pending ~= nil then + return + end + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + self.index = index + self.pending = index == 1 + self.holdFrames = Timing.YES_NO_ANSWER +end + function ChoiceBox:update(dt) local input = self.game.input -- Both branches of DisplayTwoOptionMenu hold 15 frames with the menu still @@ -48,21 +60,9 @@ function ChoiceBox:update(dt) if input:wasPressed("up") or input:wasPressed("down") then self.index = self.index == 1 and 2 or 1 elseif input:wasPressed("a") then - -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on A and B alike - if not self.noSound then - require("src.core.Sound").play(self.game.data, "Press_AB") - end - self.pending = (self.index == 1) - self.holdFrames = Timing.YES_NO_ANSWER + self:choose(self.index) elseif input:wasPressed("b") then - if not self.noSound then - require("src.core.Sound").play(self.game.data, "Press_AB") - end - -- .choseSecondMenuItem writes wCurrentMenuItem = 1 before the hold, so - -- the cursor visibly snaps to NO for those 15 frames - self.index = 2 - self.pending = false - self.holdFrames = Timing.YES_NO_ANSWER + self:choose(2) end end diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index 4f3f78b39..855f2c805 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -1015,6 +1015,38 @@ function Gen3PartyMenu:animateTo(mon, fromHP, onDone) self.heal = { mon = mon, from = from, shown = from, onDone = onDone } end +function Gen3PartyMenu:confirm() + if self.index > #self:party() then + if self.hpTransferFrom then + self.hpTransferFrom = nil + return + end + -- the CANCEL button VALIDATES when a team is being chosen; everywhere + -- else it is the way out + if self.chooseOrder then + return self:confirmOrder() + end + return self:close() + end + self:choose() +end + +function Gen3PartyMenu:cancel() + if self.hpTransferFrom then + self.hpTransferFrom = nil + return + end + -- B drops a SWITCH that was half-made before it closes the screen + if self.switchFrom then + self.switchFrom = nil + return + end + if self.chooseOrder then + return self:handOrder(nil) + end + self:close() +end + function Gen3PartyMenu:update(dt) -- RECONSTRUCTED: every icon bounces, all the time, on the same beat. On -- the cartridge each one is an OBJ with its own callback; here it is one @@ -1073,20 +1105,9 @@ function Gen3PartyMenu:update(dt) self.index = 1 end elseif input:wasPressed("a") then - if self.index > #self:party() then - if self.hpTransferFrom then self.hpTransferFrom=nil;return end - -- the CANCEL button VALIDATES when a team is being chosen; everywhere - -- else it is the way out - if self.chooseOrder then return self:confirmOrder() end - return self:close() - end - self:choose() + self:confirm() elseif input:wasPressed("b") then - if self.hpTransferFrom then self.hpTransferFrom=nil;return end - -- B drops a SWITCH that was half-made before it closes the screen - if self.switchFrom then self.switchFrom = nil return end - if self.chooseOrder then return self:handOrder(nil) end - self:close() + self:cancel() end end diff --git a/src/ui/Gen4PartyMenu.lua b/src/ui/Gen4PartyMenu.lua index ce9163880..851b8ec29 100644 --- a/src/ui/Gen4PartyMenu.lua +++ b/src/ui/Gen4PartyMenu.lua @@ -876,6 +876,28 @@ function Gen4PartyMenu:step(dir) end end +function Gen4PartyMenu:cancel() + if self.switchFrom then + self.switchFrom = nil + elseif self.hpTransfer then + self.hpTransfer = nil + -- B IN A BATTLE IS "BACK TO THE MENU", NOT "I PICK NOTHING". + -- + -- Out of a battle, handing the answer back as nil is how a caller learns + -- the player cancelled, and that is right. IN a battle it crashed: the + -- battle's own onSwitch is written for a Pokemon and went straight into + -- `mon.hp`. The battle already knows what to do when this screen simply + -- closes, so closing is both the safe answer and the correct one. + elseif self.onSwitch and not (self.pickOnly or self.forceSwitch) and not self.battle then + self:handOff(nil) + elseif self.select then + self.index = self:count() + self:choose() + else + self:close() + end +end + function Gen4PartyMenu:update(dt) self.t = (self.t or 0) + (dt or 1 / 60) local input = self.game.input @@ -908,23 +930,7 @@ function Gen4PartyMenu:update(dt) elseif input:wasPressed("a") then self:choose() elseif input:wasPressed("b") or input:wasPressed("start") then - if self.switchFrom then self.switchFrom = nil - elseif self.hpTransfer then self.hpTransfer = nil - -- B IN A BATTLE IS "BACK TO THE MENU", NOT "I PICK NOTHING". - -- - -- Out of a battle, handing the answer back as nil is how a caller learns - -- the player cancelled, and that is right. IN a battle it crashed: the - -- battle's own onSwitch is written for a Pokemon and went straight into - -- `mon.hp`. The battle already knows what to do when this screen simply - -- closes, so closing is both the safe answer and the correct one. - elseif self.onSwitch and not (self.pickOnly or self.forceSwitch) - and not self.battle then - self:handOff(nil) - elseif self.select then - self.index = self:count(); self:choose() - else - self:close() - end + self:cancel() end end diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index e93098790..cf42a71f2 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -172,6 +172,25 @@ local function navPressed(self, dir) return true end +function ListMenu:choose() + if #self.items == 0 then + return self:cancel() + end + beep(self) + local item = self.items[self.index] + if self.onChoose then + self.onChoose(item, self) + end +end + +function ListMenu:cancel() + beep(self) + self.game.stack:pop() + if self.onCancel then + self.onCancel() + end +end + function ListMenu:update(dt) if self.script then self.script(self) @@ -211,16 +230,10 @@ function ListMenu:update(dt) elseif self.onSelectKey and input:wasPressed("select") then self.onSelectKey(self.items[self.index], self) elseif input:wasPressed("b") then - beep(self) - self.game.stack:pop() - if self.onCancel then self.onCancel() end + self:cancel() return elseif input:wasPressed("a") then - beep(self) - local item = self.items[self.index] - if self.onChoose then - self.onChoose(item, self) - end + self:choose() return end diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 2415d798b..24b36069d 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -120,6 +120,35 @@ function Menu:clampScroll() end end +function Menu:choose() + -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + local item = self.items[self.index] + -- keepOpen entries run without closing the menu (e.g. the + -- Pokédex CRY option keeps the side menu up) + if not item.keepOpen then + self.game.stack:pop() + end + if item.onSelect then + item.onSelect() + end +end + +function Menu:cancel() + if not self.cancelable then + return + end + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + self.game.stack:pop() + if self.onCancel then + self.onCancel() + end +end + function Menu:update(dt) local input = self.game.input local wasIndex = self.index @@ -128,15 +157,7 @@ function Menu:update(dt) elseif input:wasPressed("down") then self.index = self.index < #self.items and self.index + 1 or 1 elseif input:wasPressed("a") then - -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press - if not self.noSound then - require("src.core.Sound").play(self.game.data, "Press_AB") - end - local item = self.items[self.index] - -- keepOpen entries run without closing the menu (e.g. the - -- Pokédex CRY option keeps the side menu up) - if not item.keepOpen then self.game.stack:pop() end - if item.onSelect then item.onSelect() end + self:choose() elseif self.cancelable and (input:wasPressed("b") or (self.startCloses and input:wasPressed("start"))) then -- HandleMenuInput_ returns for any watched key, but only replays diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index e59d7173b..4b487d623 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -472,6 +472,399 @@ function PartyMenu:close() if self.game.stack:top() == self then self.game.stack:pop() end end +-- Native confirmation shared by input and the public UI facade. +function PartyMenu:confirm() + local party = self.party or self.game.save.party + if self.submenu then + local mon = party[self.index] + local entry = self.subItems[self.subIndex] + local action = entry.action + if not action and entry.onSelect then + -- hook-injected entries carry a callback instead of an action id + entry.onSelect(mon, self.game) + elseif action == "stats" then + -- battle and field alike return to the party list afterwards + -- (core.asm .partyMenuWasSelected) + Screens.push(self.game, "SummaryMenu", mon, { + mons = party, + index = self.index, + onMonChange = function(index) self.index = index end, + }) + elseif action == "battle_switch" then + self.game.stack:pop() + self.onSwitch(mon) + return + elseif action == "cancel" then + self.game.stack:pop() + if self.onCancel then self.onCancel() end + return + elseif action == "switch" then + self.swapFrom = self.index + elseif action == "item" then + self:openItemMenu(mon) + elseif action == "quit" then + self.submenu = nil + elseif action == "fly" then + -- FLY opens the TOWN MAP with a cursor over the visited fly towns, + -- not a plain text list (engine/menus/town_map.asm LoadTownMap_Fly). + -- flyTo (OverworldController) validates the fly-warp + runs the + -- departure/warp, so we just hand it the chosen mapId (#195). + local ow = self.game.overworld + self.game.stack:pop() -- close the party menu + Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId) + if ow then ow:flyTo(mapId) end + end }) + return + elseif action == "flash" then -- FLASH lights dark tunnels + -- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText runs + -- with the party menu still on screen, and only then + -- GBPalWhiteOutWithDelay3 + jp .goBackToMap. So the message reads + -- over the menu, and the cave is lit when the blink hands the + -- screen back, never under the text (#385). + local ow = self.game.overworld + local TextBox = require("src.render.TextBox") + local Transition = require("src.render.Transition") + -- The other half of CheckUseFlash: using it in the Aerodactyl chamber + -- opens the wall. The chamber's scene script plays the opening the + -- next time the map loads, exactly as the escape-rope one does. + require("src.script.RuinsOfAlph").aerodactylChamber(self.game) + self.game.save.flashLit = true + self.game.stack:push(TextBox.new(self.game, + self.game.data.text._FlashLightsAreaText + or Strings("A blinding FLASH\nlights the area!"), function() + self:close() + -- setDark, not a bare field write: ADVANCED carries the darkness + -- in a baked atlas, so lighting the cave drops every resident map + -- and rebakes this one (#383). It runs HERE, before the blink, + -- because start_sub_menus.asm .flash clears wMapPalOffset before + -- PrintText and blinks last of all: the cave is already lit by the + -- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the + -- blink's completion instead left that rebuild's whole cost -- + -- seconds of per-pixel atlas baking on a phone -- on screen as a + -- solid white frame with nothing under it, which reads as a + -- lockup (#610). + ow:setDark(false) + self.game.stack:push(Transition.whiteFlash(self.game)) + end)) + return + elseif action == "surf" then + -- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time + -- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F + -- current refusals, both of which loop back to the submenu), then + -- ItemUseSurfboard: while surfing it tries to dismount instead; + -- otherwise it mounts only if the FACING tile is water, else + -- SurfingAttemptFailed (_NoSurfingHereText) loops back to the + -- submenu. useSurfFieldMove reports which; trySurf does the mount. + local ow = self.game.overworld + local reason = ow:useSurfFieldMove() + local Transition = require("src.render.Transition") + if reason == "ok" then + -- UseItem prints _SurfingGotOnText with the party menu still up; + -- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so + -- trySurf closes this menu when its text does (#385) + local fx, fy = ow.player:facingCell() + ow:trySurf(fx, fy, function() self:close() end) + return + end + if reason == "dismount" then + -- ItemUseSurfboard .stopSurfing: no text -- the walking state + -- and music return first (PlayDefaultMusic + + -- LoadWalkingPlayerSpriteGraphics), the menu closes with the + -- GBPalWhiteOutWithDelay3 blink, and the simulated pad press + -- steps the player forward onto land (or across a connection + -- strip when the shore is the next map's edge) + self.game.stack:pop() + ow.player.surfing = false + require("src.core.Music").setSurfing(self.game.data, false) + self.game.stack:push(Transition.whiteFlash(self.game, nil, function() + ow:stepForwardOrCrossEdge(ow.player.facing) + end)) + return + end + local TextBox = require("src.render.TextBox") + local def = self.game.data.pokemon[mon.species] + local key = ({ no_badge = "_NewBadgeRequiredText", + forced_bike = "_CyclingIsFunText", + current = "_CurrentTooFastText", + no_place = "_SurfingNoPlaceToGetOffText" })[reason] + or "_NoSurfingHereText" + local txt = (self.game.data.text[key] or Strings("No SURFing here!")) + :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) + if reason == "no_place" then + -- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but + -- never zeroes wActionResultOrTookBattleTurn, so unlike the + -- other refusals the menu still closes afterwards + -- (GBPalWhiteOutWithDelay3 + .goBackToMap), and the text prints + -- over the still-open menu like every other .loop refusal (#385) + self.game.stack:push(TextBox.new(self.game, txt, function() + self:close() + self.game.stack:push(Transition.whiteFlash(self.game)) + end)) + return + end + self.game.stack:push(TextBox.new(self.game, txt)) + return -- .loop: submenu stays open behind the message + elseif action == "cut" then + -- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm): + -- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the + -- submenu when the FACING tile isn't a cuttable tree. + local ow = self.game.overworld + local reason = ow:useCutFieldMove() + if reason == "ok" then + self.game.stack:pop() -- close the party menu (CloseTextDisplay) + local fx, fy = ow.player:facingCell() + ow:tryCut(fx, fy) + return + end + local TextBox = require("src.render.TextBox") + local def = self.game.data.pokemon[mon.species] + local key = (reason == "no_badge") and "_NewBadgeRequiredText" + or "_NothingToCutText" + local txt = (self.game.data.text[key] or Strings("Nothing to CUT!")) + :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) + self.game.stack:push(TextBox.new(self.game, txt)) + return -- .loop: submenu stays open behind the message + elseif action == "strength" then + -- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time); + -- predef PrintStrengthText (field_move_messages.asm) sets + -- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate + -- push_boulder.asm reads -- then prints _UseStrengthText (no + -- prompt: after the text, the text_asm tail plays the chosen + -- mon's cry, Delay3, and it auto-advances) and + -- _MoveBoulderText (`prompt`: waits for A/B). Back in + -- .strength, GBPalWhiteOutWithDelay3 blinks the screen white + -- before CloseTextDisplay returns to the map. + local ow = self.game.overworld + local TextBox = require("src.render.TextBox") + local Transition = require("src.render.Transition") + local def = self.game.data.pokemon[mon.species] + local name = mon.nickname or (def and def.name) or mon.species + ow.strengthActive = true + -- Gen2 text keys are _UseStrengthText / _MoveBoulderText; + -- Gen1 uses _UsedStrengthText / _CanMoveBouldersText. Try both. + local t1raw = self.game.data.text._UseStrengthText + or self.game.data.text._UsedStrengthText + local t2raw = self.game.data.text._MoveBoulderText + or self.game.data.text._CanMoveBouldersText + local t1 = (t1raw or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name) + local t2 = (t2raw or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name) + -- like surf (#320, #385): both texts print with the party menu + -- still on screen, and the blink IS the menu closing afterwards, + -- not a flashbang on the empty map + self.game.stack:push(TextBox.new(self.game, t1, function() + self.game.stack:push(TextBox.new(self.game, t2, function() + self:close() + self.game.stack:push(Transition.whiteFlash(self.game)) + end)) + end, { auto = { sound = function() + return require("src.core.Sound").playCry(self.game.data, mon.species) + end } })) + return + elseif action == "gen2_field" then + -- GSC runs the same handler from the menu as from the overworld + -- A press (SurfFromMenuScript / HeadbuttFromMenuScript), so the + -- tile check lives in one place. + local ow = self.game.overworld + local fx, fy = ow.player:facingCell() + self:close() + if not ow:gen2FieldMoveAt(entry.move, mon, fx, fy) then + local TextBox = require("src.render.TextBox") + -- _CantSurfText is "You can't SURF here", so using it for every + -- field move told a player holding HEADBUTT that the game thought + -- they had picked SURF. Only SURF gets the surf line. + local text = (entry.move == "SURF") + and self.game.data.text._CantSurfText or nil + self.game.stack:push(TextBox.new(self.game, + text or Strings("You can't use that\nhere."))) + end + return + elseif action == "softboiled" then + -- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer + -- 1/5 of the user's max HP to a chosen teammate + self.softboiledFrom = self.index + elseif action == "dig" then + -- GEN 2 (.DoDig): back out through the recorded entrance, arriving as + -- a DOOR warp so the player steps out of the opening rather than + -- standing in it. GEN 1's DIG is ItemUseEscapeRope and shares + -- TELEPORT's Pokemon Center warp, so it falls through to the same + -- departure with no target. + local ow = self.game.overworld + self.game.stack:pop() + if ow then + local escape = require("src.core.GameVersion").isGen2() + and ow.escapePoint and ow:escapePoint() and true or nil + ow:beginTeleportOut(nil, { escape = escape }) + end + return + elseif action == "escape" then + -- TELEPORT warps to the last Pokémon Center TOWN (wLastBlackoutMap, + -- special_warps.asm escape warp) -- and Gen 2 agrees: TeleportFunction + -- .TryTeleport requires an OUTDOOR map and warps to wLastSpawnMapGroup + -- / wLastSpawnMapNumber, so this destination is right for both + -- generations. Only DIG diverged; it has its own branch above. + -- pokered's .dig/.teleport spin the + -- player up (LeaveMapAnim), white/fade out, then land it; this port + -- lands OUTSIDE the town PC door like Fly (#196). beginTeleportOut + -- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares + -- the exact departure; the fade + warp fire when the spin ends. + local ow = self.game.overworld + self.game.stack:pop() + if ow then ow:beginTeleportOut() end + return + end + self.submenu = nil + return + end + if #party == 0 then return end + local mon = party[self.index] + if self.softboiledFrom then + local user = party[self.softboiledFrom] + local heal = math.floor(user.stats.hp / 5) + if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp + or user.hp <= heal then + self.softboiledFrom = nil + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect."))) + else + user.hp = user.hp - heal + mon.hp = math.min(mon.stats.hp, mon.hp + heal) + self.softboiledFrom = nil + require("src.core.Sound").play(self.game.data, "Heal_HP") + local def = self.game.data.pokemon[mon.species] + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, + Strings("%s's HP\nwas restored!", mon.nickname or def.name))) + end + elseif self.swapFrom then + if self.swapFrom ~= self.index then + party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom] + require("src.core.Sound").play(self.game.data, "Swap") + end + self.swapFrom = nil + elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then + -- keepOpen callers (HP medicine) need the menu still drawn while the + -- bar fills and the message prints, and close it themselves; everyone + -- else keeps the old pop-then-call order. Popping first is what made + -- a POTION snap the picker shut before the item had even run (#252). + if not self.keepOpen then self.game.stack:pop() end + self.onSwitch(mon, self) + else + self.submenu = true + self.subIndex = 1 + local items + local ow = self.game.overworld + if self.battle and self.onSwitch then + -- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun) + items = { { label = Strings("SWITCH"), action = "battle_switch" }, + { label = Strings("STATS"), action = "stats" }, + { label = Strings("CANCEL"), action = "cancel" } } + else + -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm + -- builds the same dynamic list) + items = { { label = Strings("STATS"), action = "stats" }, + { label = Strings("SWITCH"), action = "switch" } } + -- Field moves (HMs/TMs) are usable out of battle even when the mon + -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. + -- Battle still excludes this list via `not self.battle`. Softboiled + -- can appear for a fainted user; its heal transfer then no-ops. + if not self.battle and ow then + -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU -- + -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) + local outside = Map.isOutside(ow.map.def, + FieldDefaults.field(self.game.data, "outsideTilesets")) + -- The badge gate per field move comes from constants.hmBadges and + -- the badge itself through Badges.has: Gen2 gates the same moves on + -- the Johto badges and stores them as engine flags rather than bag + -- items, so the hard-coded R/B item names listed nothing at all. + local gates = FieldDefaults.constant(self.game.data, "hmBadges") or {} + local function badged(moveId) + local gate = gates[moveId] + if not (gate and gate.badge) then return true end + return Badges.has(self.game.save, { id = gate.badge }) + end + for _, mv in ipairs(mon.moves) do + if mv.id == "FLY" and outside and badged("FLY") then + table.insert(items, { label = Strings("FLY"), action = "fly" }) + -- LIGHT is carved on the Aerodactyl chamber wall, and the + -- chamber is not a dark cave -- so CheckUseFlash asks + -- SpecialAerodactylChamber FIRST and lets FLASH be used on its + -- carry, before it ever looks at the palset + -- (engine/events/overworld.asm:285). Without this the move is + -- never even offered there and the wall can never open. + elseif mv.id == "FLASH" and badged("FLASH") + and (ow.dark or (ow.map and ow.map.id + == require("src.script.RuinsOfAlph").MAPS.aerodactyl)) then + table.insert(items, { label = Strings("FLASH"), action = "flash" }) + elseif mv.id == "CUT" and badged("CUT") then + -- CUT/SURF/STRENGTH are party-menu field moves too + -- (start_sub_menus.asm .outOfBattleMovePointers); listed here + -- with the same list-time badge filter this file already uses + -- for FLY/FLASH. The facing-tile/activation check happens on + -- selection (useCutFieldMove/useSurfFieldMove). + table.insert(items, { label = Strings("CUT"), action = "cut" }) + elseif mv.id == "SURF" and badged("SURF") then + table.insert(items, { label = Strings("SURF"), action = "surf" }) + elseif mv.id == "STRENGTH" and badged("STRENGTH") then + table.insert(items, { label = Strings("STRENGTH"), action = "strength" }) + elseif mv.id == "SOFTBOILED" or mv.id == "MILK_DRINK" then + table.insert(items, { label = Strings(mv.id:gsub("_", " ")), + action = "softboiled" }) + elseif mv.id == "TELEPORT" and outside then + -- TELEPORT works only OUTDOORS (start_sub_menus.asm + -- .teleport -> CheckIfInOutsideMap); dark maps don't + -- block it + table.insert(items, { label = Strings("TELEPORT"), action = "escape" }) + elseif mv.id == "DIG" and ow.map.id ~= "AGATHAS_ROOM" + and (DIG_TILESETS[ow.map.def.tileset] + or (GEN2_DIG_ENVIRONMENTS[ow.map.def.environment] + and ow.escapePoint and ow:escapePoint())) then + -- GEN 1: DIG runs ItemUseEscapeRope (.dig sets wCurItem = + -- ESCAPE_ROPE): usable in the dungeon tilesets of + -- escape_rope_tilesets.asm minus Agatha's room, even in + -- the dark (Rock Tunnel). + -- GEN 2: .CheckCanDig instead -- CAVE or DUNGEON, and a + -- recorded entrance to come back out of. Its own action, + -- because Gen 2's DIG and TELEPORT no longer share a + -- destination the way Gen 1's did. + table.insert(items, { label = Strings("DIG"), action = "dig" }) + elseif GEN2_FIELD_MOVES[mv.id] and badged(mv.id) then + -- GSC's own field moves; the tile check happens on selection, + -- exactly like CUT's (start_sub_menus.asm never gates the list + -- on where the player is standing) + table.insert(items, { label = Strings(mv.id:gsub("_", " ")), + action = "gen2_field", move = mv.id }) + end + end + end + if require("src.core.GameVersion").isGen2() then + -- GSC's MonMenu lists the field moves first, then STATS, SWITCH, + -- ITEM and QUIT; Gen 1 has no held items and no QUIT row. + local stats, switch = table.remove(items, 1), table.remove(items, 1) + items[#items + 1] = stats + items[#items + 1] = switch + items[#items + 1] = { label = Strings("ITEM"), action = "item" } + items[#items + 1] = { label = Strings("QUIT"), action = "quit" } + end + end + local ctx = { battle = self.battle, overworld = ow } + local hooked = Runtime.call("ui.party.submenu", sameItems, + self.game, items, mon, ctx) + if type(hooked) == "table" then + items = hooked + else + Logger.error("ui.party.submenu returned %s; keeping the vanilla list", + type(hooked)) + end + self.subItems = items + end +end + +function PartyMenu:cancel() + if self.submenu then self.submenu = nil; return end + self.game.stack:pop() + if self.onCancel then self.onCancel() end +end + function PartyMenu:update(dt) -- icon animation counter; 320 = a whole cycle at every HP speed self.blink = ((self.blink or 0) + 1) % 320 @@ -505,245 +898,9 @@ function PartyMenu:update(dt) elseif input:wasPressed("down") then self.subIndex = self.subIndex < n and self.subIndex + 1 or 1 elseif input:wasPressed("b") then - self.submenu = nil + self:cancel() elseif input:wasPressed("a") then - local mon = party[self.index] - local entry = self.subItems[self.subIndex] - local action = entry.action - if not action and entry.onSelect then - -- hook-injected entries carry a callback instead of an action id - entry.onSelect(mon, self.game) - elseif action == "stats" then - -- battle and field alike return to the party list afterwards - -- (core.asm .partyMenuWasSelected) - Screens.push(self.game, "SummaryMenu", mon, { - mons = party, - index = self.index, - onMonChange = function(index) self.index = index end, - }) - elseif action == "battle_switch" then - self.game.stack:pop() - self.onSwitch(mon) - return - elseif action == "cancel" then - self.game.stack:pop() - if self.onCancel then self.onCancel() end - return - elseif action == "switch" then - self.swapFrom = self.index - elseif action == "item" then - self:openItemMenu(mon) - elseif action == "quit" then - self.submenu = nil - elseif action == "fly" then - -- FLY opens the TOWN MAP with a cursor over the visited fly towns, - -- not a plain text list (engine/menus/town_map.asm LoadTownMap_Fly). - -- flyTo (OverworldController) validates the fly-warp + runs the - -- departure/warp, so we just hand it the chosen mapId (#195). - local ow = self.game.overworld - self.game.stack:pop() -- close the party menu - Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId) - if ow then ow:flyTo(mapId) end - end }) - return - elseif action == "flash" then -- FLASH lights dark tunnels - -- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText runs - -- with the party menu still on screen, and only then - -- GBPalWhiteOutWithDelay3 + jp .goBackToMap. So the message reads - -- over the menu, and the cave is lit when the blink hands the - -- screen back, never under the text (#385). - local ow = self.game.overworld - local TextBox = require("src.render.TextBox") - local Transition = require("src.render.Transition") - -- The other half of CheckUseFlash: using it in the Aerodactyl chamber - -- opens the wall. The chamber's scene script plays the opening the - -- next time the map loads, exactly as the escape-rope one does. - require("src.script.RuinsOfAlph").aerodactylChamber(self.game) - self.game.save.flashLit = true - self.game.stack:push(TextBox.new(self.game, - self.game.data.text._FlashLightsAreaText - or Strings("A blinding FLASH\nlights the area!"), function() - self:close() - -- setDark, not a bare field write: ADVANCED carries the darkness - -- in a baked atlas, so lighting the cave drops every resident map - -- and rebakes this one (#383). It runs HERE, before the blink, - -- because start_sub_menus.asm .flash clears wMapPalOffset before - -- PrintText and blinks last of all: the cave is already lit by the - -- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the - -- blink's completion instead left that rebuild's whole cost -- - -- seconds of per-pixel atlas baking on a phone -- on screen as a - -- solid white frame with nothing under it, which reads as a - -- lockup (#610). - ow:setDark(false) - self.game.stack:push(Transition.whiteFlash(self.game)) - end)) - return - elseif action == "surf" then - -- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time - -- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F - -- current refusals, both of which loop back to the submenu), then - -- ItemUseSurfboard: while surfing it tries to dismount instead; - -- otherwise it mounts only if the FACING tile is water, else - -- SurfingAttemptFailed (_NoSurfingHereText) loops back to the - -- submenu. useSurfFieldMove reports which; trySurf does the mount. - local ow = self.game.overworld - local reason = ow:useSurfFieldMove() - local Transition = require("src.render.Transition") - if reason == "ok" then - -- UseItem prints _SurfingGotOnText with the party menu still up; - -- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so - -- trySurf closes this menu when its text does (#385) - local fx, fy = ow.player:facingCell() - ow:trySurf(fx, fy, function() self:close() end) - return - end - if reason == "dismount" then - -- ItemUseSurfboard .stopSurfing: no text -- the walking state - -- and music return first (PlayDefaultMusic + - -- LoadWalkingPlayerSpriteGraphics), the menu closes with the - -- GBPalWhiteOutWithDelay3 blink, and the simulated pad press - -- steps the player forward onto land (or across a connection - -- strip when the shore is the next map's edge) - self.game.stack:pop() - ow.player.surfing = false - require("src.core.Music").setSurfing(self.game.data, false) - self.game.stack:push(Transition.whiteFlash(self.game, nil, function() - ow:stepForwardOrCrossEdge(ow.player.facing) - end)) - return - end - local TextBox = require("src.render.TextBox") - local def = self.game.data.pokemon[mon.species] - local key = ({ no_badge = "_NewBadgeRequiredText", - forced_bike = "_CyclingIsFunText", - current = "_CurrentTooFastText", - no_place = "_SurfingNoPlaceToGetOffText" })[reason] - or "_NoSurfingHereText" - local txt = (self.game.data.text[key] or Strings("No SURFing here!")) - :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) - if reason == "no_place" then - -- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but - -- never zeroes wActionResultOrTookBattleTurn, so unlike the - -- other refusals the menu still closes afterwards - -- (GBPalWhiteOutWithDelay3 + .goBackToMap), and the text prints - -- over the still-open menu like every other .loop refusal (#385) - self.game.stack:push(TextBox.new(self.game, txt, function() - self:close() - self.game.stack:push(Transition.whiteFlash(self.game)) - end)) - return - end - self.game.stack:push(TextBox.new(self.game, txt)) - return -- .loop: submenu stays open behind the message - elseif action == "cut" then - -- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm): - -- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the - -- submenu when the FACING tile isn't a cuttable tree. - local ow = self.game.overworld - local reason = ow:useCutFieldMove() - if reason == "ok" then - self.game.stack:pop() -- close the party menu (CloseTextDisplay) - local fx, fy = ow.player:facingCell() - ow:tryCut(fx, fy) - return - end - local TextBox = require("src.render.TextBox") - local def = self.game.data.pokemon[mon.species] - local key = (reason == "no_badge") and "_NewBadgeRequiredText" - or "_NothingToCutText" - local txt = (self.game.data.text[key] or Strings("Nothing to CUT!")) - :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) - self.game.stack:push(TextBox.new(self.game, txt)) - return -- .loop: submenu stays open behind the message - elseif action == "strength" then - -- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time); - -- predef PrintStrengthText (field_move_messages.asm) sets - -- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate - -- push_boulder.asm reads -- then prints _UseStrengthText (no - -- prompt: after the text, the text_asm tail plays the chosen - -- mon's cry, Delay3, and it auto-advances) and - -- _MoveBoulderText (`prompt`: waits for A/B). Back in - -- .strength, GBPalWhiteOutWithDelay3 blinks the screen white - -- before CloseTextDisplay returns to the map. - local ow = self.game.overworld - local TextBox = require("src.render.TextBox") - local Transition = require("src.render.Transition") - local def = self.game.data.pokemon[mon.species] - local name = mon.nickname or (def and def.name) or mon.species - ow.strengthActive = true - -- Gen2 text keys are _UseStrengthText / _MoveBoulderText; - -- Gen1 uses _UsedStrengthText / _CanMoveBouldersText. Try both. - local t1raw = self.game.data.text._UseStrengthText - or self.game.data.text._UsedStrengthText - local t2raw = self.game.data.text._MoveBoulderText - or self.game.data.text._CanMoveBouldersText - local t1 = (t1raw or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name) - local t2 = (t2raw or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name) - -- like surf (#320, #385): both texts print with the party menu - -- still on screen, and the blink IS the menu closing afterwards, - -- not a flashbang on the empty map - self.game.stack:push(TextBox.new(self.game, t1, function() - self.game.stack:push(TextBox.new(self.game, t2, function() - self:close() - self.game.stack:push(Transition.whiteFlash(self.game)) - end)) - end, { auto = { sound = function() - return require("src.core.Sound").playCry(self.game.data, mon.species) - end } })) - return - elseif action == "gen2_field" then - -- GSC runs the same handler from the menu as from the overworld - -- A press (SurfFromMenuScript / HeadbuttFromMenuScript), so the - -- tile check lives in one place. - local ow = self.game.overworld - local fx, fy = ow.player:facingCell() - self:close() - if not ow:gen2FieldMoveAt(entry.move, mon, fx, fy) then - local TextBox = require("src.render.TextBox") - -- _CantSurfText is "You can't SURF here", so using it for every - -- field move told a player holding HEADBUTT that the game thought - -- they had picked SURF. Only SURF gets the surf line. - local text = (entry.move == "SURF") - and self.game.data.text._CantSurfText or nil - self.game.stack:push(TextBox.new(self.game, - text or Strings("You can't use that\nhere."))) - end - return - elseif action == "softboiled" then - -- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer - -- 1/5 of the user's max HP to a chosen teammate - self.softboiledFrom = self.index - elseif action == "dig" then - -- GEN 2 (.DoDig): back out through the recorded entrance, arriving as - -- a DOOR warp so the player steps out of the opening rather than - -- standing in it. GEN 1's DIG is ItemUseEscapeRope and shares - -- TELEPORT's Pokemon Center warp, so it falls through to the same - -- departure with no target. - local ow = self.game.overworld - self.game.stack:pop() - if ow then - local escape = require("src.core.GameVersion").isGen2() - and ow.escapePoint and ow:escapePoint() and true or nil - ow:beginTeleportOut(nil, { escape = escape }) - end - return - elseif action == "escape" then - -- TELEPORT warps to the last Pokémon Center TOWN (wLastBlackoutMap, - -- special_warps.asm escape warp) -- and Gen 2 agrees: TeleportFunction - -- .TryTeleport requires an OUTDOOR map and warps to wLastSpawnMapGroup - -- / wLastSpawnMapNumber, so this destination is right for both - -- generations. Only DIG diverged; it has its own branch above. - -- pokered's .dig/.teleport spin the - -- player up (LeaveMapAnim), white/fade out, then land it; this port - -- lands OUTSIDE the town PC door like Fly (#196). beginTeleportOut - -- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares - -- the exact departure; the fade + warp fire when the spin ends. - local ow = self.game.overworld - self.game.stack:pop() - if ow then ow:beginTeleportOut() end - return - end - self.submenu = nil + self:confirm() end return end @@ -753,150 +910,9 @@ function PartyMenu:update(dt) elseif input:wasPressed("down") then self.index = self.index < #party and self.index + 1 or 1 elseif input:wasPressed("b") then - self.game.stack:pop() - if self.onCancel then self.onCancel() end + self:cancel() elseif input:wasPressed("a") and #party > 0 then - local mon = party[self.index] - if self.softboiledFrom then - local user = party[self.softboiledFrom] - local heal = math.floor(user.stats.hp / 5) - if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp - or user.hp <= heal then - self.softboiledFrom = nil - local TextBox = require("src.render.TextBox") - self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect."))) - else - user.hp = user.hp - heal - mon.hp = math.min(mon.stats.hp, mon.hp + heal) - self.softboiledFrom = nil - require("src.core.Sound").play(self.game.data, "Heal_HP") - local def = self.game.data.pokemon[mon.species] - local TextBox = require("src.render.TextBox") - self.game.stack:push(TextBox.new(self.game, - Strings("%s's HP\nwas restored!", mon.nickname or def.name))) - end - elseif self.swapFrom then - if self.swapFrom ~= self.index then - party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom] - require("src.core.Sound").play(self.game.data, "Swap") - end - self.swapFrom = nil - elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then - -- keepOpen callers (HP medicine) need the menu still drawn while the - -- bar fills and the message prints, and close it themselves; everyone - -- else keeps the old pop-then-call order. Popping first is what made - -- a POTION snap the picker shut before the item had even run (#252). - if not self.keepOpen then self.game.stack:pop() end - self.onSwitch(mon, self) - else - self.submenu = true - self.subIndex = 1 - local items - local ow = self.game.overworld - if self.battle and self.onSwitch then - -- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun) - items = { { label = Strings("SWITCH"), action = "battle_switch" }, - { label = Strings("STATS"), action = "stats" }, - { label = Strings("CANCEL"), action = "cancel" } } - else - -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm - -- builds the same dynamic list) - items = { { label = Strings("STATS"), action = "stats" }, - { label = Strings("SWITCH"), action = "switch" } } - -- Field moves (HMs/TMs) are usable out of battle even when the mon - -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. - -- Battle still excludes this list via `not self.battle`. Softboiled - -- can appear for a fainted user; its heal transfer then no-ops. - if not self.battle and ow then - -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU -- - -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) - local outside = Map.isOutside(ow.map.def, - FieldDefaults.field(self.game.data, "outsideTilesets")) - -- The badge gate per field move comes from constants.hmBadges and - -- the badge itself through Badges.has: Gen2 gates the same moves on - -- the Johto badges and stores them as engine flags rather than bag - -- items, so the hard-coded R/B item names listed nothing at all. - local gates = FieldDefaults.constant(self.game.data, "hmBadges") or {} - local function badged(moveId) - local gate = gates[moveId] - if not (gate and gate.badge) then return true end - return Badges.has(self.game.save, { id = gate.badge }) - end - for _, mv in ipairs(mon.moves) do - if mv.id == "FLY" and outside and badged("FLY") then - table.insert(items, { label = Strings("FLY"), action = "fly" }) - -- LIGHT is carved on the Aerodactyl chamber wall, and the - -- chamber is not a dark cave -- so CheckUseFlash asks - -- SpecialAerodactylChamber FIRST and lets FLASH be used on its - -- carry, before it ever looks at the palset - -- (engine/events/overworld.asm:285). Without this the move is - -- never even offered there and the wall can never open. - elseif mv.id == "FLASH" and badged("FLASH") - and (ow.dark or (ow.map and ow.map.id - == require("src.script.RuinsOfAlph").MAPS.aerodactyl)) then - table.insert(items, { label = Strings("FLASH"), action = "flash" }) - elseif mv.id == "CUT" and badged("CUT") then - -- CUT/SURF/STRENGTH are party-menu field moves too - -- (start_sub_menus.asm .outOfBattleMovePointers); listed here - -- with the same list-time badge filter this file already uses - -- for FLY/FLASH. The facing-tile/activation check happens on - -- selection (useCutFieldMove/useSurfFieldMove). - table.insert(items, { label = Strings("CUT"), action = "cut" }) - elseif mv.id == "SURF" and badged("SURF") then - table.insert(items, { label = Strings("SURF"), action = "surf" }) - elseif mv.id == "STRENGTH" and badged("STRENGTH") then - table.insert(items, { label = Strings("STRENGTH"), action = "strength" }) - elseif mv.id == "SOFTBOILED" or mv.id == "MILK_DRINK" then - table.insert(items, { label = Strings(mv.id:gsub("_", " ")), - action = "softboiled" }) - elseif mv.id == "TELEPORT" and outside then - -- TELEPORT works only OUTDOORS (start_sub_menus.asm - -- .teleport -> CheckIfInOutsideMap); dark maps don't - -- block it - table.insert(items, { label = Strings("TELEPORT"), action = "escape" }) - elseif mv.id == "DIG" and ow.map.id ~= "AGATHAS_ROOM" - and (DIG_TILESETS[ow.map.def.tileset] - or (GEN2_DIG_ENVIRONMENTS[ow.map.def.environment] - and ow.escapePoint and ow:escapePoint())) then - -- GEN 1: DIG runs ItemUseEscapeRope (.dig sets wCurItem = - -- ESCAPE_ROPE): usable in the dungeon tilesets of - -- escape_rope_tilesets.asm minus Agatha's room, even in - -- the dark (Rock Tunnel). - -- GEN 2: .CheckCanDig instead -- CAVE or DUNGEON, and a - -- recorded entrance to come back out of. Its own action, - -- because Gen 2's DIG and TELEPORT no longer share a - -- destination the way Gen 1's did. - table.insert(items, { label = Strings("DIG"), action = "dig" }) - elseif GEN2_FIELD_MOVES[mv.id] and badged(mv.id) then - -- GSC's own field moves; the tile check happens on selection, - -- exactly like CUT's (start_sub_menus.asm never gates the list - -- on where the player is standing) - table.insert(items, { label = Strings(mv.id:gsub("_", " ")), - action = "gen2_field", move = mv.id }) - end - end - end - if require("src.core.GameVersion").isGen2() then - -- GSC's MonMenu lists the field moves first, then STATS, SWITCH, - -- ITEM and QUIT; Gen 1 has no held items and no QUIT row. - local stats, switch = table.remove(items, 1), table.remove(items, 1) - items[#items + 1] = stats - items[#items + 1] = switch - items[#items + 1] = { label = Strings("ITEM"), action = "item" } - items[#items + 1] = { label = Strings("QUIT"), action = "quit" } - end - end - local ctx = { battle = self.battle, overworld = ow } - local hooked = Runtime.call("ui.party.submenu", sameItems, - self.game, items, mon, ctx) - if type(hooked) == "table" then - items = hooked - else - Logger.error("ui.party.submenu returned %s; keeping the vanilla list", - type(hooked)) - end - self.subItems = items - end + self:confirm() end end diff --git a/src/ui/UICommands.lua b/src/ui/UICommands.lua new file mode 100644 index 000000000..437932a88 --- /dev/null +++ b/src/ui/UICommands.lua @@ -0,0 +1,346 @@ +-- Admission and dispatch for native party, bag and dialogue screens. +-- No screen is loaded here: an already loaded native class must own the top. +local Commands = {} +local CLASSES = { + PartyMenu = "party_gb", + Gen3PartyMenu = "party_gba", + Gen4PartyMenu = "party_ds", + Gen3BagMenu = "bag_gba", + Gen4BagMenu = "bag_ds", + HgssBagMenu = "bag_hgss", + Gen5BagMenu = "bag_bw", + Gen4BattleBag = "battle_bag", + Gen5BattleBag = "battle_bag", + Gen3ItemMenu = "item_actions", + ChoiceBox = "choice", +} +local function class(screen) + local mt = getmetatable(screen) + if mt == nil then + return + end + if mt == package.loaded["src.render.TextBox"] then + return "text" + end + for name, kind in pairs(CLASSES) do + if mt == package.loaded["src.ui." .. name] then + return kind + end + end + if mt == package.loaded["src.ui.ListMenu"] and screen.publicUIKind == "bag" then + return "bag_gb" + end + if mt == package.loaded["src.ui.Menu"] and screen.publicUIKind == "bag_actions" then + return "bag_actions" + end +end +local function row(out, value, ids, item) + local i = #out.rows + 1 + local r = { index = i, enabled = true } + if type(value) == "table" then + r.label = type(value.label) == "string" and value.label or nil + r.action = type(value.action) == "string" and value.action or nil + if value.header then + r.enabled = false + end + if value.close then + r.action = "back" + end + if item then + r.item = ids:read("items", value.id or value.value) + r.count = type(value.qty) == "number" and value.qty or nil + if not r.label and type(value.def) == "table" and type(value.def.name) == "string" then + r.label = value.def.name + end + end + elseif type(value) == "string" then + r.action, r.label = value, value + end + out.rows[i] = r + return r +end +function Commands.describe(game, screen, ids) + local kind = class(screen) + if not kind then + return nil, "unsupported UI screen" + end + if screen.game ~= game then + return nil, "UI does not belong to current Game" + end + local out = { + kind = "party", + rows = {}, + index = screen.index, + canBack = true, + canAdvance = false, + canPocket = false, + locked = false, + } + local refs = { screen = screen, stack = game.stack, states = game.stack.states, save = game.save } + -- Keep callback and nested option identity private; no engine references escape. + for key, value in pairs(screen) do + if type(value) == "function" or type(value) == "table" then + refs[key] = value + end + end + local function rows(values, items) + for i, value in ipairs(values or {}) do + refs["row:" .. i] = value + if type(value) == "table" then + refs["callback:" .. i] = value.onSelect + end + row(out, value, ids, items) + end + end + if kind:match("^party_") then + local party = kind == "party_gb" and (screen.party or game.save.party) or screen:party() + for i, mon in ipairs(party or {}) do + refs["mon:" .. i] = mon + end + if screen.selectNotice then + out.kind, out.canAdvance, out.canBack = "text", true, false + elseif screen.submenu then + out.kind = "party_actions" + rows( + kind == "party_gb" and screen.subItems or kind == "party_gba" and screen.submenu or screen:actions() + ) + out.index = kind == "party_ds" and screen.submenu or screen.subIndex + else + for i, mon in ipairs(party or {}) do + refs["mon:" .. i] = mon + local r = row(out, {}, ids) + r.partyIndex, r.species, r.label = i, ids:read("pokemon", mon.species), mon.nickname + r.hp, r.maxHp = mon.hp, mon.stats and mon.stats.hp or mon.maxHp + end + if kind ~= "party_gb" then + if kind == "party_ds" and screen.select then + row(out, "confirm", ids) + end + row(out, "back", ids) + end + end + elseif kind == "text" then + out.kind, out.index, out.canBack = "text", nil, false + out.canAdvance = ( + screen.waiting or (screen.done and not screen.auto and not screen.stay and not screen.choice) + ) + and (screen.preWait or 0) <= 0 + and (screen.holdFrames or 0) <= 0 + or false + out.locked = not out.canAdvance + elseif kind == "choice" then + out.kind = "choice" + rows({ "yes", "no" }) + out.locked = screen.pending ~= nil + elseif kind == "battle_bag" then + out.kind, out.page = "bag", screen.screen + local mem = screen.mem or screen.cur + out.pocket = screen.pocket + out.index = screen.cursor and screen.cursor.index + if screen.message then + out.kind, out.canAdvance, out.canBack = "text", true, false + elseif screen.screen == "menu" then + rows({ "pocket:1", "pocket:2", "pocket:3", "pocket:4", "last_item", "back" }) + out.rows[5].enabled = mem.lastItem ~= nil + elseif screen.screen == "pocket" then + for i = 1, 6 do + local item = screen:itemAt(i) + refs["row:" .. i] = item + local r = row(out, item or {}, ids, true) + r.enabled = item ~= nil + end + row(out, "previous_page", ids).enabled = (screen.pageCount[screen.pocket] or 0) > 0 + row(out, "next_page", ids).enabled = (screen.pageCount[screen.pocket] or 0) > 0 + row(out, "back", ids) + else + rows({ "use", "back" }) + out.rows[1].enabled = screen:itemAt((mem.positions[screen.pocket] or 0) + 1) ~= nil + end + out.locked = screen.press ~= nil + elseif kind == "bag_actions" or kind == "item_actions" then + out.kind = "bag_actions" + rows(kind == "bag_actions" and screen.items or screen.entries) + if kind == "item_actions" then + for i, r in ipairs(out.rows) do + r.enabled = screen:pickable(i) + r.action = screen.entries[i].kind + end + else + out.canBack = screen.cancelable + end + else + out.kind = "bag" + out.pocket = screen.pocket or screen.pocketIndex + if screen.message or screen.resultMessage then + out.kind, out.canAdvance, out.canBack = "text", true, false + elseif screen.menu then + out.kind, out.index = screen.menu.kind == "yesno" and "choice" or "bag_actions", screen.menu.index + rows(screen.menu.rows) + else + rows(kind == "bag_gb" and screen.items or kind == "bag_hgss" and screen:items() or screen.rows, true) + out.index = kind == "bag_hgss" and screen.page * 6 + screen.slot + or kind == "bag_bw" and screen.cursor + or screen.index + out.canPocket = not screen.lockPocket + and (kind == "bag_gb" and screen.onPocketSwitch ~= nil or kind ~= "bag_gb" and #(screen.pockets or {}) > 1) + or false + end + out.locked = screen.trash ~= nil and not screen.menu + or screen.moving ~= nil and screen.moving ~= false + or false + end + local battle = screen.battle or screen.publicUIBattle + if + battle and (battle.demo or battle.dudeDemo or battle.pokedudeDemo or battle.spectating or battle.dead) + then + out.locked = true + end + if screen.heal or screen.noInput or screen.script or screen.closed then + out.locked = true + end + if out.locked then + out.canAdvance, out.canPocket, out.canBack = false, false, false + end + return out, refs, kind +end +local function press(screen, ds) + local Sound = require("src.core.Sound") + if ds then + if Sound.playSe and screen.game.data then + Sound.playSe(screen.game.data, "SEQ_SE_DP_SELECT") + end + elseif screen.game.data then + Sound.play(screen.game.data, "Press_AB") + end +end +function Commands.dispatch(screen, kind, view, intent) + if intent.kind == "advance" then + if kind == "text" then + screen:advance() + elseif screen.selectNotice then + screen.selectNotice = nil + else + if screen.resultMessage then + press(screen) + end + screen.message, screen.resultMessage = nil, nil + end + elseif kind == "text" then + error("text only supports advance") + elseif kind == "choice" then + screen:choose(intent.kind == "back" and 2 or intent.index) + elseif kind == "battle_bag" then + if intent.kind == "select" then + screen.cursor.index = intent.index + end + screen:pick(intent.kind == "back" and ({ menu = 6, pocket = 9, use = 2 })[screen.screen] or intent.index) + elseif kind == "party_gb" then + press(screen) + if intent.kind == "back" then + screen:cancel() + else + if screen.submenu then + screen.subIndex = intent.index + else + screen.index = intent.index + end + screen:confirm() + end + elseif kind == "party_gba" or kind == "party_ds" then + if intent.kind == "back" then + if screen.submenu then + if screen.itemMenu then + screen.itemMenu, screen.submenu = nil, 1 + else + screen.submenu = nil + end + else + screen:cancel() + end + elseif screen.submenu then + if kind == "party_ds" then + screen.submenu = intent.index + screen:runAction(screen:actions()[intent.index]) + else + screen.subIndex = intent.index + local r = screen.submenu[intent.index] + screen:runAction(r.action, screen:party()[screen.index], r) + end + else + screen.index = intent.index + if kind == "party_gba" then + screen:confirm() + else + screen:choose() + end + end + elseif kind == "bag_actions" then + if intent.kind == "back" then + screen:cancel() + else + screen.index = intent.index + screen:choose() + end + elseif kind == "item_actions" then + press(screen) + if intent.kind == "select" then + screen.index = intent.index + end + screen:close(intent.kind == "back" and "cancel" or screen.entries[intent.index].kind) + elseif screen.menu then + if intent.kind == "back" then + if kind == "bag_ds" then + press(screen, true) + end + if screen.menu.kind == "yesno" then + screen:resolveTrash(false) + else + screen.menu = nil + end + else + screen.menu.index = intent.index + if kind ~= "bag_ds" then + screen:runMenuRow() + else + press(screen, true) + if screen.menu.kind == "yesno" then + screen:resolveTrash(intent.index == 1) + else + screen:runAction(screen.menu.rows[intent.index]) + end + end + end + elseif intent.kind == "pocket" then + if kind == "bag_gb" then + screen.onPocketSwitch(screen, intent.direction) + elseif kind == "bag_hgss" or kind == "bag_bw" then + screen:setPocket((screen.pocket - 1 + intent.direction) % #screen.pockets + 1) + else + screen.pocketLit = nil + screen:movePocket(intent.direction) + end + elseif intent.kind == "back" then + if kind == "bag_gb" then + screen:cancel() + else + if kind ~= "bag_gba" then + press(screen, true) + end + screen:close() + end + else + if kind == "bag_hgss" then + screen.page, screen.slot = math.floor((intent.index - 1) / 6), (intent.index - 1) % 6 + 1 + elseif kind == "bag_bw" then + screen.cursor = intent.index + screen.top = math.max(0, intent.index - 6) + else + screen.index = intent.index + if kind == "bag_ds" then + screen:clampScroll() + end + end + screen:choose() + end +end +return Commands diff --git a/tests/engine/ui_actions.lua b/tests/engine/ui_actions.lua new file mode 100644 index 000000000..8103d644f --- /dev/null +++ b/tests/engine/ui_actions.lua @@ -0,0 +1,792 @@ +-- Real native screens, Loader facades and ROM-free generation fixtures. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Loader = require("src.mods.Loader") +local GameVersion = require("src.core.GameVersion") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") +local Json = require("src.link.Json") +local Intents = require("src.mods.BattleIntents") +local original = GameVersion.get() +local versions = GameVersion.ORDER +local function boot(version, apiLevel) + GameVersion.set(version) + local data = T.fixtures.fresh() + local files = {} + for _, id in ipairs({ "probe", "other" }) do + files["mods/" .. id .. "/manifest.json"] = + Json.encode({ id = id, name = id, version = "1.0.0", api = apiLevel or 2, entry = "main.lua" }) + files["mods/" .. id .. "/main.lua"] = [[return function(mod) + assert(mod.ui:snapshot()==nil) + assert(type(mod.ui.insertBefore)=="function") + if mod.supports then + assert(mod:supports("ui", "snapshot")) + assert(mod:supports("ui", "submit")) + assert(not mod:supports("ui", "unknown")) + end + end]] + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe", "other" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" or p == "mods/other" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + T.check(loader:load(data), "fixture mods load") + local game = { data = data, stack = { states = {} }, save = SaveData.newGame() } + loader.game = game + Font.load(data) + game.save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) } + game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + function game.stack:top() + return self.states[#self.states] + end + function game.stack:push(state) + self.states[#self.states + 1] = state + end + function game.stack:pop() + return table.remove(self.states) + end + return loader.apis.probe.ui, game, loader.apis.other.ui, loader +end +local function button(game, screen, name) + local old = game.input + game.input = { + wasPressed = function(_, key) + return key == name + end, + isDown = function() + return false + end, + } + screen:update(1 / 60) + game.input = old +end +local function submit(ui, id, kind, fields) + local snap, err = ui:snapshot() + assert(snap, err) + local intent = { id = id, revision = snap.revision, kind = kind } + for k, v in pairs(fields or {}) do + intent[k] = v + end + return ui:submit(intent) +end +local partyClass = { + red = "PartyMenu", + crystal = "PartyMenu", + emerald = "Gen3PartyMenu", + platinum = "Gen4PartyMenu", + heartgold = "Gen4PartyMenu", + black = "Gen4PartyMenu", +} +local bagClass = { + red = "BagMenu", + crystal = "BagMenu", + emerald = "Gen3BagMenu", + platinum = "Gen4BagMenu", + heartgold = "HgssBagMenu", + black = "Gen5BagMenu", +} +for _, version in ipairs(versions) do + local gen = GameVersion.VERSIONS[version].generation + partyClass[version] = partyClass[version] + or (gen <= 2 and "PartyMenu" or gen == 3 and "Gen3PartyMenu" or "Gen4PartyMenu") + bagClass[version] = bagClass[version] + or ( + gen <= 2 and "BagMenu" + or gen == 3 and "Gen3BagMenu" + or version == "soulsilver" and "HgssBagMenu" + or gen == 5 and "Gen5BagMenu" + or "Gen4BagMenu" + ) + local ui, game, other = boot(version) + local chosen, cancelled = 0, 0 + local screen = require("src.ui." .. partyClass[version]).new(game, { + pickOnly = true, + onSwitch = function(mon) + T.eq(mon, game.save.party[2], "native picker target") + chosen = chosen + 1 + end, + onCancel = function() + cancelled = cancelled + 1 + end, + }) + game.stack:push(screen) + local snap = assert(ui:snapshot()) + T.eq(snap.kind, "party", version .. " party recognized") + T.eq(snap.rows[2].species, "FIXMON_B", "portable party ID") + T.eq(ui:snapshot().revision, snap.revision, "unchanged read retains revision") + snap.rows[1].hp = -999 + T.neq(game.save.party[1].hp, -999, "snapshot detached") + local stale = other:snapshot() + T.check(submit(ui, 1, "select", { index = 2 }), "native party selection accepted") + T.eq(chosen, 1, "picker callback once") + T.eq(#game.stack.states, 0, "picker popped") + T.eq( + other:submit({ id = 1, revision = stale.revision, kind = "back" }), + nil, + "other facade cannot use departed picker" + ) + game.stack:push(require("src.ui." .. partyClass[version]).new(game, { + onCancel = function() + cancelled = cancelled + 1 + end, + })) + T.check(submit(ui, 2, "back"), "native party back") + T.eq(cancelled, 1, "onCancel delivered") + local bag = require("src.ui." .. bagClass[version]).new(game, {}) + game.stack:push(bag) + local bagSnap = assert(ui:snapshot()) + T.eq(bagSnap.kind, "bag", version .. " bag recognized") + if bagSnap.canPocket then + T.check(submit(ui, 3, "pocket", { direction = 1 }), "native pocket switch") + end + T.check(submit(ui, 4, "back"), "native bag back") + T.eq(#game.stack.states, 0, "bag popped") +end + +-- Walk raw entries, preserving identity and cycles, to detect any gameplay +-- writes from observations and rejected requests (T.same is not cycle-safe). +local function freeze(root) + local graph = {} + local function visit(t) + if type(t) ~= "table" or graph[t] then + return + end + local entry = { mt = getmetatable(t), values = {} } + graph[t] = entry + for k, v in pairs(t) do + entry.values[k] = v + visit(k) + visit(v) + end + end + visit(root) + return function(label) + local same = true + for t, entry in pairs(graph) do + same = same and getmetatable(t) == entry.mt + for k, v in pairs(entry.values) do + same = same and rawget(t, k) == v + end + for k, v in pairs(t) do + same = same and entry.values[k] == v + end + end + T.check(same, label) + end +end +local function refusal(ui, game, intent, message) + local unchanged = freeze(game) + local ok, err = ui:submit(intent) + T.eq(ok, nil, message) + T.check(type(err) == "string", message .. " explains refusal") + unchanged(message .. " leaves game untouched") + return err +end +for _, version in ipairs(versions) do + -- Compare native A/B to the facade for each party submenu and its back path. + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + local party = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack:push(party) + local unchanged = freeze(game) + local snap = ui:snapshot() + unchanged("party snapshot is observational") + T.check(snap ~= nil, "native screen available") + if native then + button(game, party, "a") + else + T.check(submit(ui, 1, "select", { index = 1 }), "open party submenu") + end + local stableSubmenu = freeze(game) + snap = ui:snapshot() + stableSubmenu("submenu snapshot remains observational") + T.eq(snap.kind, "party_actions", "party action rows offered") + local swap + for i, r in ipairs(snap.rows) do + if r.action == "switch" or r.action == "switchOrder" then + swap = i + end + end + T.check(swap ~= nil, "native swap row exposed") + if native then + if GameVersion.VERSIONS[version].generation <= 3 then + party.subIndex = swap + else + party.submenu = swap + end + button(game, party, "a") + party.index = 2 + button(game, party, "a") + else + T.check(submit(ui, 2, "select", { index = swap }), "start swap") + T.check(submit(ui, 3, "select", { index = 2 }), "finish swap") + end + traces[#traces + 1] = { + first = game.save.party[1].species, + second = game.save.party[2].species, + depth = #game.stack.states, + index = party.index, + swapFrom = party.swapFrom, + switchFrom = party.switchFrom, + } + if native then + button(game, party, "b") + else + T.check(submit(ui, 4, "back"), "back after swap") + end + T.eq(#game.stack.states, 0, "native party cancellation") + end + T.same(traces[1], traces[2], version .. " swap matches native A/B") + + local ui, game, other, loader = boot(version) + local party = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack:push(party) + local snap = ui:snapshot() + for _, bad in ipairs({ + false, + 1, + "select", + {}, + { id = 1, revision = snap.revision, kind = "unknown" }, + { id = 1, revision = snap.revision, kind = "select", index = 0 }, + { id = 1, revision = snap.revision, kind = "select", index = 1.5 }, + { id = math.huge, revision = snap.revision, kind = "back" }, + { id = 1, revision = snap.revision, kind = "back", extra = true }, + setmetatable({ id = 1, revision = snap.revision, kind = "back" }, {}), + }) do + refusal(ui, game, bad, "invalid intent") + end + refusal(ui, game, { id = 1, revision = snap.revision, kind = "select", index = 999 }, "missing row") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "advance" }, "party cannot advance text") + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "pocket", direction = 1 }, + "party has no pockets" + ) + game.save.party[1].hp = game.save.party[1].hp - 1 + T.eq( + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "HP change stales snapshot"), + "stale UI context", + "stale reason" + ) + snap = ui:snapshot() + local replacement = {} + for k, v in pairs(game.save.party[1]) do + replacement[k] = v + end + game.save.party[1] = replacement + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "back" }, + "identical replacement mon stales snapshot" + ) + -- Different screen identity, even with identical values. + snap = ui:snapshot() + local replacementScreen = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack.states[1] = replacementScreen + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "replacement screen stales snapshot") + party = replacementScreen + -- Whitelist native ownership; never infer an unrelated menu from its rows. + game.stack:push({ game = game, kind = "party", items = { { label = "fake" } } }) + T.eq(ui:snapshot(), nil, "unrelated overlay unsupported") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "covered native menu refused") + game.stack:pop() + for _, flag in ipairs({ "heal", "noInput", "script", "closed" }) do + party[flag] = true + snap = ui:snapshot() + T.eq(snap.locked, true, flag .. " locks public UI") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, flag .. " blocks action") + party[flag] = nil + end + -- Shared battle/UI guard, cross-mod epoch, consumed IDs and partial failures. + snap = ui:snapshot() + Intents.state(game).busy = true + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "in-progress battle/UI work blocked") + T.eq(ui:snapshot().locked, true, "busy snapshot locked") + Intents.state(game).busy = false + snap = ui:snapshot() + Intents.state(game).epoch = Intents.state(game).epoch + 1 + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "battle work expires UI snapshot") + local calls, during, observed = 0 + party.onCancel = function() + calls = calls + 1 + game.stack:push(party) -- keep an identical screen for the epoch test + during = other:snapshot() + observed = other:submit({ id = 9, revision = during.revision, kind = "back" }) + error("native callback failure") + end + local otherSnap = other:snapshot() + snap = ui:snapshot() + Runtime.currentMod = "probe" + local ok, err = ui:submit({ id = 10, revision = snap.revision, kind = "back" }) + T.eq(ok, nil, "partial native failure reported") + T.check(err:find("intent consumed", 1, true) ~= nil, "partial failure consumes id") + T.eq(Runtime.currentMod, "probe", "engine attribution restored after error") + Runtime.currentMod = nil + T.eq(calls, 1, "callback invoked once") + T.eq(observed, nil, "callback cannot submit reentrantly") + T.eq(during.locked, true, "snapshot during callback is locked") + refusal(other, game, { id = 9, revision = otherSnap.revision, kind = "back" }, "other mod snapshot expired") + refusal(other, game, { id = 9, revision = during.revision, kind = "back" }, "mid-callback snapshot expired") + refusal( + ui, + game, + { id = 10, revision = ui:snapshot().revision, kind = "back" }, + "failed native action cannot replay" + ) + game.stack.states = { require("src.ui." .. partyClass[version]).new(game, {}) } + T.check(submit(ui, 11, "back"), "guard released after failure") + T.eq(loader.apis.probe.ui.insertBefore, require("src.ui.ModUI").insertBefore, "builder helpers retained") + T.neq(ui, other, "per-mod facade identity") +end + +local TextBox = require("src.render.TextBox") +local ChoiceBox = require("src.ui.ChoiceBox") +local function tickUntil(game, box, predicate) + for _ = 1, 1000 do + if predicate() then + return + end + box:update(1 / 60) + end + error("native UI did not reach expected state") +end +for _, version in ipairs(versions) do + local ui, game = boot(version) + local done = 0 + local box = TextBox.new(game, "First\fSecond", function() + done = done + 1 + end) + game.stack:push(box) + local snap = ui:snapshot() + T.eq(snap.kind, "text", "text recognized") + T.eq(snap.locked, true, "typing text locked") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "advance" }, "typing cannot be skipped") + tickUntil(game, box, function() + return box.waiting and (box.preWait or 0) <= 0 + end) + T.check(submit(ui, 1, "advance"), "manual page advances") + T.check((box.holdFrames or 0) > 0, "page clear hold preserved") + snap = ui:snapshot() + refusal(ui, game, { id = 2, revision = snap.revision, kind = "advance" }, "page hold cannot be skipped") + tickUntil(game, box, function() + return box.done + end) + T.check(submit(ui, 2, "advance"), "final text closes") + T.eq(done, 1, "text callback once") + T.eq(game.stack:top(), nil, "text popped") + for _, opts in ipairs({ { auto = {} }, { stay = {} }, { choice = function() end } }) do + box = TextBox.new(game, "Ready", nil, opts) + game.stack:push(box) + box.done = true + snap = ui:snapshot() + T.eq(snap.canAdvance, false, "automatic/stay/choice text not manually dismissed") + refusal(ui, game, { id = 3, revision = snap.revision, kind = "advance" }, "script-owned text protected") + game.stack:pop() + end + -- Native text -> choice -> delayed answer -> both boxes popped. + local answers = {} + box = TextBox.new(game, "Question?", nil, { + defaultNo = true, + choice = function(yes) + answers[#answers + 1] = yes + end, + }) + game.stack:push(box) + tickUntil(game, box, function() + return box.done + end) + box:update(1 / 60) + local choice = game.stack:top() + T.eq(getmetatable(choice), ChoiceBox, "native choice appears") + T.eq(ui:snapshot().index, 2, "default NO retained") + T.check(submit(ui, 4, "select", { index = 1 }), "YES accepted") + T.eq(#answers, 0, "answer callback waits for native hold") + T.eq(#game.stack.states, 2, "both boxes retained during answer hold") + snap = ui:snapshot() + refusal(ui, game, { id = 5, revision = snap.revision, kind = "back" }, "pending answer locked") + tickUntil(game, choice, function() + return #answers == 1 + end) + T.eq(answers[1], true, "YES delivered") + T.eq(#game.stack.states, 0, "choice closes original text too") + choice = ChoiceBox.new(game, function(yes) + answers[#answers + 1] = yes + end) + game.stack:push(choice) + T.check(submit(ui, 5, "back"), "B selects NO") + T.eq(choice.index, 2, "NO cursor retained while pending") + tickUntil(game, choice, function() + return #answers == 2 + end) + T.eq(answers[2], false, "NO delivered") +end + +-- Pick an actual item through each native bag. API and physical A/B must +-- deliver the same callback and row, including HGSS pages and BW cursors. +for _, version in ipairs(versions) do + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + game.data.items.POTION = { + id = "POTION", + name = "POTION", + pocket = "ITEM", + fieldPocket = 0, + bwPocket = 0, + fieldUse = 1, + fieldUseFunc = 1, + battlePocket = 4, + } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local picked = {} + local bag = require("src.ui." .. bagClass[version]).new(game, { + pick = true, + onPick = function(id) + picked[#picked + 1] = id + end, + }) + game.stack:push(bag) + local unchanged = freeze(game) + local snap = ui:snapshot() + unchanged("bag snapshot does not rebuild, sort or load graphics") + local itemRow + for i, row in ipairs(snap.rows) do + if row.item == "POTION" then + itemRow = i + end + end + T.check(itemRow ~= nil, version .. " item exposed") + if native then + if bagClass[version] == "HgssBagMenu" then + bag.page, bag.slot = math.floor((itemRow - 1) / 6), (itemRow - 1) % 6 + 1 + elseif bagClass[version] == "Gen5BagMenu" then + bag.cursor = itemRow + else + bag.index = itemRow + if bag.clampScroll then + bag:clampScroll() + end + end + button(game, bag, "a") + else + T.check(submit(ui, 1, "select", { index = itemRow }), "native bag selection accepted") + end + -- GB's bag has no generic pick option: it opens USE/GIVE/TOSS instead. + local top = game.stack:top() + if bagClass[version] == "BagMenu" then + T.eq(ui:snapshot().kind, "bag_actions", "only factory-owned bag action menus offered") + if native then + button(game, top, "b") + else + T.check(submit(ui, 2, "back"), "back from native item submenu") + end + else + T.same(picked, { "POTION" }, "native pick callback exactly once") + end + traces[#traces + 1] = { picked = picked, count = game.save.inventory.POTION, depth = #game.stack.states } + end + T.same(traces[1], traces[2], version .. " bag confirm matches native input") +end +-- Real medicine effect, target picker and HP animation through the public +-- boundary. The native flow owns consumption and its delayed completion. +for _, version in ipairs({ "red", "crystal", "emerald" }) do + local ui, game = boot(version) + game.data.items.POTION = { id = "POTION", name = "POTION", pocket = "ITEM" } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local mon = game.save.party[1] + mon.hp = mon.stats.hp - 25 + local before = mon.hp + local bag = require("src.ui." .. bagClass[version]).new(game, {}) + game.stack:push(bag) + local snap = ui:snapshot() + local slot + for i, r in ipairs(snap.rows) do + if r.item == "POTION" then + slot = i + end + end + T.check(submit(ui, 1, "select", { index = slot }), "select medicine") + snap = ui:snapshot() + if snap.kind == "bag_actions" then + local use + for i, r in ipairs(snap.rows) do + if r.action == "use" or r.label == "USE" then + use = i + end + end + T.check(submit(ui, 2, "select", { index = use }), "native USE action") + end + local picker = game.stack:top() + T.eq(ui:snapshot().kind, "party", "medicine opens native target picker") + T.check(submit(ui, 3, "select", { index = 1 }), "select medicine target") + T.eq(mon.hp, before + 20, "real native potion restores 20 HP") + T.eq(game.save.inventory.POTION, 1, "real native potion consumes once") + T.eq(picker.heal ~= nil, true, "native heal animation retained") + snap = ui:snapshot() + refusal( + ui, + game, + { id = 4, revision = snap.revision, kind = "select", index = 1 }, + "cannot use again during HP fill" + ) + tickUntil(game, picker, function() + return picker.heal == nil + end) + local box = game.stack:top() + T.eq(getmetatable(box), TextBox, "native result text appears after fill") + tickUntil(game, box, function() + return box.done + end) + T.check(submit(ui, 4, "advance"), "native medicine result dismissed") + T.eq(game.save.inventory.POTION, 1, "result dismissal cannot consume again") +end +-- The GBA action grid keeps its blank cells and script input lock. +do + local ui, game = boot("emerald") + local chosen + local menu = require("src.ui.Gen3ItemMenu").new(game, { + entries = { + { label = "USE", kind = "use" }, + { label = "", kind = "blank" }, + { label = "CANCEL", kind = "cancel" }, + }, + onPick = function(kind) + chosen = kind + end, + }) + game.stack:push(menu) + local snap = ui:snapshot() + T.eq(snap.rows[2].enabled, false, "blank grid cell disabled") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "select", index = 2 }, "blank cell refused") + T.check(submit(ui, 1, "select", { index = 1 }), "native item grid confirm") + T.eq(chosen, "use", "native item action callback") +end +-- DS battle bags admit their native delayed press, never call its callback +-- early. Page slots, absent items and return buttons preserve native indices. +for _, version in ipairs({ "platinum", "heartgold", "black", "white" }) do + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + game.data.items.POTION = + { id = "POTION", name = "POTION", fieldPocket = 0, battlePocket = 4, battleMask = 4 } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local ds = GameVersion.VERSIONS[version].generation == 5 and "Gen5BattleBag" or "Gen4BattleBag" + local bag = require("src.ui." .. ds).new(game, {}) + game.gen4CursorActive = true + game.stack:push(bag) + local snap = ui:snapshot() + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "select", index = 5 }, + "unavailable last-used row" + ) + local function select(id, index) + if native then + bag.cursor.index = index + button(game, bag, "a") + else + T.check(submit(ui, id, "select", { index = index }), "battle bag native press admitted") + end + T.check(bag.press ~= nil, "native press pending") + T.eq(ui:snapshot().locked, true, "pending press locks UI") + tickUntil(game, bag, function() + return bag.press == nil + end) + end + select(1, 1) + T.eq(bag.screen, "pocket", "native pocket opens after animation") + snap = ui:snapshot() + T.eq(snap.rows[1].item, "POTION", "native battle item row") + refusal( + ui, + game, + { id = 2, revision = snap.revision, kind = "select", index = 6 }, + "empty item slot refused" + ) + select(2, 1) + T.eq(bag.screen, "use", "native use question") + select(3, 2) + T.eq(bag.screen, "pocket", "use back returns to pocket") + select(4, 9) + T.eq(bag.screen, "menu", "pocket back returns to categories") + if native then + button(game, bag, "b") + else + T.check(submit(ui, 5, "back"), "battle bag cancel") + end + T.check(bag.press ~= nil, "cancel animation pending") + tickUntil(game, bag, function() + return bag.press == nil + end) + traces[#traces + 1] = { + depth = #game.stack.states, + count = game.save.inventory.POTION, + page = bag.screen, + closed = bag.closed, + } + end + T.same(traces[1], traces[2], version .. " battle bag matches native A/B") +end + +-- opts.auto/stay/choice govern the final page, not the manual paragraph +-- breaks before it. Do not strand a question that spans several pages. +for _, opts in ipairs({ { auto = {} }, { stay = {} }, { choice = function() end } }) do + local ui, game = boot("crystal") + local box = TextBox.new(game, "First\fLast", nil, opts) + game.stack:push(box) + tickUntil(game, box, function() + return box.waiting and (box.preWait or 0) <= 0 + end) + T.eq(ui:snapshot().canAdvance, true, "script tail still allows manual intermediate page") + T.check(submit(ui, 1, "advance"), "intermediate question/automatic/stay page advances") + tickUntil(game, box, function() + return box.done + end) + T.eq(ui:snapshot().canAdvance, false, "final script-owned page stays protected") +end +-- Wrong Game, unknown overlays, script-owned bags and pending controllers. +do + local ui, game = boot("platinum") + local bag = require("src.ui.Gen4BagMenu").new(game, {}) + game.stack:push(bag) + for _, key in ipairs({ "moving", "trash", "script", "noInput" }) do + bag[key] = true + local snap = ui:snapshot() + T.eq(snap.locked, true, "busy bag locked") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "busy bag refusal") + bag[key] = nil + end + bag.battle = { demo = true } + local snap = ui:snapshot() + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "battle tutorial owns bag") + bag.battle = nil + bag.game = {} + T.eq(ui:snapshot(), nil, "wrong game screen unavailable") +end +-- Existing helpers remain usable with their original dot-call contract. +do + local ui, _, other = boot("red") + local rows = { { label = "a" }, { label = "c" } } + ui.insertBefore(rows, "c", { label = "b" }) + T.eq(rows[2].label, "b", "legacy builder still works") + T.eq(rawget(other, "snapshot") ~= nil, true, "each mod owns its facade methods") +end + +-- API 1 gets the same additive surface without changing its content format. +for _, version in ipairs({ "red", "emerald", "black" }) do + local ui, game, _, loader = boot(version, 1) + game.stack:push(ChoiceBox.new(game, function() end)) + T.eq(ui:snapshot().kind, "choice", "API 1 UI reader") + T.check(submit(ui, 1, "back"), "API 1 UI action") + if loader.apis.probe.supports then + T.eq(loader.apis.probe:supports("ui", "snapshot"), true, "optional reader capability") + T.eq(loader.apis.probe:supports("ui", "submit"), true, "optional action capability") + T.eq(loader.apis.probe:supports("ui", "unknown"), false, "unknown capability declined") + end +end +-- A real battle opens its picker via mod.battle:submit. Targeting the picker +-- consumes the current UI context, then native callbacks resume the battle. +do + local ui, game, _, loader = boot("crystal") + local BattleState = require("src.battle.BattleState") + local b = BattleState.newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase, b.queue, b.menuIndex = "menu", {}, 1 + game.stack:push(b) + local api = loader.apis.probe.battle + local snapshot = api:snapshot() + T.check( + api:submit({ id = 1, revision = snapshot.revision, kind = "menu", choice = "party" }), + "battle opens native party UI" + ) + for _ = 1, 100 do + if game.stack:top() ~= b then + break + end + b:updateQueue() + end + T.eq(ui:snapshot().kind, "party", "battle picker exposed by UI facade") + local oldBattle = api:snapshot() + T.check(submit(ui, 1, "select", { index = 2 }), "choose switching party slot") + T.eq(ui:snapshot().kind, "party_actions", "native battle switch submenu") + local switch + for i, row in ipairs(ui:snapshot().rows) do + if row.action == "battle_switch" then + switch = i + end + end + T.check(submit(ui, 2, "select", { index = switch }), "native switch callback dispatched") + T.eq(game.stack:top(), b, "native battle resumes") + T.check(#b.queue > 0, "native switch queues battle work") + T.eq( + api:submit({ id = 2, revision = oldBattle.revision, kind = "menu", choice = "fight" }), + nil, + "UI work expires battle context" + ) +end + +-- Link/temporary party arrays can differ from save.party. Track the actual +-- native target, including changes made while its action submenu is open. +do + local ui, game = boot("crystal") + local copy = { Pokemon.new(game.data, "FIXMON_C", 15), Pokemon.new(game.data, "FIXMON_B", 12) } + local party = require("src.ui.PartyMenu").new(game, { party = copy }) + game.stack:push(party) + T.check(submit(ui, 1, "select", { index = 1 }), "open temporary party submenu") + local snapshot = ui:snapshot() + copy[1].hp = copy[1].hp - 1 + T.eq( + refusal( + ui, + game, + { id = 2, revision = snapshot.revision, kind = "back" }, + "temporary party HP invalidates submenu" + ), + "stale UI context", + "actual native party tracked" + ) + snapshot = ui:snapshot() + local replacement = {} + for k, v in pairs(copy[1]) do + replacement[k] = v + end + copy[1] = replacement + refusal( + ui, + game, + { id = 2, revision = snapshot.revision, kind = "back" }, + "temporary party object identity tracked" + ) +end +GameVersion.set(original) +T.finish("UI actions") From 4bfa6683fc334697ec2622895a3678e4e4b8d525 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:15:11 +0200 Subject: [PATCH 011/138] Add opt-in prevalidated portable world dialogue scripts --- docs/modding.md | 7 + docs/portable-world-scripts.md | 164 +++++ src/mods/Loader.lua | 8 +- src/mods/Manifest.lua | 5 + src/mods/PortableScripts.lua | 543 +++++++++++++++ src/mods/ScriptProgram.lua | 160 +++++ tests/engine/portable_world_scripts.lua | 856 ++++++++++++++++++++++++ 7 files changed, 1741 insertions(+), 2 deletions(-) create mode 100644 docs/portable-world-scripts.md create mode 100644 src/mods/PortableScripts.lua create mode 100644 src/mods/ScriptProgram.lua create mode 100644 tests/engine/portable_world_scripts.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..57f514c97 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -87,6 +87,13 @@ An edited vanilla map becomes a `mod.content.maps:patch` carrying only the fields that moved; a new map becomes a `:register`. See `docs/new-features.md` and the extension's own README. +## Portable dialogue scripts (opt-in) + +API-2 mods can select `"script_format": "unified"` for prevalidated, copied +`mod.world:queueScript()` dialogue/control-flow programs with exactly-once +completion and owner-only cancellation. Existing manifests keep native script +behavior. See [portable commands, ownership and limits](portable-world-scripts.md). + ## Rendering pipelines Most registries hand the engine *content*. `render_pipelines` hands it diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md new file mode 100644 index 000000000..1391146ba --- /dev/null +++ b/docs/portable-world-scripts.md @@ -0,0 +1,164 @@ +# Portable dialogue scripts + +API-2 mods can opt into a bounded, prevalidated language for +`mod.world:queueScript(rows, options)`: + +```json +{ + "id": "quest_dialogue", + "name": "Quest dialogue", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "script_format": "unified" +} +``` + +Check `mod.scriptFormat == "unified"` in the entry file. Older engines may +ignore unknown manifest fields; checking the marker prevents a portable row +list from silently reaching their native script runner. Omitted or explicit +`"native"` formats preserve existing API-1/API-2 queueScript behavior. Other +values, or API 1 with `"unified"`, are load errors. Content and callback format +opt-ins are independent. The engine's Mod API major remains 2. + +## Contract + +```lua +return function(mod) + assert(mod.scriptFormat == "unified", "portable scripts required") + + -- Run this from an idle-world interaction/update, not at entry time. + local function offerQuest() + local ok, why = mod.world:queueScript({ + { "ask", "Help with the quest, {PLAYER}?" }, + { "jump_if_false", "declined" }, + { "show_text", "Thank you!" }, + { "jump", "end" }, + { "label", "declined" }, + { "text", "Maybe another time." }, + }, { + onDone = function(completed, reason) + if not completed then mod.log:warn("Dialogue stopped: %s", reason) end + end, + }) + if not ok then mod.log:warn("Dialogue not started: %s", why) end + end +end +``` + +Admission returns `true`, or `nil, reason` without executing any prefix of a +rejected program. The entire list, every row/argument and every jump target is +validated and copied first, including unreachable rows. Caller edits to the +original list, choice labels/options or callback options cannot change an +accepted program. Reading the facade and an early request do not construct +Game. A request with no live save/world fails quietly. + +`true` means admitted, not successfully completed. After admission, +`onDone(true)` reports completion and `onDone(false, reason)` reports a runtime +failure or cancellation. It fires exactly once, including failures during the +initial dispatch. It is not called for rejected requests. A throwing completion +callback is isolated and attributed to its mod; completion is not retried. +Already entered native work is not rolled back or automatically replayed. + +Only `onDone` is accepted in options. Programs and rows must be dense plain +arrays without metatables or extra keys. Functions/userdata/native handles are +not command arguments. Choice options are a closed plain table. + +## Initial command set + +| Row | Meaning | +| --- | --- | +| `{ "show_text", text }` / `{ "text", text }` | Native manual TextBox; wait for all pages and final dismissal | +| `{ "ask", text }` | Native text and YES/NO overlay; YES is true, NO/B is false | +| `{ "choice", labels, opts }` | Native N-way menu; choice 1 is true, every other choice is false | +| `{ "wait", frames }` | Native logic-frame wait, integer 0–36000; zero waits until the next world update | +| `{ "label", name }` | Named jump destination | +| `{ "jump", target }` | Unconditional jump | +| `{ "jump_if_true", target }` | Jump after a true answer | +| `{ "jump_if_false", target }` | Jump after false/nil, including before any answer | +| `{ "end" }` | Finish the program | + +Text is literal UTF-8, up to 4096 bytes per row, without NUL. Native TextBox +placeholder substitution (e.g. `{PLAYER}`), prose pagination, typing, scroll/ +page holds and answer holds remain in use. These rows do not resolve native +text registry IDs or lower ROM script bytecode. Future pages are not skipped. + +`labels` contains 1–16 nonempty UTF-8 strings of at most 128 bytes each. +Optional `opts.default` and `opts.cancel` are valid 1-based indices; defaults +are 1 and the last choice. B follows `cancel`. Answers remain internal to the +program for conditional jumps; the completion callback reports success/failure. + +Labels contain letters, digits, `_`, `-` or `:`, at most 64 bytes, and are unique. +`end` is reserved. Jump targets are existing labels, valid 1-based row indices, +or the string `"end"`. No arbitrary native verbs, custom command handlers, +extra callback arguments or executable rows are accepted. + +A program has at most 1024 rows and 128 KiB of text/choice labels. Execution +yields after each slice of 128 instructions and fails after 65536 instructions +in total. This bounds authored jump loops without a Lua debug hook or a frame +stall; it is not a wall-clock deadline on a player reading text. + +## Ownership and cancellation + +Admission requires the current overworld to own the top of the stack. Native +scripts, queued/parallel scripts, movement, transitions, field animations, +input/controller locks, link sessions and another portable program decline. +Only one portable foreground program can own a Game at a time. + +An accepted program temporarily installs an isolated instance of the native +ScriptRunner. Its `isRunning()` participates in the world's existing input and +script-scheduling gates. It uses native TextBox, ChoiceBox and Menu classes, +including their normal keyboard/touch handling and delayed answer completion. +The idle runner is restored at completion/cancellation, provided it has not +already been replaced by another controller. Global runner/UI classes and the +native command registry are unchanged. + +`mod.world:cancelScript()` cancels only this facade's active portable program. +It returns `true`, or `false, "no portable script"`. It cannot cancel another +mod or a native story script. Cancellation removes only the run's exact native +windows, even beneath a foreign overlay, and releases its coroutine. It does +not pop unrelated screens or release another controller's locks. A covering +foreign screen pauses normal progress. Removing an awaited owned window +externally cancels at the next input-step boundary. + +A replaced Game/save/data/stack/world/map/player/runner, changed map identity or +player position/version, transition, link session, mod rollback, reload or +teardown cancels the program. Session changes are checked at input-step and UI/ +runner boundaries, including while the world is covered. Retained callbacks +cannot resume a cancelled program, skip a later row's wait or pop a new screen. +Facades from an unloaded/reloaded/failed mod are retired. + +Native `script.started`/`script.ended` lifecycle events retain their context +shape; `source.modId` attributes the portable run and `completed` describes its +outcome. End notification/completion follow owned cleanup. Portable rows use +this validated language rather than resolving native/custom handlers or firing +`script.command` wrappers; native-format scripts retain those contracts. +Direct changes to engine objects from such event handlers or other mods remain +outside this boundary's guarantees; this is not a mod security sandbox. + +When optional capability queries are installed, unified mods can probe +`mod:supports("scripts", verb)` and `mod:supports("world", "cancelScript")`. +Unknown/unimplemented portable verbs return false. The existing native +`world/queueScript` capability is not, by itself, evidence of this opt-in. + +## Scope and verification + +This first adapter covers dialogue/control flow across the 15 supported +version selections (Gen 1–5). Flags, items/Pokémon, world movement/warps, +battles, shops and other Gen1Recomp portable verbs need separately verified +adapters; they refuse the complete list before the first effect here. + +The contract is informed by Gen1Recomp's +[UnifiedScripts](https://github.com/bryanthaboi/gen1recomp/blob/79ea62f45fb2ea8f65d081a109431e4eb1bbdb36/src/mods/UnifiedScripts.lua). +This implementation uses this host's native shared UI and runner architecture. + +Run `luajit tests/engine/portable_world_scripts.lua` and +`LUA=luajit bash scripts/test.sh`. The ROM-free suite uses the real Loader, +ScriptRunner, state stack and native UI classes. It compares complete per-frame +native/portable dialogue traces, checks preflight/argument copying, rejection +immutability, identity/session changes, replay, cancellation/reentry, budget and +native failures. Fixture runs do not establish rendered retail-ROM coverage. + +When the unified content/callback PRs are also present, run +`PORTABLE_SCRIPT_ALL_FORMATS=1 luajit tests/engine/portable_world_scripts.lua` +to repeat the suite with all three format opt-ins enabled together. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..d1d9d5e2b 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -11,6 +11,7 @@ local Data = require("src.core.Data") local Version = require("src.core.Version") local Assets = require("src.render.Assets") local ModUI = require("src.ui.ModUI") +local PortableScripts = require("src.mods.PortableScripts") local AssetTransform = require("src.mods.AssetTransform") local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") @@ -1125,7 +1126,7 @@ function Loader:_api(mod) -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. local world, storage - setmetatable(api, { __index = function(_, key) + setmetatable(api, PortableScripts.wrap({ __index = function(_, key) if key == "game" then return loader:_game() end if key == "storage" then -- Do not pass loader.fs when it is love.filesystem: that always writes @@ -1141,7 +1142,7 @@ function Loader:_api(mod) if not module then return nil end world = module.new(game, modId) return world - end }) + end }, api, loader, mod.manifest)) return api end @@ -1196,6 +1197,7 @@ end -- migration it took goes with them. The journal only exists around an -- entry chunk; a later failure (script validation) purges every registry. function Loader:_rollback(modId) + PortableScripts.cancelAll(self, modId, "mod rolled back") for name in pairs(self.journal or self.content) do self.content[name]:rollback(modId) end @@ -1310,6 +1312,7 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + PortableScripts.cancelAll(nil, nil, "mods reloaded") -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST -- LOAD'S MODS. EXIT GAME -> launcher -> Play runs every mod's main.lua -- again (and the launcher backdrop boots the plain game in between); the @@ -1653,6 +1656,7 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + PortableScripts.cancelAll(nil, nil, "mods unloaded") local undone = select(1, ModSeams.unwindAll()) pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index ac5614634..61ae1c60e 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -240,6 +240,10 @@ function Manifest.validate(raw, path) end end + local scriptFormat = raw.script_format == nil and "native" or raw.script_format + assert(scriptFormat == "native" or scriptFormat == "unified", "script_format must be native or unified") + assert(scriptFormat ~= "unified" or api >= 2, "unified scripts require mod API 2") + local gameVersionOk, gameVersionErr = Semver.validRange(raw.game_version) assert(gameVersionOk, ("malformed game_version %q: %s") :format(tostring(raw.game_version), tostring(gameVersionErr))) @@ -325,6 +329,7 @@ function Manifest.validate(raw, path) affects_link = affectsLink, permissions = permissions, permissionSet = permissionSet, + script_format = scriptFormat, options_schema = optionalFile(raw.options_schema, "options_schema"), -- Base files the mod needs but cannot ship -- a Stadium 2 cartridge, a -- second game's ROM. Declared here, satisfied by the launcher, read by diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua new file mode 100644 index 000000000..bfa6c6181 --- /dev/null +++ b/src/mods/PortableScripts.lua @@ -0,0 +1,543 @@ +-- Per-mod portable dialogue programs. An isolated native runner owns the +-- foreground until completion, then returns the idle runner it borrowed. +local Program = require("src.mods.ScriptProgram") +local Runtime = require("src.mods.Runtime") +local GameVersion = require("src.core.GameVersion") +local Scripts = {} +local runs = setmetatable({}, { __mode = "k" }) +local observers = setmetatable({}, { __mode = "k" }) +local contexts = setmetatable({}, { __mode = "k" }) +local draining = false +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function engine(fn, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local result = pack(pcall(fn, ...)) + Runtime.currentMod = owner + return unpack(result, 1, result.n) +end +local function current(loader) + if loader.game then + return loader.game + end + local game = package.loaded["src.core.Game"] + return type(game) == "table" and game or nil +end +local function message(err) + local ok, text = pcall(tostring, err) + return ok and text or "unprintable error" +end +local function contains(stack, state) + if type(stack.states) ~= "table" then + return false + end + for _, value in ipairs(stack.states) do + if value == state then + return true + end + end + return false +end +local function remove(stack, state) + if type(stack.states) ~= "table" then + return + end + for i = #stack.states, 1, -1 do + if stack.states[i] == state then + table.remove(stack.states, i) + if Runtime.wants("screen.popped") then + Runtime.emit("screen.popped", { state = state }) + end + return + end + end +end +local LOCKS = { + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "gen3Locked", + "frameScriptThisTick", + "_modScriptLock", +} +local function ready(game) + if not (game and game.save) then + return nil, "no save" + end + local stack = game.stack + if not (stack and type(stack.states) == "table" and type(stack.top) == "function") then + return nil, "no overworld" + end + local ow = stack:top() + if not (ow and ow.isOverworld and ow.map and ow.player and ow.runner) then + return nil, "world is busy" + end + if + runs[game] + or game.overworld and game.overworld ~= ow + or game.linkSession + or game.linkNet and not game.linkNet.closed + then + return nil, "world is busy" + end + for _, key in ipairs(LOCKS) do + if ow[key] then + return nil, "world is busy" + end + end + for _, key in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do + if ow.player[key] then + return nil, "world is busy" + end + end + if + ow.runner:isRunning() + or #(ow.scriptMoves or {}) > 0 + or #(ow.pendingScripts or {}) > 0 + or #(ow.parallelRunners or {}) > 0 + or #(ow.parallelQueue or {}) > 0 + then + return nil, "world is busy" + end + return ow +end +local function observe(loader) + local installed = observers[loader] + if installed and installed[1] == loader.hooks then + return + end + -- Weak values too: the hook closure captures its loader, so retaining the + -- hooks here would otherwise keep every unloaded loader alive on LuaJIT. + observers[loader] = setmetatable({ loader.hooks }, { __mode = "v" }) + -- Keep one observer per loader: retiring a hook while its chain dispatches + -- would shift the following mod's index. Per-run cleanup never edits it. + loader.hooks:wrap("input.step", function(nextFn, game, dt) + if next(runs) == nil then + return nextFn(game, dt) + end + local pending = {} + for _, token in pairs(runs) do + if token.loader == loader then + pending[#pending + 1] = token + end + end + for _, token in ipairs(pending) do + local ok, live, why = engine(token.valid) + if not ok or not live then + token.finish(false, ok and why or "script context failed") + elseif token.waitingUI and not contains(token.stack, token.waitingUI) then + token.finish(false, "script UI was removed") + end + end + return nextFn(game, dt) + end, math.huge, "engine:portable_scripts") +end +function Scripts.cancelAll(loader, modId, reason) + if loader and modId then + if contexts[loader] then + contexts[loader].owners[modId] = nil + end + elseif loader then + contexts[loader] = nil + else + for owner in pairs(contexts) do + contexts[owner] = nil + end + end + local wasDraining = draining + draining = true + local pending = {} + for _, token in pairs(runs) do + if (not loader or token.loader == loader) and (not modId or token.modId == modId) then + pending[#pending + 1] = token + end + end + for _, token in ipairs(pending) do + token.finish(false, reason or "script session ended") + end + draining = wasDraining +end +local function facade(loader, modId, native) + local context = contexts[loader] + if not context then + context = { owners = {} } + contexts[loader] = context + end + local lease = {} + context.owners[modId] = lease + local function licensed() + return not draining and contexts[loader] == context and context.owners[modId] == lease + end + -- Install before entry code can subscribe/queue from input.step. Sorting + -- a new observer into a live hook chain could replay its current wrapper. + observe(loader) + local world, active = {}, nil + local function queue(rows, options) + if not licensed() then + return nil, "script facade is retired" + end + local program, why = Program.compile(rows) + if not program then + return nil, why + end + if options ~= nil then + if type(options) ~= "table" or getmetatable(options) ~= nil then + return nil, "options must be a plain table" + end + for key, value in pairs(options) do + if key ~= "onDone" or type(value) ~= "function" then + return nil, "only onDone callback is supported" + end + end + end + local onDone = options and options.onDone + local game = current(loader) + local ow + ow, why = ready(game) + if not ow then + return nil, why + end + local Runner = require("src.script.ScriptRunner") + local runner = Runner.new(game, ow) + local token = { + loader = loader, + modId = modId, + game = game, + world = ow, + runner = runner, + original = ow.runner, + stack = game.stack, + states = game.stack.states, + mapId = ow.map.id, + x = ow.player.cellX, + y = ow.player.cellY, + save = game.save, + data = game.data, + map = ow.map, + player = ow.player, + version = GameVersion.get(), + ui = {}, + steps = 0, + } + function token.valid() + if token.done then + return nil, "script is finished" + end + if + not licensed() + or current(loader) ~= game + or game.save ~= token.save + or game.data ~= token.data + or game.stack ~= token.stack + or game.stack.states ~= token.states + or not contains(token.stack, ow) + or game.overworld and game.overworld ~= ow + or ow.map ~= token.map + or ow.map.id ~= token.mapId + or ow.player ~= token.player + or ow.player.cellX ~= token.x + or ow.player.cellY ~= token.y + or ow.runner ~= runner + or GameVersion.get() ~= token.version + or Runtime.hooks ~= loader.hooks + or Runtime.events ~= loader.events + or game.linkSession + or game.linkNet and not game.linkNet.closed + or ow.transitioning + then + return nil, "script session changed" + end + return true + end + function token.finish(ok, reason) + if token.done then + return + end + token.done = true + runner.co, runner.waitingFrames, runner.waitingCheck = nil, nil, nil + -- Remove only this run's exact native screens, preserving foreign + -- overlays and a replacement world's runner/locks. + for i = #token.ui, 1, -1 do + remove(token.stack, token.ui[i]) + end + if ow.runner == runner then + ow.runner = token.original + end + if runs[game] == token then + runs[game] = nil + end + if active == token then + active = nil + end + if Runtime.wants("script.ended") then + Runtime.emit("script.ended", { ctx = runner.ctx, completed = ok }) + end + if onDone then + local called, err = pcall(onDone, ok, reason) + if not called then + Runtime.reportError(modId, "portable script completion failed: " .. message(err)) + end + end + end + local function check() + local valid, reason = token.valid() + if not valid then + token.finish(false, reason) + end + return valid + end + local function owned(state, step) + token.ui[#token.ui + 1] = state + local update = state.update + function state:update(...) + if not check() or token.step ~= step or game.stack:top() ~= self then + return + end + local ok, err = engine(update, self, ...) + if not ok then + token.finish(false, "native UI failed: " .. message(err)) + end + end + return state + end + local function dialog(ctx, text, asking, step) + local TextBox = require("src.render.TextBox") + local box, answered + local function answer(value) + if answered or token.step ~= step or not check() then + return + end + answered = true + token.waitingUI = nil + ctx.lastCheck = value + runner:resume() + end + box = TextBox.new(game, TextBox.fromProse(text), function() + if not asking and not contains(token.stack, box) then + answer(nil) + end + end) + if asking then + local update, pushed, selected = box.update, false, nil + box.choice = function(yes) + if token.step ~= step or not check() or not selected or contains(token.stack, selected) then + return + end + remove(token.stack, box) + answer(yes) + end + function box:update(dt) + if self.done and (self.holdFrames or 0) <= 0 and not pushed then + pushed, self.choicePushed = true, true + local Choice = require("src.ui.ChoiceBox") + local geometry = self.choiceBox or {} + selected = owned( + Choice.new( + game, + self.choice, + { anchor = "bottom", tx = geometry.tx, ty = geometry.ty, tw = geometry.tw, th = geometry.th } + ), + step + ) + token.waitingUI = selected + game.stack:push(selected) + else + update(self, dt) + end + end + end + owned(box, step) + token.waitingUI = box + game.stack:push(box) + runner:yield() + end + local function choice(ctx, labels, opts, step) + local Menu = require("src.ui.Menu") + local items, menu, answered = {}, nil, false + local function pick(index) + if answered or token.step ~= step or not check() or contains(token.stack, menu) then + return + end + answered = true + token.waitingUI = nil + ctx.lastCheck, ctx.lastChoice = index == 1, { index = index, label = labels[index] } + runner:resume() + end + for i, label in ipairs(labels) do + items[i] = { + label = label, + onSelect = function() + pick(i) + end, + } + end + menu = owned( + Menu.new(game, items, { + onCancel = function() + pick(opts.cancel) + end, + }), + step + ) + menu.index = opts.default + token.waitingUI = menu + game.stack:push(menu) + runner:yield() + end + function runner:exec(compiled, ctx) + local pc, slice = 1, 0 + while pc <= #compiled do + if not check() then + return + end + if slice >= Program.SLICE then + self.waitingFrames = 1 + self:yield() + slice = 0 + if not check() then + return + end + end + token.steps, slice = token.steps + 1, slice + 1 + if token.steps > Program.MAX_STEPS then + error("portable script instruction budget exceeded", 0) + end + local row = compiled[pc] + local verb = row[1] + token.step = {} + self.lastRow = verb .. " (portable row " .. pc .. ")" + ctx.script, ctx.pc = compiled, pc + if verb == "wait" then + self.waitingFrames = row[2] + self:yield() + elseif verb == "show_text" or verb == "ask" then + dialog(ctx, row[2], verb == "ask", token.step) + elseif verb == "choice" then + choice(ctx, row[2], row[3], token.step) + elseif verb == "end" then + return + elseif + verb == "jump" + or verb == "jump_if_true" and ctx.lastCheck + or verb == "jump_if_false" and not ctx.lastCheck + then + pc = row[2] - 1 + end + pc = pc + 1 + end + end + local update = Runner.update + function runner:resume(...) + if not check() or not self.co or coroutine.status(self.co) == "running" then + return + end + local ok, err = coroutine.resume(self.co, ...) + if not ok then + Runtime.reportError(modId, "portable script failed: " .. message(err)) + token.finish(false, "script failed: " .. message(err)) + elseif not token.done and coroutine.status(self.co) == "dead" then + token.finish(true) + end + end + function runner:update(...) + if not check() or game.stack:top() ~= ow then + return + end + local ok, err = engine(update, self, ...) + if not ok then + token.finish(false, "script update failed: " .. message(err)) + end + end + function runner:run() + error("portable script owns this runner", 0) + end + -- Session/argument checks above do not mutate gameplay. Admission starts + -- here; subsequent native failures complete through onDone(false, reason). + active, runs[game], ow.runner = token, token, runner + runner.ctx = runner:makeContext({ + source = { modId = modId, mapId = ow.map.id, hook = "queueScript", strict = true }, + }) + runner.co = coroutine.create(function() + runner:exec(program, runner.ctx) + end) + if Runtime.wants("script.started") then + Runtime.emit("script.started", { ctx = runner.ctx }) + end + local ok, err = engine(runner.resume, runner) + if not ok then + token.finish(false, "script start failed: " .. message(err)) + end + return true + end + function world:queueScript(rows, options) + local ok, result, reason = engine(queue, rows, options) + if not ok then + return nil, "portable script validation failed" + end + return result, reason + end + function world:cancelScript() + if not active or active.done then + return false, "no portable script" + end + active.finish(false, "script cancelled") + return true + end + return setmetatable(world, { + __index = function(_, key) + local value = native() + local member = value and value[key] + if type(member) == "function" then + return function(_, ...) + return member(value, ...) + end + end + return member + end, + }) +end +function Scripts.wrap(mt, api, loader, manifest) + local format = manifest.script_format or "native" + api.scriptFormat = format + if format ~= "unified" then + return mt + end + local index = mt.__index + local world = facade(loader, manifest.id, function() + return index(api, "world") + end) + mt.__index = function(self, key) + if key == "world" then + return world + end + return index(self, key) + end + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "scripts" then + if Program.supports(name) then + return true + end + return false, "unsupported portable script command" + end + if surface == "world" and (name == "queueScript" or name == "cancelScript") then + return true + end + return supports(self, surface, name) + end + end + return mt +end +return Scripts diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua new file mode 100644 index 000000000..4bfc0a813 --- /dev/null +++ b/src/mods/ScriptProgram.lua @@ -0,0 +1,160 @@ +-- Pure preflight for the bounded portable dialogue/control-flow language. +local Program = {} +Program.MAX_ROWS, Program.MAX_STEPS, Program.SLICE = 1024, 65536, 128 +local ARITY = { + show_text = 2, + ask = 2, + choice = 3, + wait = 2, + label = 2, + jump = 2, + jump_if_true = 2, + jump_if_false = 2, + ["end"] = 1, +} +function Program.supports(verb) + return type(verb) == "string" and (ARITY[verb] ~= nil or verb == "text") +end +local function integer(n, lo, hi) + return type(n) == "number" and n == n and n % 1 == 0 and n >= lo and n <= hi +end +local function array(t, max) + if type(t) ~= "table" or getmetatable(t) ~= nil then + return nil + end + local n = 0 + for k in pairs(t) do + if not integer(k, 1, max) then + return nil + end + n = n + 1 + end + for i = 1, n do + if rawget(t, i) == nil then + return nil + end + end + return n +end +local function label(s) + return type(s) == "string" and #s > 0 and #s <= 64 and s:match("^[%w_:%-]+$") ~= nil +end +local function text(value, max) + if type(value) ~= "string" or #value > max or value:find("%z") then + return false + end + local i = 1 + while i <= #value do + local b = value:byte(i) + local n = b < 128 and 1 + or b >= 194 and b <= 223 and 2 + or b >= 224 and b <= 239 and 3 + or b >= 240 and b <= 244 and 4 + if not n or i + n - 1 > #value then + return false + end + for j = i + 1, i + n - 1 do + local c = value:byte(j) + if c < 128 or c > 191 then + return false + end + end + if n > 1 then + local c = value:byte(i + 1) + if b == 224 and c < 160 or b == 237 and c > 159 or b == 240 and c < 144 or b == 244 and c > 143 then + return false + end + end + i = i + n + end + return true +end +function Program.compile(raw) + local n = array(raw, Program.MAX_ROWS) + if not n then + return nil, "script must be a dense plain array of at most 1024 rows" + end + local rows, labels, bytes = {}, {}, 0 + local function fail(i, message) + return nil, "row " .. i .. ": " .. message + end + for i = 1, n do + local source, count = raw[i], array(raw[i], 3) + if not count or count == 0 then + return fail(i, "expected a dense plain command row") + end + local verb = source[1] == "text" and "show_text" or source[1] + if type(verb) ~= "string" or not ARITY[verb] then + return fail(i, "unsupported portable command") + end + if count > ARITY[verb] or count < (verb == "choice" and 2 or ARITY[verb]) then + return fail(i, "invalid command arity") + end + local row = { verb, source[2] } + if verb == "show_text" or verb == "ask" then + if not text(source[2], 4096) then + return fail(i, "text must be valid UTF-8 without NUL, at most 4096 bytes") + end + bytes = bytes + #source[2] + elseif verb == "choice" then + local size = array(source[2], 16) + if not size or size == 0 then + return fail(i, "choice needs 1-16 labels") + end + row[2] = {} + for j = 1, size do + local value = source[2][j] + if not text(value, 128) or #value == 0 then + return fail(i, "invalid choice label") + end + row[2][j], bytes = value, bytes + #value + end + local opts = source[3] + row[3] = { default = 1, cancel = size } + if opts ~= nil then + if type(opts) ~= "table" or getmetatable(opts) ~= nil then + return fail(i, "choice options must be a plain table") + end + for key, value in pairs(opts) do + if (key ~= "default" and key ~= "cancel") or not integer(value, 1, size) then + return fail(i, "invalid choice option") + end + row[3][key] = value + end + end + elseif verb == "wait" then + if not integer(source[2], 0, 36000) then + return fail(i, "wait must be 0-36000 native logic frames") + end + elseif verb == "label" then + if not label(source[2]) or source[2] == "end" or labels[source[2]] then + return fail(i, "invalid or duplicate label") + end + labels[source[2]] = i + elseif verb ~= "end" then + if not label(source[2]) and not integer(source[2], 1, n) then + return fail(i, "invalid jump target") + end + end + if bytes > 131072 then + return fail(i, "script text exceeds 128 KiB") + end + rows[i] = row + end + for i, row in ipairs(rows) do + if row[1] == "jump" or row[1] == "jump_if_true" or row[1] == "jump_if_false" then + local target = row[2] + if type(target) == "string" then + if target == "end" then + row[2] = n + 1 + elseif labels[target] then + row[2] = labels[target] + else + return fail(i, "jump to missing label") + end + end + end + end + return rows +end +return Program diff --git a/tests/engine/portable_world_scripts.lua b/tests/engine/portable_world_scripts.lua new file mode 100644 index 000000000..ccef037d9 --- /dev/null +++ b/tests/engine/portable_world_scripts.lua @@ -0,0 +1,856 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Loader = require("src.mods.Loader") +local Manifest = require("src.mods.Manifest") +local Program = require("src.mods.ScriptProgram") +local Runtime = require("src.mods.Runtime") +local Runner = require("src.script.ScriptRunner") +local WorldAPI = require("src.world.WorldAPI") +local GameVersion = require("src.core.GameVersion") +local Font = require("src.render.Font") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local Json = require("src.link.Json") +local originalVersion = GameVersion.get() +local function boot(version) + GameVersion.set(version) + local data, files = T.fixtures.fresh(), {} + for _, id in ipairs({ "probe", "other", "legacy" }) do + files["mods/" .. id .. "/manifest.json"] = Json.encode({ + id = id, + name = id, + version = "1.0.0", + api = 2, + entry = "main.lua", + script_format = id ~= "legacy" and "unified" or nil, + content_format = os.getenv("PORTABLE_SCRIPT_ALL_FORMATS") == "1" and id ~= "legacy" and "unified" + or nil, + callback_format = os.getenv("PORTABLE_SCRIPT_ALL_FORMATS") == "1" and id ~= "legacy" and "unified" + or nil, + description = os.getenv("PORTABLE_SCRIPT_ALL_FORMATS") == "1" and "all-formats" or "", + }) + files["mods/" .. id .. "/main.lua"] = [[return function(mod) + if mod.id ~= "legacy" then + assert(mod.scriptFormat == "unified") + if mod.manifest.description == "all-formats" then + assert(mod.contentFormat == "unified") + assert(mod.callbackFormat == "unified") + end + local ok, why = mod.world:queueScript({ { "wait", 1 } }) + assert(ok == nil and type(why) == "string") + else assert(mod.scriptFormat == "native") end + end]] + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe", "other", "legacy" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" or p == "mods/other" or p == "mods/legacy" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + T.check(loader:load(data), "fixture mods load") + T.eq(package.loaded["src.core.Game"], nil, "early portable request does not bootstrap Game") + Font.load(data) + local game = { + data = data, + save = SaveData.newGame(), + stack = setmetatable({ states = {} }, { __index = StateStack }), + } + local ow = { + isOverworld = true, + map = { id = "FIX_TOWN", def = data.maps.FIX_TOWN }, + player = { cellX = 4, cellY = 5, facing = "up" }, + scriptMoves = {}, + pendingScripts = {}, + parallelRunners = {}, + } + game.overworld, ow.game = ow, game + ow.runner = Runner.new(game, ow) + function ow:update() + self.runner:update() + end + game.stack.states = { ow } + game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + loader.game = game + return loader.apis.probe.world, game, ow, loader, files +end +local function tick(game, key) + game.input.wasPressed = function(_, k) + return k == key + end + Runtime.call("input.step", function() end, game, 1 / 60) + game.stack:update(1 / 60) + game.input.wasPressed = function() + return false + end +end +local function untilState(game, predicate) + for _ = 1, 2000 do + if predicate() then + return + end + tick(game) + end + error("native UI did not reach requested state") +end +local function finishText(game) + local box = game.stack:top() + untilState(game, function() + return box.done or box.waiting and (box.preWait or 0) <= 0 + end) + tick(game, "a") +end +for _, version in ipairs(GameVersion.ORDER) do + local world, game, ow, loader = boot(version) + local before, calls, result = ow.runner, 0, nil + T.check( + world:queueScript({ { "wait", 2 }, { "text", "First" }, { "end" }, { "text", "Unreachable" } }, { + onDone = function(ok, why) + calls = calls + 1 + result = { ok, why } + end, + }), + version .. " program admitted" + ) + T.neq(ow.runner, before, "isolated native runner installed") + T.eq(ow.runner:isRunning(), true, "runner locks foreground input") + tick(game) + T.eq(game.stack:top(), ow, "wait does not end early") + tick(game) + T.neq(game.stack:top(), ow, "native text appears after wait") + T.eq(calls, 0, "completion awaits text") + finishText(game) + T.same(result, { true }, "success completion") + T.eq(calls, 1, "completion once") + T.eq(ow.runner, before, "idle native runner restored") + T.eq(game.stack:top(), ow, "script text closed") + + local Choice = require("src.ui.ChoiceBox") + for _, yes in ipairs({ true, false }) do + local lines = {} + T.check( + world:queueScript({ + { "ask", "Question?" }, + { "jump_if_false", "no" }, + { "text", "YES" }, + { "jump", "end" }, + { "label", "no" }, + { "text", "NO" }, + }, { + onDone = function(ok) + lines[#lines + 1] = ok + end, + }), + "question script admitted" + ) + untilState(game, function() + return getmetatable(game.stack:top()) == Choice + end) + local choice = game.stack:top() + tick(game, yes and "a" or "b") + T.eq(choice.pending, yes, "native answer pending") + T.eq(#lines, 0, "answer hold preserved") + untilState(game, function() + return game.stack:top() ~= choice + end) + T.same( + game.stack:top().pages, + require("src.render.TextBox").paginate( + yes and "YES" or "NO", + game.stack:top().maxCols, + game.stack:top().letterSpacing, + game.stack:top().maxPixels, + true + ), + "correct branch text" + ) + finishText(game) + T.same(lines, { true }, "question branch completes") + end + local trace = {} + T.check( + world:queueScript({ + { "choice", { "ONE", "TWO", "THREE" }, { default = 2, cancel = 3 } }, + { "jump_if_false", 5 }, + { "text", "WRONG" }, + { "end" }, + { "text", "RIGHT" }, + }, { + onDone = function(ok) + trace[#trace + 1] = ok + end, + }), + "multiple choice admitted" + ) + T.eq(game.stack:top().index, 2, "native default selection") + tick(game, "b") + finishText(game) + T.same(trace, { true }, "native B choice follows false branch") + T.eq(loader.apis.legacy.world.queueScript, WorldAPI.queueScript, "legacy method unchanged") +end + +local function freeze(root) + local graph = {} + local function visit(t) + if type(t) ~= "table" or graph[t] then + return + end + local entry = { mt = getmetatable(t), values = {} } + graph[t] = entry + for k, v in pairs(t) do + entry.values[k] = v + visit(k) + visit(v) + end + end + visit(root) + return function(label) + local same = true + for t, entry in pairs(graph) do + same = same and getmetatable(t) == entry.mt + for k, v in pairs(entry.values) do + same = same and rawget(t, k) == v + end + for k, v in pairs(t) do + same = same and entry.values[k] == v + end + end + T.check(same, label) + end +end +local function refusal(world, game, rows, options, label) + local unchanged = freeze(game) + local ok, why = world:queueScript(rows, options) + T.eq(ok, nil, label) + T.eq(type(why), "string", label .. " has reason") + unchanged(label .. " is observational") +end +local badPrograms = { + false, + 1, + "text", + function() end, + { [1] = { "wait", 1 }, [3] = { "end" } }, + { [1] = { "wait", 1 }, extra = true }, + setmetatable({ { "end" } }, {}), + { false }, + { {} }, + { { "wait" } }, + { { "wait", 1, 2 } }, + { { "wait", -1 } }, + { { "wait", 1.5 } }, + { { "wait", 0 / 0 } }, + { { "wait", math.huge } }, + { { "wait", 36001 } }, + { { "end", false } }, + { { "native_mutate", {} } }, + { { "show_text", {} } }, + { { "show_text", string.rep("x", 4097) } }, + { { "text", "Valid" }, { "give_item", "POTION" } }, + { { "text", "Valid" }, { "jump", "missing" } }, + { { "label", "same" }, { "label", "same" } }, + { { "label", "end" } }, + { { "label", "bad\nname" } }, + { { "jump", 0 } }, + { { "jump", 99 } }, + { { "jump", false } }, + { { "choice", {} } }, + { { "choice", { "A", "B" }, { cancel = 3 } } }, + { { "choice", { "A" }, { default = 0 } } }, + { { "choice", { "A" }, { custom = true } } }, + { { "choice", { "A", function() end } } }, + { { "choice", { "" } } }, + { { "choice", { "A" }, setmetatable({}, {}) } }, + { setmetatable({ "wait", 1 }, {}) }, +} +for _, version in ipairs(GameVersion.ORDER) do + local world, game, ow, loader = boot(version) + local callbacks = 0 + for _, rows in ipairs(badPrograms) do + refusal(world, game, rows, { + onDone = function() + callbacks = callbacks + 1 + end, + }, "invalid whole program") + end + for _, options in ipairs({ + false, + 1, + "options", + { onDone = true }, + { onDone = function() end, runner = {} }, + setmetatable({}, {}), + }) do + refusal(world, game, { { "wait", 1 } }, options, "invalid options") + end + T.eq(callbacks, 0, "rejected lists never complete or execute a prefix") + for _, key in ipairs({ + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "gen3Locked", + "frameScriptThisTick", + "_modScriptLock", + }) do + ow[key] = true + refusal(world, game, { { "wait", 1 } }, nil, key .. " owns world") + ow[key] = nil + end + for _, key in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do + ow.player[key] = true + refusal(world, game, { { "wait", 1 } }, nil, key .. " owns player") + ow.player[key] = nil + end + for _, key in ipairs({ "pendingScripts", "scriptMoves", "parallelRunners", "parallelQueue" }) do + ow[key] = { {} } + refusal(world, game, { { "wait", 1 } }, nil, key .. " owns script scheduling") + ow[key] = {} + end + game.linkSession = {} + refusal(world, game, { { "wait", 1 } }, nil, "link session busy") + game.linkSession = nil + game.linkNet = { closed = false } + refusal(world, game, { { "wait", 1 } }, nil, "link transport busy") + game.linkNet = nil + game.stack:push({}) + refusal(world, game, { { "wait", 1 } }, nil, "covered world busy") + game.stack:pop() + ow.runner:run({ { "wait", 9 } }) + refusal(world, game, { { "wait", 1 } }, nil, "native script busy") + for _ = 1, 9 do + tick(game) + end + T.check(world:queueScript({ { "wait", 1 } }), "new script after native completion") + tick(game) + T.eq(loader.apis.other.world:cancelScript(), false, "another mod cannot cancel a program") +end +-- Limits, data copying and compiler immutability, including a cyclic payload. +do + local world, game, ow = boot("crystal") + local over = {} + for i = 1, 1025 do + over[i] = { "end" } + end + refusal(world, game, over, nil, "row bound") + over = {} + for i = 1, 33 do + over[i] = { "text", string.rep("x", 4096) } + end + refusal(world, game, over, nil, "total text bound") + local cycle = {} + cycle[1] = cycle + refusal(world, game, cycle, nil, "cyclic command denied") + local rows = { { "wait", 2 }, { "choice", { "ONE", "TWO" }, { default = 2 } } } + local unchanged = freeze(rows) + local compiled = Program.compile(rows) + unchanged("compiler leaves caller rows intact") + T.neq(compiled[2][2], rows[2][2], "compiler copies nested labels") + T.check(world:queueScript(rows), "copied program accepted") + rows[2][1], rows[2][2][2], rows[2][3].default = "invalid", "MUTATED", 1 + tick(game) + tick(game) + T.eq(game.stack:top().index, 2, "choice options captured before caller edit") + T.eq(game.stack:top().items[2].label, "TWO", "choice labels copied before execution") + world:cancelScript() +end +-- Session changes cancel even while an old Game is no longer being stepped. +for _, version in ipairs({ "red", "crystal", "emerald", "platinum", "heartgold", "black" }) do + for _, change in ipairs({ + "save", + "data", + "stack", + "states", + "map", + "mapId", + "player", + "position", + "runner", + "game", + "version", + "removed", + "transition", + }) do + local world, game, ow, loader = boot(version) + local originalRunner, calls, result = ow.runner, 0 + T.check( + world:queueScript({ { "wait", 30 }, { "text", "Must not run" } }, { + onDone = function(ok, why) + calls = calls + 1 + result = { ok, why } + end, + }), + "session test admitted" + ) + local stale = ow.runner + local replacement + if change == "save" then + game.save = SaveData.newGame() + elseif change == "data" then + game.data = T.fixtures.fresh() + elseif change == "stack" then + game.stack = setmetatable({ states = { ow } }, { __index = StateStack }) + elseif change == "states" then + game.stack.states = { ow } + elseif change == "map" then + ow.map = { id = "FIX_ROUTE", def = game.data.maps.FIX_ROUTE } + elseif change == "mapId" then + ow.map.id = "FIX_ROUTE" + elseif change == "player" then + ow.player = { cellX = 4, cellY = 5 } + elseif change == "position" then + ow.player.cellX = 7 + elseif change == "runner" then + replacement = Runner.new(game, ow) + ow.runner = replacement + elseif change == "game" then + replacement = { stack = setmetatable({ states = {} }, { __index = StateStack }), input = game.input } + loader.game = replacement + elseif change == "version" then + GameVersion.set(version == "red" and "crystal" or "red") + elseif change == "removed" then + game.stack.states = {} + elseif change == "transition" then + ow.transitioning = true + end + Runtime.call("input.step", function() end, change == "game" and replacement or game, 1 / 60) + T.eq(calls, 1, change .. " cancellation delivered") + T.eq(result[1], false, "session cancellation is not success") + T.eq(type(result[2]), "string", "cancellation gives reason") + T.eq(ow.runner, change == "runner" and replacement or originalRunner, "restore only owned runner") + stale:resume() + stale:update() + T.eq(calls, 1, "old runner callbacks cannot replay cancellation") + T.eq(stale:isRunning(), false, "abandoned coroutine released") + GameVersion.set(version) + end +end +-- Cancel removes only owned windows, including the choice above its text. +for _, version in ipairs(GameVersion.ORDER) do + local world, game, ow, loader = boot(version) + local originalRunner, calls = ow.runner, 0 + T.check( + world:queueScript({ { "ask", "Question?" }, { "text", "Later" } }, { + onDone = function(ok) + T.eq(ok, false, "explicit cancel completion") + calls = calls + 1 + end, + }), + "cancellable question" + ) + local box = game.stack:top() + untilState(game, function() + return game.stack:top() ~= box + end) + local choice = game.stack:top() + local foreign = { update = function() end } + game.stack:push(foreign) + T.eq(loader.apis.other.world:cancelScript(), false, "foreign mod has no cancellation authority") + T.check(world:cancelScript(), "owner cancels") + T.same(game.stack.states, { ow, foreign }, "foreign overlay survives cancellation") + T.eq(ow.runner, originalRunner, "cancel restores idle runner") + T.eq(calls, 1, "cancel callback once") + choice.onChoose(true) + box.onDone() + choice:update(1 / 60) + box:update(1 / 60) + T.same(game.stack.states, { ow, foreign }, "stale native UI cannot pop foreign screen") + T.eq(calls, 1, "stale native callbacks do not complete twice") +end +-- Replaying an old callback during a later row must not skip that row's wait. +do + local world, game, ow = boot("red") + T.check( + world:queueScript({ { "text", "First" }, { "wait", 10 }, { "text", "Last" } }), + "callback replay test admitted" + ) + local old = game.stack:top() + finishText(game) + T.eq(ow.runner.waitingFrames, 10, "later wait begins") + old.onDone() + old:update(1 / 60) + T.eq(ow.runner.waitingFrames, 10, "old text callback cannot skip current wait") + for _ = 1, 10 do + tick(game) + end + T.neq(game.stack:top(), ow, "new text after full wait") + world:cancelScript() +end + +-- Native error cleanup, completion exceptions and event reentry are bounded. +for _, version in ipairs({ "red", "crystal", "emerald", "platinum", "black" }) do + local world, game, ow, loader = boot(version) + local TextBox = require("src.render.TextBox") + local original, make = ow.runner, TextBox.new + local completions, result = 0 + TextBox.new = function() + error("injected native constructor failure") + end + Runtime.currentMod = "probe" + T.check( + world:queueScript({ { "text", "Fail" } }, { + onDone = function(ok, why) + completions = completions + 1 + result = { ok, why } + end, + }), + "entered native failure is admitted" + ) + T.eq(Runtime.currentMod, "probe", "engine ownership restored on failure") + Runtime.currentMod = nil + TextBox.new = make + T.eq(result[1], false, "native error completes as failure") + T.check(result[2]:find("constructor failure", 1, true) ~= nil, "native error reason retained") + T.eq(completions, 1, "native error completion once") + T.eq(ow.runner, original, "native error releases runner") + T.check( + world:queueScript({}, { + onDone = function() + error("completion failure") + end, + }), + "callback error isolated" + ) + T.eq(ow.runner, original, "callback error cannot strand runner") + local events = {} + local stop1 = loader.events:on("script.started", function(ev) + if ev.ctx.source and ev.ctx.source.modId == "probe" then + events[#events + 1] = "started" + refusal( + loader.apis.other.world, + game, + { { "wait", 1 } }, + nil, + "event callback cannot reenter owned run" + ) + end + end) + local stop2 = loader.events:on("script.ended", function(ev) + if ev.ctx.source and ev.ctx.source.modId == "probe" then + events[#events + 1] = ev.completed and "success" or "failure" + end + end) + T.check(world:queueScript({ { "wait", 1 } }), "eventful wait admitted") + tick(game) + T.same(events, { "started", "success" }, "one lifecycle event pair") + T.check(world:queueScript({ { "wait", 10 } }), "eventful cancel admitted") + world:cancelScript() + T.same(events, { "started", "success", "started", "failure" }, "cancel emits failure once") + stop1() + stop2() +end +-- An infinite authored jump yields slices to the engine and eventually +-- reports failure rather than blocking a frame or permanently owning input. +do + local world, game, ow = boot("red") + local original, result, ticks = ow.runner, nil, 0 + T.check( + world:queueScript({ { "label", "loop" }, { "jump", "loop" } }, { + onDone = function(ok, why) + result = { ok, why } + end, + }), + "loop admitted" + ) + T.eq(result, nil, "loop yields back from initial call") + untilState(game, function() + ticks = ticks + 1 + return result ~= nil + end) + T.check(ticks > 1, "loop spans engine updates") + T.eq(result[1], false, "instruction limit fails explicitly") + T.check(result[2]:find("budget", 1, true) ~= nil, "budget reason") + T.eq(ow.runner, original, "budget exhaustion releases world") +end +-- Teardown/reload and failed entry chunks retire captured facades. Neither +-- completion nor a script-ended listener may start fresh work during teardown. +for _, mode in ipairs({ "teardown", "reload", "rollback" }) do + local world, game, ow, loader, files = boot("emerald") + local original, calls, retry = ow.runner, 0 + T.check( + world:queueScript({ { "text", "Pending" } }, { + onDone = function(ok) + T.eq(ok, false, "lifecycle cancellation") + calls = calls + 1 + retry = world:queueScript({ { "wait", 1 } }) + end, + }), + "lifecycle program accepted" + ) + if mode == "teardown" then + Loader.teardown() + elseif mode == "reload" then + boot("emerald") + else + loader:_rollback("probe") + end + T.eq(calls, 1, "lifecycle completion once") + T.eq(retry, nil, "no restart while retiring") + T.eq(ow.runner, original, "lifecycle restores runner") + T.same(game.stack.states, { ow }, "lifecycle removes owned text") + refusal(world, game, { { "wait", 1 } }, nil, "retained API retired") +end +do + local _, game, ow, loader, files = boot("crystal") + local original = ow.runner + files["mods/probe/main.lua"] = [[return function(mod) + assert(mod.world:queueScript({ { "wait", 30 } })) + error("entry failure after queue") + end]] + files["mods/other/main.lua"] = "return function() end" + local failed = Loader.new({ fs = loader.fs, dev = false }) + failed.game = game + failed:load(T.fixtures.fresh()) + T.eq(ow.runner, original, "failed entry cancels its accepted run") + T.eq(original:isRunning(), false, "failed entry cannot strand foreground") +end +-- Omitted/native formats preserve the old API 1/2 contract. Explicit malformed +-- or API-1 unified declarations must fail instead of silently changing mode. +do + local function manifest(format, level) + return { + id = "probe", + name = "Probe", + version = "1.0.0", + entry = "main.lua", + api = level or 2, + script_format = format, + } + end + for _, bad in ipairs({ false, true, 1, {}, "portable", "" }) do + T.raises(function() + Manifest.validate(manifest(bad), "mods/probe") + end, "script_format", "invalid format") + end + T.raises(function() + Manifest.validate(manifest("unified", 1), "mods/probe") + end, "API 2", "unified requires API 2") + T.eq( + Manifest.validate(manifest(nil, 1), "mods/probe").script_format, + "native", + "API 1 default remains native" + ) + T.eq( + Manifest.validate(manifest("native", 1), "mods/probe").script_format, + "native", + "explicit API 1 native accepted" + ) +end +-- Per-frame traces against the real native runner/commands, rather than +-- against another implementation of the portable adapter. +local function stateRecord(game, ow) + local top = game.stack:top() + local record = + { depth = #game.stack.states, running = ow.runner:isRunning(), wait = ow.runner.waitingFrames } + if top ~= ow then + record.index, record.pending, record.hold = top.index, top.pending, top.holdFrames + record.page, record.line, record.char = top.pageIndex, top.lineIndex, top.charIndex + record.done, record.waiting, record.preWait = top.done, top.waiting, top.preWait + if top.shown then + record.shown = Json.decode(Json.encode(top.shown)) + end + if top.items then + record.labels = {} + for _, item in ipairs(top.items) do + record.labels[#record.labels + 1] = item.label + end + end + end + return record +end +for _, version in ipairs(GameVersion.ORDER) do + for _, yes in ipairs({ true, false }) do + local traces = {} + for _, native in ipairs({ true, false }) do + local world, game, ow = boot(version) + local rows = { + { "wait", 2 }, + { "show_text", "First\fSecond" }, + { "ask", "Question?" }, + { "jump_if_false", "no" }, + { "choice", { "ONE", "TWO" }, { default = 2, cancel = 2 } }, + { "jump", "end" }, + { "label", "no" }, + { "show_text", "NO" }, + } + local completed, trace = false, {} + local saveUnchanged = freeze(game.save) + if native then + ow.runner:run(rows, { + onDone = function() + completed = true + end, + }) + else + T.check( + world:queueScript(rows, { + onDone = function(ok) + T.eq(ok, true, "parity program succeeds") + completed = true + end, + }), + "parity program admitted" + ) + end + for frame = 1, 2000 do + trace[#trace + 1] = stateRecord(game, ow) + if completed then + break + end + local top = game.stack:top() + local key + if top.pending == nil and getmetatable(top) == require("src.ui.ChoiceBox") then + key = yes and "a" or "b" + elseif top.items then + key = "b" + elseif top.done or top.waiting and (top.preWait or 0) <= 0 then + key = "a" + end + tick(game, key) + end + T.eq(completed, true, "native parity trace completed") + saveUnchanged("dialogue program leaves save unchanged") + traces[#traces + 1] = trace + end + T.same(traces[1], traces[2], version .. " frame-by-frame native dialogue/wait/branch parity") + end +end + +do + local world, game, ow, loader = boot("black") + for _, invalid in ipairs({ + "bad\0text", + "\128", + "\192\128", + "\224\128\128", + "\237\160\128", + "\244\144\128\128", + "\240\159", + }) do + refusal( + world, + game, + { { "text", "Valid first" }, { "ask", invalid } }, + nil, + "invalid UTF-8 refused before prefix" + ) + end + T.check(world:queueScript({ { "text", "Grüße Pokémon ♀" } }), "valid UTF-8 accepted") + world:cancelScript() + local calls = 0 + local stop = loader.hooks:wrap("input.step", function(nextFn, ...) + calls = calls + 1 + return nextFn(...) + end, -1, "other") + for _ = 1, 3 do + T.check(world:queueScript({ { "wait", 2 } }), "reused observer admission") + world:cancelScript() + end + local before = calls + tick(game) + T.eq(calls, before + 1, "observer does not skip or duplicate downstream hooks") + stop() + local random = math.random + local rolls = 0 + math.random = function() + rolls = rolls + 1 + error("portable scripts must not roll RNG") + end + T.check(world:queueScript({ { "text", "FIRST" } }), "no-RNG dialogue") + finishText(game) + math.random = random + T.eq(rolls, 0, "no RNG consumed") + local events = {} + local done = loader.events:on("script.ended", function(ev) + events[#events + 1] = ev.completed + end) + T.check(world:queueScript({ { "text", "Native failure" } }), "native update failure admission") + local box = game.stack:top() + -- The guarded method captures the native update when the box is created. + -- Fail native work reached on a later frame instead of changing the guard. + box.beginLine = function() + error("native text update failure") + end + box.done, box.waiting, box.preWait, box.contAdvance = false, true, 0, true + tick(game, "a") + T.same(events, { false }, "native UI update error completes once") + T.eq(game.stack:top(), ow, "native update error releases owned UI") + done() +end +for _, version in ipairs(GameVersion.ORDER) do + local world, game, ow, loader = boot(version) + local api = loader.apis.probe + if api.supports then + T.eq(api:supports("world", "queueScript"), true, "portable queue capability") + T.eq(api:supports("world", "cancelScript"), true, "portable cancel capability") + for _, verb in ipairs({ + "text", + "show_text", + "ask", + "choice", + "wait", + "label", + "jump", + "jump_if_true", + "jump_if_false", + "end", + }) do + T.eq(api:supports("scripts", verb), true, "bounded verb capability") + end + T.eq(api:supports("scripts", "warp"), false, "unsupported verb declined") + T.eq(loader.apis.legacy:supports("world", "cancelScript"), false, "legacy has no portable cancellation") + T.eq(loader.apis.legacy:supports("scripts", "wait"), false, "native format does not claim portable verbs") + end + T.eq(world:current().mapId, "FIX_TOWN", "unrelated world methods still delegate") + T.eq(world.game, game, "existing native fields still delegate") +end + +-- First admission from an input.step callback must not sort a new observer +-- into the chain while it is being traversed (which would replay the caller). +do + local world, game = boot("crystal") + local wrappers, vanilla = 0, 0 + local stop = Runtime.hooks:wrap("input.step", function(nextFn, ...) + wrappers = wrappers + 1 + if wrappers == 1 then + T.check(world:queueScript({ { "wait", 2 } }), "first queue inside input hook") + end + return nextFn(...) + end, 0, "probe") + Runtime.call("input.step", function() + vanilla = vanilla + 1 + end, game, 1 / 60) + T.eq(wrappers, 1, "first queue does not replay input callback") + T.eq(vanilla, 1, "native input boundary called once") + world:cancelScript() + stop() +end +GameVersion.set(originalVersion) +T.finish("portable world scripts") From b46e1289f0038b1538190b956ce07117e5fda066 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:07:37 +0200 Subject: [PATCH 012/138] Add read-only map overview and encounter table queries --- docs/modding.md | 2 + docs/world-overview.md | 105 +++++ src/mods/Loader.lua | 3 + src/mods/UnifiedIds.lua | 85 ++++ src/mods/WorldOverview.lua | 153 +++++++ src/world/MapOverview.lua | 176 ++++++++ src/world/WorldAPI.lua | 15 + tests/engine/world_overview_api.lua | 660 ++++++++++++++++++++++++++++ 8 files changed, 1199 insertions(+) create mode 100644 docs/world-overview.md create mode 100644 src/mods/UnifiedIds.lua create mode 100644 src/mods/WorldOverview.lua create mode 100644 src/world/MapOverview.lua create mode 100644 tests/engine/world_overview_api.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..2e08c6136 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -18,6 +18,8 @@ Regenerate the reference straight into a wiki checkout: luajit tools/gen_registry_docs.lua ../gen1recomp.wiki ``` +Additional read-only world summaries: [map overview and encounter queries](world-overview.md). + ## Bulk storage (`mod.storage`) `mod.save` is the right home for a handful of per-playthrough values: it lives diff --git a/docs/world-overview.md b/docs/world-overview.md new file mode 100644 index 000000000..e3c494197 --- /dev/null +++ b/docs/world-overview.md @@ -0,0 +1,105 @@ +# World overview and encounter queries + +These additive API-1/API-2 readers use the Gen1Recomp `mapOverview` and +`effectiveEncounters` result shapes. They return detached tables and do not +load maps, consume RNG, run mod callbacks, refresh objects, or change the save. +They are available through the normal lazy `mod.world` facade. Hosts with +`mod:supports` report both names on the `world` surface before Game exists. +This does not opt a mod into API 3 or change existing actions/encounter rolls. + +```lua +local overview, reason = mod.world:mapOverview() +if overview then + -- Cells and marker coordinates start at zero; Lua row arrays start at one. + local terrain = overview.rows[y + 1]:sub(x + 1, x + 1) +end + +local encounters, reason = mod.world:effectiveEncounters(mapId, "grass", { + daytime = "NITE", -- optional; MORN/MORNING, DAY, NITE/NIGHT +}) +if encounters then + local total = 0 + for species, weight in pairs(encounters.dist) do total = total + weight end + -- Conditional species share = weight / total (when total > 0). +end +``` + +## `mapOverview()` + +Requires an active overworld; menus/battles above it do not prevent reads. +At the title screen, returns `nil, "no overworld"`. + +| Field | Meaning | +| --- | --- | +| `mapId`, `width`, `height` | Active map ID and size in 16px walk cells | +| `rows` | `height` strings, each exactly `width` ASCII characters | +| `markers` | Array of detached `{kind, x, y}` records | +| `tileRows`, `tileWidth`, `tileHeight` | Optional visual shade grid at twice the cell resolution | +| `tileDetailRows`, `tileDetailWidth`, `tileDetailHeight` | Optional visual shade grid at four times the cell resolution | + +Terrain characters are `+` for a native warp tile, `~` for water, `.` for +walkable ground, and a space for a wall, in that order of precedence. This +summarizes terrain, not directional movement permissions, elevation crossings, +NPC occupancy, or a pathfinding result. Runtime block/collision patches win. +Packed GBA blocks are decoded privately; a cold map remains cold afterwards. + +Markers identify in-bounds `warp` events, `item` objects actually in the active +NPC list (at their current cells, excluding hidden objects and fruit trees), +and untaken `hidden` item spots. Hidden-item signs also honor their event flags +and their distinct native taken keys. Neighbour-map objects are not included. +The snapshot reflects the loaded object list; it does not run visibility or +spawn filters again. Hidden markers deliberately reveal item locations to the +requesting mod, as in Gen1Recomp. + +GB visual grids use the resolved, mod-overridable atlas's pixel luminance: +`"0"` is lightest, `"3"` darkest. `tileRows` averages each 8x8 tile; +`tileDetailRows` preserves each 4x4 quadrant. Missing/invalid art is omitted. +The private visual cache is invalidated with the engine's asset cache. +GBA metatile compositions and DS 3D art have no GB atlas, so visual grids +are omitted there. Terrain grids support GB/GBA maps and resident DS interior +permission grids. DS maps without a resident behaviour grid return +`nil, "map overview unavailable for streamed terrain"`; reading streamed +renderer chunks would load/cache terrain during a read query. + +## `effectiveEncounters(mapId, terrain, opts?)` + +Takes an explicit, known map ID, including at the title screen. `terrain` is +`"grass"`, `"water"`, or `"indoor"` (the grass table used by cave floors). +Returns `{chance = number, dist = {[publicSpeciesId] = integerWeight}}`. +Unknown maps, invalid terrain/options/time, unloaded encounter data and invalid +bucket/rate metadata return `nil, reason`. A known map without that table +returns `{chance = 0, dist = {}}`. + +`chance` is the table's independent integer rate gate, clamped to its native +denominator (256 by default, 100 on DS adapter tables). It is **not** the final +probability of a battle on the player's next step. `dist` aggregates consecutive +cumulative bucket differences for duplicate species. Weights are not percentages: +use their sum when computing conditional shares. Missing slots/uncovered bucket +space remain empty outcomes, just as in the native chooser. Zero-rate tables +return an empty distribution. Species IDs use the same dataset-derived public +names as the other portable readers, including numeric DS species and aliases. +Loader identity metadata is captured before presentation-name patches. + +Gen2 land tables use the current clock period, or the last effective cached +period when a `world.tod` hook is installed. `opts.daytime` overrides this +without dispatching the hook or updating the world. Active Gold/Silver/Crystal +swarm tables are selected by the native resolver. DS previews use the native +map-to-table indirection, day/night substitutions, active daily slots/outbreaks +and BW season selection. DS `daytime` selects a representative hour (6/12/22). +Water uses its own table. + +This is a **table preview**: it excludes per-step ability/radio/held-item rate +changes, movement/grace gates, Repel, roamers, lead-based slot filtering, +fishing, Radar, phenomena and dark-grass overrides. It never samples or executes +`encounter.roll`, `encounter.species`, or `world.tod`. Gen2Recomped has no native +`encounter.table` hook; this PR does not introduce one or pretend to preview +arbitrary mod callbacks. A future shared table-transform seam can extend both +the native roll and preview together. + +HGSS's position-dependent Safari Zone returns +`nil, "position-dependent Safari encounters unavailable"`; its native resolver +consults the global player's position and can initialize save state. Emerald's +Battle Pike wild room and Battle Pyramid floors return +`nil, "facility encounters unavailable"`; these use separate facility tables. +These refusals keep an explicit-map query from reporting another map's table +or changing gameplay state. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..ba7509711 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -23,6 +23,7 @@ local ModStorage = require("src.mods.Storage") local Runtime = require("src.mods.Runtime") local ModGens = require("src.mods.ModGens") local ModSeams = require("src.mods.ModSeams") +local WorldOverview = require("src.mods.WorldOverview") -- Module-name aliases for mods written against the Gold port's per-generation -- layout (src.world.gen2.Player and friends). Installed here because this is -- the module that loads mods: the searcher has to be in place before the @@ -1125,6 +1126,7 @@ function Loader:_api(mod) -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. local world, storage + local overviewContext = WorldOverview.install(api, loader) setmetatable(api, { __index = function(_, key) if key == "game" then return loader:_game() end if key == "storage" then @@ -1140,6 +1142,7 @@ function Loader:_api(mod) local module = game and engineRequire("src.world.WorldAPI") if not module then return nil end world = module.new(game, modId) + world.overviewContext = overviewContext return world end }) return api diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua new file mode 100644 index 000000000..50866a636 --- /dev/null +++ b/src/mods/UnifiedIds.lua @@ -0,0 +1,85 @@ +-- Resolve portable names through this boot's own data, never a hard-coded +-- National Dex/item-number table (ROM hacks can assign entirely different IDs). +local Ids = {} +local function slug(name) + if type(name) ~= "string" then return nil end + name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M") + :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2") + return name ~= "" and name or nil +end +local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" } +local function canonical(key, row, kind) + if kind == "pokemon" or kind == "moves" or kind == "items" then + if type(key) ~= "string" or key:match("^SPECIES_%d+$") + or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then + return aliases[slug(row.name)] or slug(row.name) + end + return aliases[key] or key + end + -- Trainer classes, individual trainers and map IDs are game-specific. + return type(key) == "number" and tostring(key) or key +end + +function Ids.new(loader) + local indexes = {} + local function index(kind) + if indexes[kind] then return indexes[kind] end + local out = { public = {}, native = {} } + indexes[kind] = out + local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind)) + -- Preserve identity/name metadata before the loader merges alias keys + -- into separate result tables. Presentation-name patches cannot rename IDs. + loader._unifiedIdBase = loader._unifiedIdBase or {} + local base = loader._unifiedIdBase[kind] + if not base then + base = {} + for key, row in pairs(registry.base and registry.base() or {}) do + if type(row) == "table" then + base[key] = { identity = row, id = canonical(key, row, kind) } + end + end + loader._unifiedIdBase[kind] = base + end + local groups = {} + for key, row in registry:each() do + if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then + -- Aliases pointing at one pristine record form one identity even + -- after an earlier native mod patched only one of its keys. + local identity = base[key] and base[key].identity or row + local group = groups[identity] + if not group then + local id = base[key] and base[key].id or canonical(key, row, kind) + if id then + group = { id = id, key = key, keys = {} } + groups[identity] = group + local previous = out.public[id] + -- false is an ambiguity marker, never last-writer-wins. + if previous ~= nil then out.public[id] = false else out.public[id] = group end + end + end + if group then + group.keys[#group.keys + 1] = key + out.native[key] = group + if type(key) == "number" then group.key = key end + end + end + end + return out + end + return { + resolve = function(_, kind, id) + local found = index(kind).public[id] + if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end + if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end + return found + end, + read = function(_, kind, id) + if id == nil or id == false or id == 0 or id == "" then return nil end + local found = index(kind).native[id] + return found and found.id or tostring(id) + end, + entries = function(_, kind) return index(kind).public end, + } +end +return Ids diff --git a/src/mods/WorldOverview.lua b/src/mods/WorldOverview.lua new file mode 100644 index 000000000..491963d48 --- /dev/null +++ b/src/mods/WorldOverview.lua @@ -0,0 +1,153 @@ +-- Read-only world summaries. Do not call timeOfDay(), rollTable(), map loading, +-- or object visibility refreshes here: each can change the live simulation. +local Ids = require("src.mods.UnifiedIds") +local GameVersion = require("src.core.GameVersion") +local Overview = {} + +function Overview.context(loader) + Ids.new(loader):entries("pokemon") + return function() + return Ids.new(loader) + end +end + +function Overview.install(api, loader) + local context = Overview.context(loader) + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "world" and (name == "mapOverview" or name == "effectiveEncounters") then + return true + end + return supports(self, surface, name) + end + end + return context +end + +local function dataIds(data) + local rows = data.pokemon or {} + return Ids.new({ + content = { + pokemon = { + base = function() + return rows + end, + each = function() + return pairs(rows) + end, + }, + }, + }) +end + +local periods = { MORN = "MORNING", MORNING = "MORNING", DAY = "DAY", NITE = "NITE", NIGHT = "NITE" } +local hours = { MORNING = 6, DAY = 12, NITE = 22 } + +function Overview.encounters(game, ow, context, mapId, terrain, opts) + if terrain ~= "grass" and terrain ~= "water" and terrain ~= "indoor" then + return nil, "invalid terrain: " .. tostring(terrain) + end + if opts ~= nil and type(opts) ~= "table" then + return nil, "opts must be a table" + end + local daytime = opts and opts.daytime + if daytime ~= nil then + daytime = type(daytime) == "string" and periods[daytime:upper()] + if not daytime then + return nil, "invalid daytime" + end + end + local data = game and game.data + if not (data and type(data.encounters) == "table") then + return nil, "encounters data not loaded" + end + local def = data.maps and data.maps[mapId] + if not def then + return nil, "unknown map: " .. tostring(mapId) + end + -- HGSS's Safari table depends on the player's position in a six-area matrix. + -- Encounter.forMap resolves it through the Game singleton and seeds Safari + -- save state. Refuse this exceptional table instead of querying another game. + if def.cameraFamily == "hgss" and require("src.world.HgssSafariZone").isSafariMap(def) then + return nil, "position-dependent Safari encounters unavailable" + end + if GameVersion.get() == "emerald" then + if + require("src.script.Gen3BattlePike").inWildRoom(mapId) + or require("src.script.Gen3BattlePyramid").onFloor(mapId) + then + return nil, "facility encounters unavailable" + end + end + local Encounter = require("src.world.Encounter") + local hour = daytime and hours[daytime] + local enc = Encounter.forMap(data, def, mapId, hour, game.save) + if not daytime then + -- A world.tod hook's most recent effective period is already on the world. + -- Calling it again would execute mod code and can emit/change world state. + local Runtime = require("src.mods.Runtime") + if Runtime.wantsHook("world.tod") and ow then + daytime = ow.tod + end + if GameVersion.isGen2() then + if not daytime then + hour = tonumber(os.date("%H")) or 12 + daytime = hour < 4 and "NITE" or hour < 10 and "MORNING" or hour < 18 and "DAY" or "NITE" + end + else + daytime = daytime or (ow and ow.tod) or "DAY" + end + end + local slots = enc and enc[terrain == "indoor" and "grass" or terrain] + if terrain ~= "water" then + slots = Encounter.atTime(slots, daytime) + end + if not slots then + return { chance = 0, dist = {} } + end + local maximum = tonumber(slots.rateMax) or 256 + local rate = tonumber(slots.rate) or 0 + local span = tonumber(slots.bucketSpan) or 256 + if + maximum <= 0 + or maximum ~= math.floor(maximum) + or maximum == math.huge + or rate ~= rate + or rate == math.huge + or rate == -math.huge + or span <= 0 + or span ~= math.floor(span) + or span == math.huge + then + return nil, "invalid encounter rate/span" + end + local dist, prev = {}, 0 + local weights = slots.buckets or require("src.world.FieldDefaults").constant(data, "encounterBuckets") + if type(weights) ~= "table" or (slots.slots ~= nil and type(slots.slots) ~= "table") then + return nil, "invalid encounter buckets/slots" + end + local ids = context and context() or dataIds(data) + for i, threshold in ipairs(weights or {}) do + if + type(threshold) ~= "number" + or threshold ~= math.floor(threshold) + or threshold < prev + or threshold > span + then + return nil, "invalid encounter buckets" + end + local slot = slots.slots and slots.slots[i] + if rate > 0 and slot and slot.species and threshold > prev then + local id = ids:read("pokemon", slot.species) + if id then + dist[id] = (dist[id] or 0) + threshold - prev + end + end + prev = threshold + end + -- Same independent integer rate gate as rollTable, without drawing a slot. + return { chance = math.max(0, math.min(maximum, math.ceil(rate))) / maximum, dist = dist } +end + +return Overview diff --git a/src/world/MapOverview.lua b/src/world/MapOverview.lua new file mode 100644 index 000000000..adf912a6b --- /dev/null +++ b/src/world/MapOverview.lua @@ -0,0 +1,176 @@ +-- Generation-neutral terrain grid; optional GB tile luminance. All cells are +-- zero based. The scratch Map shares read-only data, but never the cold block +-- decoder's mutable definition or streamed renderer. +local Map = require("src.world.Map") +local Assets = require("src.render.Assets") +local Overview = {} +local shades = {} +Assets.register(function() + shades = {} +end) + +local function digit(sum, count) + return tostring(math.max(0, math.min(3, math.floor((1 - sum / count) * 3 + 0.5)))) +end + +local function visual(map) + local ts = map.tileset + -- GBA metatiles are composed/paletted by their renderer; their opaque tileAt + -- identifiers do not index a GB atlas. DS maps do not have one either. + if + map.blockCells ~= 2 + or not ts.blocks + or type(ts.image) ~= "string" + or type(ts.tilesPerRow) ~= "number" + or ts.tilesPerRow < 1 + or ts.tilesPerRow >= math.huge + or ts.tilesPerRow ~= math.floor(ts.tilesPerRow) + then + return nil + end + local path = Assets.resolve(ts.image) + local cache = shades[path] + if not cache then + if not (love and love.image and love.image.newImageData) then + return nil + end + local ok, pixels = pcall(love.image.newImageData, path) + if not ok or not pixels then + return nil + end + cache = { pixels = pixels, tiles = {} } + shades[path] = cache + end + local rows, details, pixels = {}, {}, cache.pixels + local width, height = pixels:getDimensions() + for y = 0, map.heightCells * 2 - 1 do + local row, top, bottom = {}, {}, {} + for x = 0, map.widthCells * 2 - 1 do + local tile = map:tileAt(x, y) + if type(tile) ~= "number" or tile < 0 or tile ~= math.floor(tile) then + return nil + end + local ox, oy = tile % ts.tilesPerRow * 8, math.floor(tile / ts.tilesPerRow) * 8 + if ox + 8 > width or oy + 8 > height then + return nil + end + local key = ts.tilesPerRow .. ":" .. tile + local s = cache.tiles[key] + if not s then + local sums = { 0, 0, 0, 0 } + for py = 0, 7 do + for px = 0, 7 do + local r, g, b = pixels:getPixel(ox + px, oy + py) + local q = math.floor(py / 4) * 2 + math.floor(px / 4) + 1 + sums[q] = sums[q] + r * 0.2126 + g * 0.7152 + b * 0.0722 + end + end + s = { + digit(sums[1] + sums[2] + sums[3] + sums[4], 64), + digit(sums[1], 16), + digit(sums[2], 16), + digit(sums[3], 16), + digit(sums[4], 16), + } + cache.tiles[key] = s + end + row[#row + 1], top[#top + 1], bottom[#bottom + 1] = s[1], s[2] .. s[3], s[4] .. s[5] + end + rows[#rows + 1] = table.concat(row) + details[#details + 1], details[#details + 2] = table.concat(top), table.concat(bottom) + end + return rows, details +end + +local function integer(n) + return type(n) == "number" and n >= 0 and n < math.huge and n == math.floor(n) +end + +function Overview.build(game, ow) + local map = ow.map + local w, h = map.widthCells, map.heightCells + if not integer(w) or not integer(h) or w == 0 or h == 0 then + return nil, "invalid map dimensions" + end + -- Outdoor DS terrain lives in lazily streamed renderer chunks. Walking that + -- renderer from a query can load/cache chunks. Interior permission grids are + -- already resident and can use the same pure Map classifiers below. + if map.def.generation == 4 and not map.def.behaviorCells then + return nil, "map overview unavailable for streamed terrain" + end + local scratch, def = {}, {} + for k, v in pairs(map) do + scratch[k] = v + end + for k, v in pairs(map.def) do + def[k] = v + end + scratch.def, scratch.renderer = def, nil + setmetatable(scratch, Map) + -- blockArray() writes only into the private definition on a cold GBA map. + -- It may reuse the warm immutable array, and honors the live block patches. + local rows, markers = {}, {} + local function marker(kind, x, y) + if integer(x) and integer(y) and x < w and y < h then + markers[#markers + 1] = { kind = kind, x = x, y = y } + end + end + for _, warp in ipairs(def.warps or {}) do + marker("warp", warp.x, warp.y) + end + -- The active object list is the result of native flag, session, time and + -- scripted spawn filters. Re-running those filters can consult another Game + -- singleton or initialize sprite state. Use the objects actually present. + for _, npc in ipairs(ow.npcs or {}) do + local obj = npc.def or {} + if obj.item and obj.item ~= 0 and obj.item ~= "0" and not npc.hidden and not obj.fruitTree then + marker("item", npc.cellX, npc.cellY) + end + end + local save = game.save or {} + local hidden = game.data and game.data.field and game.data.field.hiddenItems + for _, item in ipairs(hidden and hidden[map.id] or {}) do + if not (save.hiddenTaken and save.hiddenTaken[map.id .. "_" .. item.x .. "_" .. item.y]) then + marker("hidden", item.x, item.y) + end + end + -- Gen2/3 imports also carry hidden-item sign events, with their own taken + -- key and event flag (e.g. Cerulean Gym's Machine Part). + for _, item in ipairs(def.signs or {}) do + if + item.item + and item.item ~= 0 + and item.item ~= "0" + and not (item.eventFlag and save.flags and save.flags[item.eventFlag] == true) + and not (save.hiddenTaken and save.hiddenTaken[map.id .. "_sign_" .. item.x .. "_" .. item.y]) + then + marker("hidden", item.x, item.y) + end + end + for y = 0, h - 1 do + local row = {} + for x = 0, w - 1 do + row[#row + 1] = scratch:isWarpTileCell(x, y) and "+" + or scratch:isWaterCell(x, y) and "~" + or scratch:isWalkableCell(x, y) and "." + or " " + end + rows[#rows + 1] = table.concat(row) + end + local tiles, details = visual(scratch) + return { + mapId = map.id, + width = w, + height = h, + rows = rows, + markers = markers, + tileRows = tiles, + tileWidth = tiles and w * 2, + tileHeight = tiles and h * 2, + tileDetailRows = details, + tileDetailWidth = details and w * 4, + tileDetailHeight = details and h * 4, + } +end + +return Overview diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index f21419ae9..ea1a6e542 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -9,6 +9,8 @@ local Logger = require("src.core.Logger") local MapLoader = require("src.world.MapLoader") local Gen3Elevation = require("src.world.Gen3Elevation") local Runtime = require("src.mods.Runtime") +local WorldOverview = require("src.mods.WorldOverview") +local MapOverview = require("src.world.MapOverview") local WorldAPI = {} WorldAPI.__index = WorldAPI @@ -58,6 +60,19 @@ function WorldAPI:current() elevation) } end +-- Detached, zero-based terrain and marker grids for companion UIs/minimaps. +function WorldAPI:mapOverview() + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + return MapOverview.build(self.game, ow) +end + +-- Table distribution before per-step RNG, repel, roamers and roll hooks. +-- Explicit map IDs also work at the title screen without loading that map. +function WorldAPI:effectiveEncounters(mapId, terrain, opts) + return WorldOverview.encounters(self.game, self:overworld(), self.overviewContext, mapId, terrain, opts) +end + -- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else -- lands the player without one, like a scripted warp. function WorldAPI:warpTo(mapId, x, y, facing, opts) diff --git a/tests/engine/world_overview_api.lua b/tests/engine/world_overview_api.lua new file mode 100644 index 000000000..dab0d78bd --- /dev/null +++ b/tests/engine/world_overview_api.lua @@ -0,0 +1,660 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Map = require("src.world.Map") +local WorldAPI = require("src.world.WorldAPI") +local Encounter = require("src.world.Encounter") +local Runtime = require("src.mods.Runtime") +local Hooks = require("src.mods.Hooks") +local GameVersion = require("src.core.GameVersion") +local Assets = require("src.render.Assets") +local Loader = require("src.mods.Loader") +local Json = require("src.link.Json") +local savedVersion, savedHooks = GameVersion.get(), Runtime.hooks +local function copy(t, seen) + if type(t) ~= "table" then + return t + end + seen = seen or {} + if seen[t] then + return seen[t] + end + local out = {} + seen[t] = out + for k, v in pairs(t) do + out[copy(k, seen)] = copy(v, seen) + end + return out +end +-- Exact reference/key/metatable freeze; cycles (Game -> world -> Game) are real. +local function freeze(root) + local graph = {} + local function walk(t) + if type(t) ~= "table" or graph[t] then + return + end + local keys = {} + graph[t] = { keys = keys, mt = getmetatable(t) } + for k, v in pairs(t) do + keys[k] = v + walk(k) + walk(v) + end + end + walk(root) + return function(label) + for t, before in pairs(graph) do + T.eq(getmetatable(t), before.mt, label .. " metatable") + for k, v in pairs(before.keys) do + T.eq(rawget(t, k), v, label .. " value") + end + for k, v in pairs(t) do + T.eq(before.keys[k], v, label .. " no new key") + end + end + end +end +local function pack(...) + local out = {} + for _, n in ipairs({ ... }) do + out[#out + 1] = string.char(n % 256, math.floor(n / 256)) + end + return table.concat(out) +end +local function uniform(n) + local out = {} + for i = 1, 16 do + out[i] = n + end + return out +end +local function scene(version) + GameVersion.set(version) + local gen = GameVersion.generation() + local def = { + id = "ACTIVE", + width = 2, + height = 2, + borderBlock = 0, + blocks = { 1, 2, 3, 4 }, + warps = { { x = 1, y = 1 }, { x = -1, y = 1 } }, + signs = { + { x = 0, y = 1, item = "POTION", eventFlag = "FOUND" }, + { + x = 1, + y = 0, + item = "POTION", + eventFlag = "HIDDEN", + }, + }, + } + local ts = { + blockCells = 2, + blockTiles = 4, + blocks = { uniform(0), uniform(0), uniform(1), uniform(2), uniform(3) }, + walkable = { 0, 2 }, + waterTiles = { 1 }, + shoreTiles = {}, + warpTiles = { 3 }, + image = "overview-test.png", + tilesPerRow = 4, + } + if gen == 2 then + ts.collision = { 255, 255, 255, 255, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3 } + elseif gen >= 3 then + def.blocks = pack(1 + 1024, 2 + 4096, 3, 4) + def.collisionCells = { 0, 0, 1, 0 } + def.elevationCells = gen == 3 and { 3, 1, 3, 3 } or nil + ts.blockCells, ts.blockTiles, ts.blocks, ts.collision = 1, 2, nil, { 0, 0, 1, 2, 3 } + ts.behaviourBytes = true + ts.warpsAreEvents = true + if gen >= 4 then + def.generation = 4 + def.gen5 = gen == 5 or nil + def.behaviorCells = string.char(0, gen == 5 and 0x3D or 0x10, 0, 0) + end + end + local map = Map.new(def, ts) + local ow = { + isOverworld = true, + map = map, + npcs = { + { def = { item = "POTION" }, cellX = 0, cellY = 0 }, + { def = { item = "POTION" }, cellX = 1, cellY = 0, hidden = true }, + { def = { item = "POTION", fruitTree = true }, cellX = 0, cellY = 1 }, + { def = { item = 0 }, cellX = 0, cellY = 1 }, + { def = { item = "POTION" }, cellX = 99, cellY = 99 }, + }, + tod = "NITE", + player = { cellX = 0, cellY = 0 }, + } + local game = { + data = { + maps = { ACTIVE = def }, + field = { hiddenItems = { ACTIVE = { { x = 0, y = 0 }, { x = 1, y = 1 } } } }, + }, + save = { flags = { HIDDEN = true }, hiddenTaken = { ACTIVE_1_1 = true } }, + overworld = ow, + stack = { states = { ow, { isMenu = true } } }, + } + ow.game = game + return WorldAPI.new(game, "probe"), game, ow +end +local savedLoveRandom, savedMathRandom = love.math.random, math.random +local draws = 0 +love.math.random = function() + draws = draws + 1 + error("query consumed love RNG") +end +math.random = function() + draws = draws + 1 + error("query consumed math RNG") +end +Runtime.hooks = Hooks.new() +local callbacks = 0 +for _, name in ipairs({ "encounter.roll", "encounter.species", "encounter.table" }) do + Runtime.hooks:wrap(name, function() + callbacks = callbacks + 1 + error("query executed encounter hook") + end) +end +for _, version in ipairs({ + "red", + "gold", + "silver", + "crystal", + "emerald", + "firered", + "platinum", + "heartgold", + "black", + "white", +}) do + local api, game, ow = scene(version) + for _, warm in ipairs({ false, true }) do + if warm then + ow.map:blockAt(0, 0) + end + local stable = freeze(game) + local out = assert(api:mapOverview()) + stable(version .. " overview") + T.eq(out.mapId, "ACTIVE", version .. " map ID") + T.eq(out.width, ow.map.widthCells, version .. " width") + T.eq(#out.rows, out.height, version .. " rows") + -- Native classifiers on an independent map give the exact terrain contract. + local native = Map.new(copy(ow.map.def), copy(ow.map.tileset)) + for y = 0, out.height - 1 do + T.eq(#out.rows[y + 1], out.width, version .. " row width") + for x = 0, out.width - 1 do + local expected = native:isWarpTileCell(x, y) and "+" + or native:isWaterCell(x, y) and "~" + or native:isWalkableCell(x, y) and "." + or " " + T.eq(out.rows[y + 1]:sub(x + 1, x + 1), expected, version .. " native terrain") + end + end + T.same(out.markers, { + { kind = "warp", x = 1, y = 1 }, + { kind = "item", x = 0, y = 0 }, + { kind = "hidden", x = 0, y = 0 }, + { + kind = "hidden", + x = 0, + y = 1, + }, + }, version .. " only active markers") + out.markers[1].x = 999 + out.rows[1] = "corrupted" + T.eq(api:mapOverview().markers[1].x, 1, version .. " detached markers") + end + local old = assert(api:mapOverview()) + ow.npcs = {} + game.save.flags.FOUND = true + game.save.hiddenTaken.ACTIVE_0_0 = true + T.eq(#api:mapOverview().markers, 1, version .. " live removals reflected") + T.eq(#old.markers, 4, version .. " old snapshot remains detached") + ow.map:setBlock(0, 0, 0, true) + local stable = freeze(game) + local patched = assert(api:mapOverview()) + stable(version .. " patched map") + T.eq(patched.rows[1]:sub(1, 1), " ", version .. " live shut cell") + if GameVersion.generation() >= 4 then + ow.map.def.behaviorCells = nil + local result, why = api:mapOverview() + T.eq(result, nil, version .. " streamed raster refused") + T.check(why:find("streamed", 1, true), version .. " streamed reason") + end +end +-- Literal terrain reference: whitespace is an impassable cell, not a missing row. +do + local api = scene("red") + T.same(api:mapOverview().rows, { "..~~", "..~~", "..++", "..++" }, "GB literal terrain grid") +end +-- GB visuals use actual 8x8 pixel means/quadrants; no placeholder art on failure. +do + local api, game, ow = scene("gold") + local saved = love.image + love.image = {} + local reads = 0 + love.image.newImageData = function(path) + reads = reads + 1 + T.eq(path, "overview-test.png", "resolved image path") + return { + getDimensions = function() + return 32, 8 + end, + getPixel = function(_, x, y) + local shade = math.floor(x / 8) + if shade == 2 then + shade = (x % 8 < 4 and 0 or 3) + end + local v = 1 - shade / 3 + return v, v, v, 1 + end, + } + end + Assets.invalidate() + local stable = freeze(game) + local out = assert(api:mapOverview()) + stable("visual pure") + T.eq(out.tileRows[1], "00001111", "literal GB tile shades") + T.eq(out.tileRows[5], "22223333", "literal averaged split tile") + T.eq(out.tileDetailRows[9], "0303030333333333", "literal GB quadrants") + T.eq(out.tileWidth, 8, "2x tile width") + T.eq(out.tileDetailWidth, 16, "4x detail width") + out.tileRows[1] = "broken" + out.tileDetailRows[1] = "broken" + T.eq(api:mapOverview().tileRows[1], "00001111", "detached visual rows") + T.eq(reads, 1, "visual cache reused") + Assets.invalidate() + api:mapOverview() + T.eq(reads, 2, "asset reload invalidates visual cache") + love.image.newImageData = function() + error("missing image") + end + Assets.invalidate() + T.eq(api:mapOverview().tileRows, nil, "unavailable art is omitted") + love.image = saved + Assets.invalidate() +end +local function tableDef(rate, buckets, species) + local slots = {} + for i, id in ipairs(species or { "RATTATA", "PIDGEY", "RATTATA" }) do + slots[i] = { species = id, min = 2, max = 7 } + end + return { rate = rate, buckets = buckets or { 51, 102, 256 }, slots = slots } +end +local function encounters(version) + GameVersion.set(version) + local a = tableDef(60) + a.byTime = { + morn = tableDef(80, nil, { "PIDGEY", "PIDGEY", "RATTATA" }), + nite = tableDef(20, nil, { "HOOTHOOT", "RATTATA", "HOOTHOOT" }), + } + local maps = { + A = { id = "A", label = "RouteA", encounters = GameVersion.generation() >= 4 and 7 or nil }, + B = { + id = "B", + }, + } + local data = { + maps = maps, + encounters = { + [maps.A.encounters or "A"] = { + grass = a, + water = tableDef(10, { 128, 256 }, { "MAGIKARP", "POLIWAG" }), + }, + }, + field = {}, + pokemon = { + RATTATA = { name = "Rattata" }, + PIDGEY = { name = "Pidgey" }, + HOOTHOOT = { name = "Hoothoot" }, + MAGIKARP = { name = "Magikarp" }, + POLIWAG = { name = "Poliwag" }, + }, + } + local game = { data = data, save = { party = {}, flags = {} }, stack = { states = {} } } + return WorldAPI.new(game, "probe"), game, a +end +local function nativeParity(api, game, opts) + local hour = opts.daytime == "NITE" and 22 or opts.daytime == "MORNING" and 6 or 12 + local enc = Encounter.forMap(game.data, game.data.maps.A, "A", hour, game.save) + local slots = Encounter.atTime(enc.grass, opts.daytime) + local counts = {} + Encounter.load(game.data) + for pick = 0, (slots.bucketSpan or 256) - 1 do + local n = 0 + local result = Encounter.chooseTable(slots, function(lo) + n = n + 1 + if n == 1 then + return pick + end + return lo + end) + if result then + counts[tostring(result.species)] = (counts[tostring(result.species)] or 0) + 1 + end + end + local stable = freeze(game) + local out = assert(api:effectiveEncounters("A", "grass", opts)) + stable("encounter graph") + T.same(out.dist, counts, "native exhaustive bucket distribution") + local passed = 0 + for gate = 0, (slots.rateMax or 256) - 1 do + local n = 0 + local result = Encounter.rollTable(slots, function(lo) + n = n + 1 + if n == 1 then + return gate + end + return lo + end) + if result then + passed = passed + 1 + end + end + T.eq(out.chance, passed / (slots.rateMax or 256), "native exhaustive rate gate") + return out +end +for _, version in ipairs({ "red", "gold", "silver", "crystal", "emerald", "firered" }) do + local api, game = encounters(version) + for _, period in ipairs({ "MORNING", "DAY", "NITE" }) do + nativeParity(api, game, { daytime = period }) + end + local out = api:effectiveEncounters("A", "grass", { daytime = "DAY" }) + T.same(out.dist, { RATTATA = 205, PIDGEY = 51 }, version .. " duplicate species aggregated") + out.dist.RATTATA = 0 + T.eq(api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.RATTATA, 205, "distribution detached") + T.same( + api:effectiveEncounters("A", "indoor", { daytime = "NITE" }), + api:effectiveEncounters("A", "grass", { daytime = "NITE" }), + "indoor grass alias" + ) + T.same( + api:effectiveEncounters("A", "water", { daytime = "NITE" }).dist, + { MAGIKARP = 128, POLIWAG = 128 }, + "water table" + ) + T.same(api:effectiveEncounters("B", "grass"), { chance = 0, dist = {} }, "known map with no encounters") + T.eq(api:effectiveEncounters("missing", "grass"), nil, "unknown map refused") + for _, bad in ipairs({ "fishing", "darkGrass", false }) do + T.eq(api:effectiveEncounters("A", bad), nil, "terrain refused") + end + T.eq(api:effectiveEncounters("A", "grass", "NITE"), nil, "options must be table") + T.eq(api:effectiveEncounters("A", "grass", { daytime = "dusk" }), nil, "invalid time refused") + T.same( + api:effectiveEncounters("A", "grass", { daytime = "night" }), + api:effectiveEncounters("A", "grass", { daytime = "NITE" }), + "time aliases" + ) + game.data.encounters = nil + T.eq(api:effectiveEncounters("A", "grass"), nil, "unloaded data refused") +end +-- Gen2 outbreaks use the same native map selection for all three cartridges. +for _, version in ipairs({ "gold", "silver", "crystal" }) do + local api, game = encounters(version) + game.data.field.gen2Swarms = { + maps = { RouteA = { grass = tableDef(200, nil, { "HOOTHOOT", "HOOTHOOT", "HOOTHOOT" }) } }, + flags = { [0] = 17 }, + } + game.save.g2Swarm = { kind = 0, map = "A" } + if version == "crystal" then + game.save.flags[require("src.script.Gen2Flags").scriptFlag(17)] = true + end + nativeParity(api, game, { daytime = "DAY" }) + T.same( + api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist, + { HOOTHOOT = 256 }, + version .. " outbreak table" + ) + game.save.g2Swarm = nil + T.eq( + api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.RATTATA, + 205, + "outbreak ends immediately" + ) +end +-- DS rates/bucket spans, time substitutions, daily replacements and seasons. +for _, version in ipairs({ "platinum", "heartgold", "black", "white" }) do + local api, game = encounters(version) + local slots = {} + for i, chance in ipairs({ 20, 20, 10, 10, 10, 10, 5, 5, 4, 4, 1, 1 }) do + slots[i] = { species = i % 2 == 0 and "PIDGEY" or "RATTATA", level = 5, chance = chance } + end + local raw = { + grassRate = 30, + grass = slots, + surf = { + rate = 20, + slots = { { species = "MAGIKARP", level = 5, chance = 100 } }, + }, + } + game.data.encounters[7] = raw + if version == "black" or version == "white" then + raw.family = "bw" + raw.seasons = + { winter = { grassRate = 50, grass = { { species = "HOOTHOOT", level = 5, chance = 100 } } } } + game.save.gen5Season = 3 + end + for _, period in ipairs({ "MORNING", "DAY", "NITE" }) do + nativeParity(api, game, { daytime = period }) + end + if raw.family == "bw" then + T.same(api:effectiveEncounters("A", "grass").dist, { HOOTHOOT = 100 }, "BW current season") + game.save.gen5Season = 0 + T.eq(api:effectiveEncounters("A", "grass").chance, 0.3, "BW season changes immediately") + end +end +-- Cold DS resolver views: timed slots and daily replacements are reflected +-- without initializing save state or exposing mutable source slot records. +for _, version in ipairs({ "platinum", "heartgold" }) do + local api, game = encounters(version) + local slots = {} + for i, chance in ipairs({ 20, 20, 10, 10, 10, 10, 5, 5, 4, 4, 1, 1 }) do + slots[i] = { species = "RATTATA", level = 5, chance = chance } + end + local raw = { grassRate = 30, grass = slots, day = { 25, 25 }, night = { 163, 163 }, swarm = { 129, 129 } } + if version == "heartgold" then + raw.timed = { day = {}, night = {} } + for i = 1, 12 do + raw.timed.day[i] = 25 + raw.timed.night[i] = 163 + end + end + game.data.pokemon[25] = { name = "Pikachu" } + game.data.pokemon[163] = { name = "Hoothoot" } + game.data.pokemon[129] = { name = "Magikarp" } + game.data.encounters[7] = raw + local stable = freeze(game) + local day = assert(api:effectiveEncounters("A", "grass", { daytime = "DAY" })) + local night = assert(api:effectiveEncounters("A", "grass", { daytime = "NITE" })) + T.eq(day.dist.PIKACHU, version == "heartgold" and 100 or 20, "DS time species day") + T.eq(night.dist.HOOTHOOT, version == "heartgold" and 100 or 20, "DS time species night") + stable("cold DS timed query") + local Swarms = require("src.world.Gen4Swarms") + game.data.maps.A.header = Swarms.HEADERS[1] + game.save.gen4Swarm = { enabled = true, daily = 0, rand = 123 } + stable = freeze(game) + local outbreak = assert(api:effectiveEncounters("A", "grass", { daytime = "DAY" })) + T.eq(outbreak.dist.MAGIKARP, 40, "DS daily swarm slots") + stable("DS daily swarm read") + outbreak.dist.MAGIKARP = 0 + T.eq( + api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.MAGIKARP, + 40, + "DS distribution detached" + ) + game.save.gen4Swarm.enabled = false + if version == "platinum" then + game.data.maps.A.header = 287 + game.save.pokedex = { national = true } + game.save.gen4TrophyGarden = { slot1 = 0, slot2 = 1 } + game.data.gen4_special_encounters = { trophyGarden = { 25, 163 } } + stable = freeze(game) + local garden = assert(api:effectiveEncounters("A", "grass", { daytime = "MORNING" })) + T.eq(garden.dist.PIKACHU, 5, "garden first daily slot") + T.eq(garden.dist.HOOTHOOT, 5, "garden second daily slot") + stable("DS garden read") + end +end +-- A water table is not time-swapped by the native step path. +do + local api, game = encounters("gold") + game.data.encounters.A.water.byTime = { nite = tableDef(255, { 256 }, { "HOOTHOOT" }) } + T.same( + api:effectiveEncounters("A", "water", { daytime = "NITE" }).dist, + { MAGIKARP = 128, POLIWAG = 128 }, + "water ignores land time selector" + ) +end +-- Numeric IDs follow the shared public name contract, including loader aliases. +do + local api, game = encounters("platinum") + game.data.pokemon[25] = { id = 25, name = "Pikachu" } + game.data.encounters[7] = { grass = tableDef(50, { 256 }, { 25 }) } + T.same(api:effectiveEncounters("A", "grass").dist, { PIKACHU = 256 }, "numeric species -> portable ID") +end +-- Default Gen2 period follows the clock, and queries never run world.tod hooks. +do + local api, game = encounters("gold") + local date = os.date + os.date = function(format) + if format == "%H" then + return "23" + end + return date(format) + end + T.same( + api:effectiveEncounters("A", "grass"), + api:effectiveEncounters("A", "grass", { daytime = "NITE" }), + "live Gen2 clock" + ) + Runtime.hooks:wrap("world.tod", function() + callbacks = callbacks + 1 + end) + game.overworld = { isOverworld = true, map = { id = "A" }, tod = "MORNING" } + game.stack.states = { game.overworld } + T.same( + api:effectiveEncounters("A", "grass"), + api:effectiveEncounters("A", "grass", { daytime = "MORNING" }), + "cached effective mod period" + ) + os.date = date +end +-- Malformed tables and exceptional positional tables fail explicitly. +do + local api, game, a = encounters("red") + for _, bad in ipairs({ 0, -1, math.huge, 0 / 0 }) do + a.rateMax = bad + T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid denominator") + end + a.rateMax = nil + a.buckets = { 51, 50, 256 } + T.eq(api:effectiveEncounters("A", "grass"), nil, "descending buckets refused") + a.buckets = { 51, 102, 257 } + T.eq(api:effectiveEncounters("A", "grass"), nil, "unreachable bucket refused") + a.buckets = "bad" + T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid bucket type refused") + a.buckets = { 51, 102, 256 } + local originalSlots = a.slots + a.slots = "bad" + T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid slot type refused") + a.slots = originalSlots + a.buckets = { 51, 102, 256 } + a.rate = 0 + T.same(api:effectiveEncounters("A", "grass"), { chance = 0, dist = {} }, "zero rate") + a.rate = 999 + T.eq(api:effectiveEncounters("A", "grass").chance, 1, "saturated rate") + GameVersion.set("heartgold") + local S = require("src.world.HgssSafariZone") + game.data.maps.A.layout = S.LAYOUT + game.data.maps.A.cameraFamily = "hgss" + local stable = freeze(game) + T.eq(api:effectiveEncounters("A", "grass"), nil, "Safari query refused") + stable("Safari does not seed save") + GameVersion.set("emerald") + for _, id in ipairs({ + require("src.script.Gen3BattlePike").WILD_ROOM, + require("src.script.Gen3BattlePyramid").FLOOR, + }) do + game.data.maps[id] = { id = id } + T.eq(api:effectiveEncounters(id, "grass"), nil, "facility query refused") + end +end +-- API1/2 native mod loading, lazy capability queries and live table patches. +for _, apiVersion in ipairs({ 1, 2 }) do + GameVersion.set("gold") + local data = T.fixtures.fresh() + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = apiVersion, + entry = "main.lua", + content_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified" + or nil, + callback_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified" + or nil, + script_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified" or nil, + }), + ["mods/probe/main.lua"] = "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + elseif files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolves = 0 + loader._game = function(self) + resolves = resolves + 1 + return self.game + end + T.check(loader:load(data), "real loader succeeds") + local mod = loader.apis.probe + if os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 then + T.eq(mod.contentFormat, "unified", "unified content facade") + T.eq(mod.callbackFormat, "unified", "unified callback facade") + T.eq(mod.scriptFormat, "unified", "portable script facade") + end + T.eq(resolves, 0, "entry and ID context stay lazy") + if mod.supports then + T.eq(mod:supports("world", "mapOverview"), true, "overview capability") + T.eq(mod:supports("world", "effectiveEncounters"), true, "encounter capability") + T.eq(resolves, 0, "capability query stays lazy") + end + loader.game = { data = data, save = {}, stack = { states = {} } } + local world = mod.world + T.eq(world:mapOverview(), nil, "title screen overview is quiet") + local out = assert(world:effectiveEncounters("FIX_ROUTE", "grass")) + T.check(next(out.dist) ~= nil, "query available at title screen") + local before = out.chance + local id = next(out.dist) + data.pokemon[id].name = "Renamed presentation" + T.check( + world:effectiveEncounters("FIX_ROUTE", "grass").dist[id] ~= nil, + "public identity survives name patch" + ) + data.encounters.FIX_ROUTE.grass.rate = 100 + T.check(world:effectiveEncounters("FIX_ROUTE", "grass").chance ~= before, "live data patch reflected") +end +T.eq(draws, 0, "all queries consume zero RNG draws") +T.eq(callbacks, 0, "all queries execute zero hook callbacks") +love.math.random, math.random = savedLoveRandom, savedMathRandom +Runtime.hooks = savedHooks +GameVersion.set(savedVersion) +T.finish("world overview and encounter API") From 3ed4741afb030feb8a7653a93927e83c8d6a0095 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:59:54 +0200 Subject: [PATCH 013/138] Add optional local shader presets with persistent parameters --- docs/modding.md | 2 + docs/shader-presets.md | 118 ++++++++ src/core/Game.lua | 12 + src/render/Renderer.lua | 4 + src/render/ShaderPresets.lua | 334 ++++++++++++++++++++++ src/ui/Gen3Options.lua | 1 + src/ui/Gen4Options.lua | 1 + src/ui/OptionsMenu.lua | 9 + src/ui/ShaderPresetMenu.lua | 139 +++++++++ tests/engine/shader_presets.lua | 436 +++++++++++++++++++++++++++++ tools/shader-preset-check/conf.lua | 8 + tools/shader-preset-check/main.lua | 406 +++++++++++++++++++++++++++ 12 files changed, 1470 insertions(+) create mode 100644 docs/shader-presets.md create mode 100644 src/render/ShaderPresets.lua create mode 100644 src/ui/ShaderPresetMenu.lua create mode 100644 tests/engine/shader_presets.lua create mode 100644 tools/shader-preset-check/conf.lua create mode 100644 tools/shader-preset-check/main.lua diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..60828deaa 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -89,6 +89,8 @@ fields that moved; a new map becomes a `:register`. See ## Rendering pipelines +Player-selectable local effects are described in [Optional shader presets](shader-presets.md). + Most registries hand the engine *content*. `render_pipelines` hands it *drawing*: a pipeline is a display mode a mod owns, which may replace the overworld's world pass with geometry of its own and/or post-process the diff --git a/docs/shader-presets.md b/docs/shader-presets.md new file mode 100644 index 000000000..91b016466 --- /dev/null +++ b/docs/shader-presets.md @@ -0,0 +1,118 @@ +# Optional shader presets + +OPTIONS -> SHADER PRESET opens a shared picker on GB, GBA and DS games. +The default is OFF. WARM DISPLAY and SOFT LCD are bundled, adjustable examples; +local presets appear after them in filename order. Select with A, then SELECT +on the active preset to edit its parameters. Left/Right or A changes a value; +SELECT resets that value. B/START returns. Changes use the ordinary options +save path, including existing generation-specific option overrides. + +The options and shader-control screens bypass this effect so they remain +readable. F8 turns the preset OFF and saves that choice even when another +screen captures keyboard input. A failed selection shows a separate error +page and keeps the last working preset. If a saved preset is unavailable, +startup falls back to OFF without deleting the saved choice. Missing shader +support, allocation errors and render/uniform errors also fall back to the +unfiltered input; a failing active effect is retired until explicitly selected +again. This handles reported API errors, not GPU-driver hangs or the visual +quality of arbitrary user shaders. + +## Local format + +Place a JSON descriptor and a LÖVE-compatible fragment shader in +`shader-presets/` in the active game-data folder (the normal LÖVE save folder, +portable folder, or the folder selected in the launcher). Discovery uses the +same `CacheFs.dataFs()` read union as other installation data. Reopen the +picker and select the preset again after editing its files. + +Example `shader-presets/dim.json`: + +```json +{ + "format": 1, + "name": "DIM DISPLAY", + "shader": "dim.glsl", + "parameters": [ + { "id": "brightness", "name": "BRIGHTNESS", + "min": 0.2, "max": 1.0, "step": 0.05, "default": 0.8 } + ] +} +``` + +Matching `shader-presets/dim.glsl`: + +```glsl +extern number brightness; + +vec4 effect(vec4 color, Image image, vec2 uv, vec2 pixel) { + vec4 c = Texel(image, uv) * color; + return vec4(c.rgb * brightness, c.a); +} +``` + +Descriptors are data only; no Lua is loaded. Filenames contain only letters, +digits, `_` or `-` followed by `.json`/`.glsl`; paths and subdirectories are +not accepted. Names are 1–48 printable ASCII characters and are shortened in +the fixed-width menu. Descriptors are limited to 16 KiB and shader source to +64 KiB. Invalid descriptors remain visible as INVALID; one bad file does not +hide the other presets. + +Up to eight scalar parameters are supported. Each requires a unique GLSL +identifier `id`, display `name`, finite `min < max`, positive `step` no larger +than the range, and an in-range `default`. A range may contain at most one +million steps. The compiled shader must expose each declared parameter as a +uniform. Saved values are clamped and snapped to steps; non-finite values use +the default. Defaults themselves are snapped to the same step grid, so reset +and first use agree. Parameter changes send uniforms without recompiling the shader. +`sourceSize`, `pixelScale` and names beginning with `love_` are reserved. + +Two optional uniforms are supplied when the shader uses them: + +| Uniform | Meaning | +| --- | --- | +| `extern vec2 sourceSize` | Input texture size in physical framebuffer pixels | +| `extern number pixelScale` | Renderer's physical pixels per native game pixel, at least 1 | + +Presets must use LÖVE's `effect()` convention and support the target's GLSL +or GLSL ES dialect. This feature does not translate RetroArch `.slangp` +presets. There is one shader pass and one selected preset, with no download, +Rust/native bridge, LUT graph, frame history or pass feedback. + +## Rendering and persistence + +The pass processes the completed **primary game view**: world, ordinary game +menus/dialogues, transitions and screen veils. It runs after mod +`render_pipelines.present` and before the existing GBC FX LCD effect. When +GBC colour correction from PR #89 is installed, that remains before the mod +pipelines and this preset pass. Touch controls, `render.hud` overlays and +separately composed DS lower displays retain their existing later draw paths. +A mod that fully handles `render.compose` also retains control of its output. + +OFF preserves the existing render path without enumeration, shader compilation +or another render target. Active output preserves the input's unit dimensions +and DPI scale. Targets are reused, recreated/released on resize or DPI changes, +and released with the shader on OFF or replacement. GPU state is enclosed by +`push("all")`/`pop()`; the input is never the active output target. + +The additive keys are `options.shaderPreset` (`"off"`, `"builtin:warm"`, +`"builtin:soft-lcd"`, or `"local:"`) and +`options.shaderPresetParams[presetId][parameterId]`. Values belonging to other +presets are preserved. Old option files need no migration and remain OFF. +Custom shaders can be expensive; this first version does not assign device +performance tiers or claim validation on mobile hardware. + +## Verification + +```sh +luajit tests/engine/shader_presets.lua +love tools/shader-preset-check +``` + +The ROM-free unit suite covers discovery, validation, persistence, parameter +editing, recovery, menu navigation and resource/state lifetime. The GPU tool +validates bundled sources for desktop GL and GLES, compares real pixels at DPI +1/2/2.75, checks alpha and physical-size uniforms, renders the real +`Renderer:endFrame` path, verifies OFF/identity equivalence and stage ordering, +and injects a draw error to check fallback/state restoration. Set +`SHADER_PRESET_CHECK_SHOT=/absolute/path.png` to export its comparison sheet. +It uses synthetic pixels and the engine's fallback font, with no ROM or saves. diff --git a/src/core/Game.lua b/src/core/Game.lua index 9dc07ce9d..24a715917 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1168,6 +1168,9 @@ function Game:_draw() if worldDrawn and self.overworld.sgbWorldZones then worldZones = self.overworld:sgbWorldZones() end + -- Keep shader controls readable even when a local shader paints black. + local top = self.stack:top() + Renderer.shaderPresetBypass = top and top.shaderPresetBypass or false local viewport = Renderer:endFrame(zones, worldZones) -- Persistent tool status is screen-space UI: draw it over the completed -- render pipeline with exact playfield/margin geometry, but below mobile @@ -1346,6 +1349,14 @@ end function Game:keypressed(key) -- ...ahead of the delegation below, which is unconditional. + if key == "f8" then + require("src.render.ShaderPresets"):off() + if self.save and self.save.options then + self.save.options.shaderPreset = "off" + self:writeOptions() + end + return + end if key == "f9" then return self:unstick() end if key == "f11" then return self:toggleFrameProfile() end if key == "f10" then return self:toggleModManager() end @@ -1859,6 +1870,7 @@ function Game:applyOptions(opts) -- after Tilt, so a persisted world pipeline can switch the tilt level it -- just restored back off (the two are mutually exclusive) require("src.render.Pipelines").applyOptions(opts) + require("src.render.ShaderPresets"):applyOptions(opts) require("src.render.Zoom").applyOptions(opts) require("src.render.TileRenderer").applyOptions(opts) -- returns true when a persisted GBC FX level was cleared on mobile diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 7cff87285..aad95e668 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -31,6 +31,8 @@ local function gbcFX() return GBCFXMod end +local ShaderPresets = require("src.render.ShaderPresets") + local Renderer = {} -- The Game Boy surface. WIDTH/HEIGHT are the classic dimensions every @@ -930,6 +932,7 @@ function Renderer:endFrame(zones, worldZones) or self.battleWipe.needsSource) and self.battleWipe.screenDraw local needPresent = GBCFX.active() or Pipelines.wantsPresent() + or (ShaderPresets:active() and not self.shaderPresetBypass) or battleNeedsSource local present = nil if needPresent then @@ -1401,6 +1404,7 @@ function Renderer:endFrame(zones, worldZones) -- this returns `present` unchanged and the frame is byte-identical. local composed = Pipelines.present(present, { width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present + if not self.shaderPresetBypass then composed = ShaderPresets:apply(composed, Sp) end if GBCFX.active() then -- shader grid/shadow math is in framebuffer pixels GBCFX.present(composed, Sp) diff --git a/src/render/ShaderPresets.lua b/src/render/ShaderPresets.lua new file mode 100644 index 000000000..a18645961 --- /dev/null +++ b/src/render/ShaderPresets.lua @@ -0,0 +1,334 @@ +-- Optional single-pass LÖVE shaders over the completed primary game view. +-- Metadata is JSON, never executable Lua. OFF does no discovery or GPU work. +local Json = require("src.link.Json") +local Logger = require("src.core.Logger") +local Presets = {} +Presets.__index = Presets +local ROOT, MAX_JSON, MAX_SOURCE = "shader-presets", 16384, 65536 +local function finite(n) + return type(n) == "number" and n == n and math.abs(n) < math.huge +end +local function label(s) + return type(s) == "string" and #s > 0 and #s <= 48 and not s:find("[^ -~]") +end +local function filename(s, ext) + return type(s) == "string" and #s <= 64 and s:match("^[%w_-]+%." .. ext .. "$") ~= nil +end +local function release(resource) + if resource and resource.release then + pcall(resource.release, resource) + end +end +local function value(p, n) + if not finite(n) then + return p.default + end + local steps = math.floor((math.max(p.min, math.min(p.max, n)) - p.min) / p.step + 0.5) + return math.max(p.min, math.min(p.max, p.min + steps * p.step)) +end +local function validate(raw, id) + if type(raw) ~= "table" or raw.format ~= 1 or not label(raw.name) then + return nil, "invalid preset metadata" + end + if not filename(raw.shader, "glsl") and not raw.builtinSource then + return nil, "invalid shader filename" + end + local out = { id = id, name = raw.name, shader = raw.shader, source = raw.builtinSource, parameters = {} } + local params = raw.parameters or {} + if type(params) ~= "table" or #params > 8 then + return nil, "invalid parameter list" + end + local seen = { sourceSize = true, pixelScale = true } + for k in pairs(params) do + if type(k) ~= "number" or k < 1 or k > #params or k ~= math.floor(k) then + return nil, "invalid parameter list" + end + end + for _, p in ipairs(params) do + if + type(p) ~= "table" + or type(p.id) ~= "string" + or #p.id > 32 + or not p.id:match("^[%a_][%w_]*$") + or seen[p.id] + or p.id:match("^love_") + or not label(p.name) + or not finite(p.min) + or not finite(p.max) + or p.min >= p.max + or not finite(p.max - p.min) + or not finite(p.step) + or p.step <= 0 + or p.step > p.max - p.min + or (p.max - p.min) / p.step > 1000000 + or not finite(p.default) + or p.default < p.min + or p.default > p.max + then + return nil, "invalid parameter definition" + end + seen[p.id] = true + out.parameters[#out.parameters + 1] = + { id = p.id, name = p.name, min = p.min, max = p.max, step = p.step, default = value(p, p.default) } + end + return out +end +local BUILTINS = { + { + id = "builtin:warm", + format = 1, + name = "WARM DISPLAY", + parameters = { + { id = "strength", name = "STRENGTH", min = 0, max = 1, step = 0.05, default = 0.4 }, + }, + builtinSource = [[ +extern number strength; +vec4 effect(vec4 color, Image tex, vec2 uv, vec2 pixel) { + vec4 c = Texel(tex, uv) * color; + return vec4(c.rgb * mix(vec3(1.0), vec3(1.0, 0.94, 0.80), strength), c.a); +}]], + }, + { + id = "builtin:soft-lcd", + format = 1, + name = "SOFT LCD", + parameters = { + { id = "strength", name = "STRENGTH", min = 0, max = 0.5, step = 0.05, default = 0.15 }, + }, + builtinSource = [[ +extern number strength; +extern number pixelScale; +vec4 effect(vec4 color, Image tex, vec2 uv, vec2 pixel) { + vec4 c = Texel(tex, uv) * color; + float row = mod(floor(pixel.y / max(1.0, pixelScale)), 2.0); + return vec4(c.rgb * (1.0 - strength * row), c.a); +}]], + }, +} +function Presets.new(opts) + opts = opts or {} + return setmetatable({ fs = opts.fs, graphics = opts.graphics, entries = {}, byId = {} }, Presets) +end +function Presets:_fs() + return self.fs or (love and love.filesystem and require("src.import.CacheFs").dataFs()) +end +function Presets:_graphics() + return self.graphics or (love and love.graphics) +end +local function read(fs, path, limit) + if not fs or not fs.read or not fs.getInfo then + return nil, "filesystem unavailable" + end + local ok, info = pcall(fs.getInfo, path) + if not ok or not info or info.type ~= "file" or (info.size and info.size > limit) then + return nil, "missing or oversized file: " .. path + end + local success, text = pcall(fs.read, path, limit + 1) + if not success or type(text) ~= "string" or #text > limit then + return nil, "cannot read file: " .. path + end + return text +end +function Presets:refresh() + self.entries, self.byId = {}, {} + local function add(entry) + self.entries[#self.entries + 1] = entry + self.byId[entry.id] = entry + end + for _, raw in ipairs(BUILTINS) do + add(assert(validate(raw, raw.id))) + end + local fs = self:_fs() + local ok, names = false, nil + if fs and fs.getDirectoryItems then + ok, names = pcall(fs.getDirectoryItems, ROOT) + end + if ok and type(names) == "table" then + table.sort(names) + for _, name in ipairs(names) do + if filename(name, "json") then + local id = "local:" .. name:sub(1, -6) + local text, why = read(fs, ROOT .. "/" .. name, MAX_JSON) + local parsed, raw = false, nil + if text then + parsed, raw = pcall(Json.decode, text) + end + local entry + if parsed then + -- Local metadata cannot inject a built-in source or use Lua loaders. + if type(raw) == "table" then + raw.builtinSource = nil + end + entry, why = validate(raw, id) + end + add(entry or { id = id, name = name:sub(1, -6), error = why or "invalid JSON" }) + end + end + end + self.scanned = true + return self.entries +end +function Presets:list() + if not self.scanned then + self:refresh() + end + return self.entries +end +function Presets:_fail(reason) + self.error = tostring(reason):gsub("[\r\n]+", " "):sub(1, 240) + return nil, self.error +end +function Presets:off() + release(self.shader) + release(self.output) + self.shader, self.output, self.entry, self.values = nil, nil, nil, nil + self.error = nil +end +function Presets:active() + return self.shader ~= nil +end +function Presets:activate(id, params) + if id == nil or id == "off" then + self:off() + return true + end + self:list() + local entry = self.byId[id] + if not entry then + return self:_fail("preset not found") + end + if entry.error then + return self:_fail(entry.error) + end + local source, why = entry.source + if not source then + source, why = read(self:_fs(), ROOT .. "/" .. entry.shader, MAX_SOURCE) + end + if not source then + return self:_fail(why) + end + local g = self:_graphics() + if not (g and g.newShader and g.newCanvas) then + return self:_fail("shader graphics unavailable") + end + local shader + local values = {} + local ok, err = pcall(function() + shader = g.newShader(source) + for _, p in ipairs(entry.parameters) do + assert(shader:hasUniform(p.id), "missing parameter uniform: " .. p.id) + values[p.id] = value(p, type(params) == "table" and params[p.id] or nil) + shader:send(p.id, values[p.id]) + end + end) + if not ok then + release(shader) + return self:_fail(err) + end + self:off() + self.shader, self.entry, self.values = shader, entry, values + return true +end +function Presets:applyOptions(opts) + -- Reset the previous game's selection even when this save's preset is bad. + self:off() + local id = type(opts) == "table" and opts.shaderPreset + if id == nil or id == "off" then + return true + end + local all = type(opts.shaderPresetParams) == "table" and opts.shaderPresetParams + return self:activate(id, all and all[id]) +end +function Presets:select(opts, id) + local all = type(opts.shaderPresetParams) == "table" and opts.shaderPresetParams + local ok, why = self:activate(id, all and all[id]) + if ok then + opts.shaderPreset = id or "off" + end + return ok, why +end +function Presets:setParameter(opts, id, n) + if not self.entry then + return nil, "no active preset" + end + for _, p in ipairs(self.entry.parameters) do + if p.id == id then + self.values[id] = value(p, n) + if type(opts.shaderPresetParams) ~= "table" then + opts.shaderPresetParams = {} + end + local key = self.entry.id + if type(opts.shaderPresetParams[key]) ~= "table" then + opts.shaderPresetParams[key] = {} + end + opts.shaderPresetParams[key][id] = self.values[id] + return true + end + end + return nil, "unknown parameter" +end +function Presets:apply(input, pixelScale) + if not self:active() or input == self.output then + return input + end + local g = self:_graphics() + local ok, err = pcall(function() + local w, h = input:getDimensions() + local dpi = input.getDPIScale and input:getDPIScale() or 1 + if + not self.output + or self.output:getWidth() ~= w + or self.output:getHeight() ~= h + or self.output:getDPIScale() ~= dpi + then + release(self.output) + self.output = nil + self.output = g.newCanvas(w, h, { dpiscale = dpi }) + self.output:setFilter("nearest", "nearest") + end + end) + if not ok then + self:off() + self:_fail(err) + return input + end + local pushed, pushError = pcall(g.push, "all") + if not pushed then + self:off() + self:_fail(pushError) + return input + end + ok, err = pcall(function() + g.setCanvas(self.output) + g.origin() + g.setScissor() + g.setStencilTest() + g.setDepthMode() + g.setColorMask(true, true, true, true) + g.setBlendMode("replace", "premultiplied") + g.setColor(1, 1, 1, 1) + for id, n in pairs(self.values) do + self.shader:send(id, n) + end + if self.shader:hasUniform("sourceSize") then + local w, h = input:getPixelDimensions() + self.shader:send("sourceSize", { w, h }) + end + if self.shader:hasUniform("pixelScale") then + self.shader:send("pixelScale", finite(pixelScale) and math.max(1, pixelScale) or 1) + end + g.setShader(self.shader) + g.draw(input, 0, 0) + end) + g.pop() + if not ok then + self:off() + self:_fail(err) + Logger.warn("shader preset disabled: %s", self.error) + return input + end + return self.output +end +local default = Presets.new() +default.new = Presets.new +return default diff --git a/src/ui/Gen3Options.lua b/src/ui/Gen3Options.lua index 6546c8f01..bbbdfb9a0 100644 --- a/src/ui/Gen3Options.lua +++ b/src/ui/Gen3Options.lua @@ -37,6 +37,7 @@ local Theme = require("src.ui.Theme") local Gen3Options = {} Gen3Options.__index = Gen3Options Gen3Options.isOpaque = true +Gen3Options.shaderPresetBypass = true local GBA_W, GBA_H = 240, 160 diff --git a/src/ui/Gen4Options.lua b/src/ui/Gen4Options.lua index 5f7fe8180..53f1895be 100644 --- a/src/ui/Gen4Options.lua +++ b/src/ui/Gen4Options.lua @@ -50,6 +50,7 @@ local Theme = require("src.ui.Theme") local Gen4Options = {} Gen4Options.__index = Gen4Options Gen4Options.isOpaque = true +Gen4Options.shaderPresetBypass = true local W, H = 256, 192 diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index d6d1f500d..48e099c2a 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -31,6 +31,7 @@ local Strings = require("src.core.Strings") local OptionsMenu = {} OptionsMenu.__index = OptionsMenu OptionsMenu.isOpaque = true +OptionsMenu.shaderPresetBypass = true -- Opaque full-screen menu: own MEWMON so opening OPTION from the title -- (or over the overworld) does not inherit TitleState's LOGO1 band -- that @@ -407,6 +408,14 @@ local function buildRows(game) GBCFX.setLevel(o.gbcfx) return true end }, + { id = "shaderPreset", label = Strings("SHADER PRESET"), + value = function(g) + local presets = require("src.render.ShaderPresets") + return presets.entry and presets.entry.name:sub(1, 12) or "OFF" + end, + activate = function(g) + g.stack:push(require("src.ui.ShaderPresetMenu").new(g)) + end }, { id = "zoom", label = Strings("ZOOM"), value = function(g) return Zoom.offsetLabel(g.save.options.zoom or 0) diff --git a/src/ui/ShaderPresetMenu.lua b/src/ui/ShaderPresetMenu.lua new file mode 100644 index 000000000..e85f7cfc4 --- /dev/null +++ b/src/ui/ShaderPresetMenu.lua @@ -0,0 +1,139 @@ +-- Shared small options UI, also reachable through the GBA/DS extra rows. +local Presets = require("src.render.ShaderPresets") +local OptionRows = require("src.ui.OptionRows") +local Menu = {} +Menu.__index = Menu +Menu.isOpaque = true +Menu.shaderPresetBypass = true +local function short(s, n) + s = tostring(s or "") + return #s > n and s:sub(1, n - 1) .. "~" or s +end +local function save(game) + if game.writeOptions then + game:writeOptions() + end +end +function Menu:sgbPalettes(game) + return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") +end +function Menu.parameters(game, presets) + presets = presets or Presets + local self = setmetatable( + { game = game, presets = presets, index = 1, scroll = 0, rows = {}, isParameters = true }, + Menu + ) + local entryId = presets.entry and presets.entry.id + local function current() + return presets.entry and presets.entry.id == entryId and presets.values + end + for _, parameter in ipairs(presets.entry and presets.entry.parameters or {}) do + local p = parameter + self.rows[#self.rows + 1] = { + label = short(p.name, 17), + value = function() + local values = current() + return values and ("%.3g"):format(values[p.id]) or "OFF" + end, + reset = function() + if not current() then + return + end + presets:setParameter(game.save.options, p.id, p.default) + save(game) + end, + step = function(_, dir) + local values = current() + if not values then + return + end + presets:setParameter(game.save.options, p.id, values[p.id] + dir * p.step) + save(game) + end, + } + end + return self +end +function Menu.new(game, presets) + presets = presets or Presets + local self = setmetatable({ game = game, presets = presets, index = 1, scroll = 0, rows = {} }, Menu) + self.rows[1] = { + label = "OFF", + activate = function() + presets:select(game.save.options, "off") + save(game) + end, + } + for _, record in ipairs(presets:refresh()) do + local entry = record + self.rows[#self.rows + 1] = { + presetId = entry.id, + label = short(entry.name, 17), + value = function() + return entry.error and "INVALID" + or (presets.entry and presets.entry.id == entry.id and "ACTIVE" or "SELECT") + end, + activate = function() + local ok = presets:select(game.save.options, entry.id) + if ok then + save(game) + else + game.stack:push(Menu.message(game, presets.error)) + end + end, + } + end + return self +end +function Menu.message(game, reason) + local self = setmetatable({ game = game, index = 1, scroll = 0, rows = {}, isMessage = true }, Menu) + reason = tostring(reason):gsub("[^ -~]", " ") + for i = 1, math.min(136, #reason), 34 do + local text = reason:sub(i, i + 33) + self.rows[#self.rows + 1] = { + label = text:sub(1, 17), + value = function() + return text:sub(18, 34) + end, + } + end + return self +end +function Menu:update() + local input = self.game.input + local bottom = #self.rows + 1 + if input:wasPressed("b") or input:wasPressed("start") then + self.game.stack:pop() + return + end + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or bottom + elseif input:wasPressed("down") then + self.index = self.index < bottom and self.index + 1 or 1 + elseif input:wasPressed("select") then + local row = self.rows[self.index] + if self.isParameters and row and row.reset then + row.reset() + elseif not self.isMessage and self.presets.entry and row and row.presetId == self.presets.entry.id then + self.game.stack:push(Menu.parameters(self.game, self.presets)) + end + elseif input:wasPressed("a") or input:wasPressed("left") or input:wasPressed("right") then + local row = self.rows[self.index] + if not row then + if input:wasPressed("a") then + self.game.stack:pop() + end + elseif row.step then + row.step(self.game, input:wasPressed("left") and -1 or 1) + elseif row.activate and input:wasPressed("a") then + row.activate(self.game) + end + end + self.scroll = OptionRows.clampScroll(self.index, self.scroll, #self.rows, bottom) +end +function Menu:draw() + local footer = self.isMessage and "BACK" + or (self.isParameters and "BACK / SEL RESET" or "BACK / SEL PARAMS") + OptionRows.draw(self.game, self.rows, self.index, self.scroll, footer, #self.rows + 1) +end +return Menu diff --git a/tests/engine/shader_presets.lua b/tests/engine/shader_presets.lua new file mode 100644 index 000000000..bada90583 --- /dev/null +++ b/tests/engine/shader_presets.lua @@ -0,0 +1,436 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Presets = require("src.render.ShaderPresets") +local Json = require("src.link.Json") +local Menu = require("src.ui.ShaderPresetMenu") +local SaveData = require("src.core.SaveData") +local GenOptions = require("src.core.GenOptions") +local StateStack = require("src.core.StateStack") +local source = + "extern number amount; vec4 effect(vec4 c, Image t, vec2 u, vec2 p) { return Texel(t,u)*amount*c; }" +local metadata = { + format = 1, + name = "LOCAL EFFECT", + shader = "probe.glsl", + parameters = { + { id = "amount", name = "AMOUNT", min = 0, max = 1, step = 0.1, default = 0.5 }, + }, +} +local function clone(t) + if type(t) ~= "table" then + return t + end + local out = {} + for k, v in pairs(t) do + out[k] = clone(v) + end + return out +end +local function fixture() + local files = + { ["shader-presets/probe.json"] = Json.encode(metadata), ["shader-presets/probe.glsl"] = source } + local reads = 0 + local fs = { + getDirectoryItems = function() + return { "probe.json", "ignored.lua", "../escape.json" } + end, + getInfo = function(p) + if files[p] then + return { type = "file", size = #files[p] } + end + end, + read = function(p) + reads = reads + 1 + return files[p] + end, + } + local state = + { canvas = "original", shader = "old", color = "old", blend = "old", depth = "old", transform = "old" } + local stack = {} + local count = { shader = 0, canvas = 0, released = 0, draw = 0 } + local faults = {} + local function texture(w, h, dpi) + local t = { w = w, h = h, dpi = dpi or 1 } + function t:getDimensions() + return self.w, self.h + end + function t:getWidth() + return self.w + end + function t:getHeight() + return self.h + end + function t:getDPIScale() + return self.dpi + end + function t:getPixelDimensions() + return self.w * self.dpi, self.h * self.dpi + end + function t:setFilter() end + function t:release() + T.check(not self.released, "GPU object released once") + self.released = true + count.released = count.released + 1 + end + return t + end + local g = { + newShader = function(s) + count.shader = count.shader + 1 + if faults.compile then + error("compile failed") + end + local sh = { uniforms = {} } + function sh:hasUniform(id) + return not faults.uniform and s:find(id, 1, true) ~= nil + end + function sh:send(id, v) + if faults.send then + error("uniform failed") + end + self.uniforms[id] = v + end + function sh:release() + T.check(not self.released, "shader released once") + self.released = true + count.released = count.released + 1 + end + return sh + end, + newCanvas = function(w, h, opts) + if faults.canvas then + error("allocation failed") + end + count.canvas = count.canvas + 1 + return texture(w, h, opts.dpiscale) + end, + push = function() + if faults.push then + error("stack full") + end + stack[#stack + 1] = clone(state) + end, + pop = function() + state = table.remove(stack) + end, + setCanvas = function(v) + state.canvas = v + end, + setShader = function(v) + state.shader = v + end, + setColor = function() + state.color = "changed" + end, + setBlendMode = function() + state.blend = "changed" + end, + origin = function() + state.transform = "changed" + end, + setScissor = function() + state.scissor = "changed" + end, + setStencilTest = function() + state.stencil = "changed" + end, + setDepthMode = function() + state.depth = "changed" + end, + setColorMask = function() + state.mask = "changed" + end, + draw = function(t) + count.draw = count.draw + 1 + T.check(not t.released, "input remains valid") + if faults.draw then + error("draw failed") + end + end, + } + local p = Presets.new({ fs = fs, graphics = g }) + return p, + files, + fs, + g, + faults, + count, + texture, + function() + return reads + end, + function() + return state, #stack + end +end +-- OFF is a no-I/O/no-GPU identity path, including old save files. +do + local p, files, fs, g, faults, count, texture, reads = fixture() + local input = texture(10, 8) + T.check(p:applyOptions({}), "old options accepted") + T.eq(p:apply(input), input, "OFF exact identity") + T.eq(count.shader, 0, "OFF never compiles") + T.eq(count.canvas, 0, "OFF never allocates") + T.eq(reads(), 0, "OFF never reads files") + T.eq(p.scanned, nil, "OFF does not enumerate") + T.check(p:applyOptions({ shaderPreset = "off" }), "explicit OFF accepted") +end +-- Real catalogue: bundled records, local JSON, isolated errors, no Lua loader. +do + local p, files = fixture() + local records = p:refresh() + T.eq(#records, 3, "two builtins and one local preset") + T.eq(records[3].id, "local:probe", "stable namespace") + T.eq(records[3].parameters[1].default, 0.5, "parameters copied") + files["shader-presets/probe.json"] = "not JSON" + p:refresh() + T.check(p.byId["local:probe"].error, "malformed metadata shown as invalid") + for _, field in ipairs({ "shader", "name", "format", "parameters" }) do + local raw = clone(metadata) + raw[field] = ({ shader = "../escape.glsl", name = string.rep("a", 49), format = 2, parameters = "bad" })[field] + files["shader-presets/probe.json"] = Json.encode(raw) + p:refresh() + T.check(p.byId["local:probe"].error, "invalid " .. field) + end + for _, change in ipairs({ + { id = "sourceSize" }, + { id = "love_bad" }, + { step = 0 }, + { step = 1e-300 }, + { max = 0 }, + { min = -1e308, max = 1e308 }, + { default = 2 }, + }) do + local raw = clone(metadata) + for k, v in pairs(change) do + raw.parameters[1][k] = v + end + files["shader-presets/probe.json"] = Json.encode(raw) + p:refresh() + T.check(p.byId["local:probe"].error, "invalid parameter definition") + end +end +-- An off-grid metadata default is normalized once, so reset and first use +-- agree instead of silently producing different uniform values. +do + local p, files = fixture() + local raw = clone(metadata) + raw.parameters[1].default = 0.35 + raw.parameters[1].step = 0.2 + files["shader-presets/probe.json"] = Json.encode(raw) + p:activate("local:probe") + T.eq(p.values.amount, 0.4, "default snapped to step grid") + local menu = Menu.parameters({ save = { options = {} } }, p) + p:setParameter({}, "amount", 1) + menu.rows[1].reset() + T.eq(p.values.amount, 0.4, "reset agrees with normalized default") +end +-- Parameter normalization, saved identity, graphics state and target lifetime. +do + local p, files, fs, g, faults, count, texture, reads, state = fixture() + local opts = { shaderPresetParams = { ["local:probe"] = { amount = 0.72, unknown = 5 } } } + T.check(p:select(opts, "local:probe"), "local selection compiles") + T.eq(opts.shaderPreset, "local:probe", "selected ID persisted") + T.check(math.abs(p.values.amount - 0.7) < 1e-9, "restored value snaps to step") + local input = texture(100, 75, 2) + local out = p:apply(input, 6) + T.neq(out, input, "active effect allocates output") + T.eq(out:getDPIScale(), 2, "DPI preserved") + T.eq(out:getWidth(), 100, "units preserved") + T.eq(count.canvas, 1, "one allocation") + T.eq(p:apply(input, 6), out, "target reused") + T.same( + state(), + { canvas = "original", shader = "old", color = "old", blend = "old", depth = "old", transform = "old" }, + "graphics state restored" + ) + local _, depth = state() + T.eq(depth, 0, "graphics stack balanced") + p:setParameter(opts, "amount", 0.21) + p:apply(input, 6) + T.check(math.abs(p.shader.uniforms.amount - 0.2) < 1e-9, "new parameter sent") + T.eq(count.shader, 1, "parameter edit never recompiles") + T.eq(p:setParameter(opts, "missing", 1), nil, "unknown parameter refused") + p:setParameter(opts, "amount", math.huge) + T.eq(p.values.amount, 0.5, "non-finite parameter default") + p:setParameter(opts, "amount", -3) + T.eq(p.values.amount, 0, "parameter clamp") + local resized = p:apply(texture(100, 75, 1), 3) + T.check(out.released, "DPI change releases old target") + T.eq(resized:getDPIScale(), 1, "DPI change reallocates") + local released = count.released + p:select(opts, "off") + T.eq(count.released, released + 2, "OFF releases shader and canvas") + T.eq(p:active(), false, "OFF inactive") + T.eq(opts.shaderPresetParams["local:probe"].amount, 0, "OFF retains parameter settings") +end +for _, kind in ipairs({ "compile", "uniform", "send", "canvas", "push", "draw" }) do + local p, files, fs, g, faults, count, texture, reads, state = fixture() + local opts = { shaderPreset = "off" } + local startup = kind == "compile" or kind == "uniform" or kind == "send" + if startup then + faults[kind] = true + end + local selected = p:select(opts, "local:probe") + if startup then + T.eq(selected, nil, kind .. " rejected") + T.eq(opts.shaderPreset, "off", kind .. " not saved") + T.eq(p:active(), false, kind .. " stays OFF") + else + T.check(selected, kind .. " setup") + faults[kind] = true + local input = texture(10, 8) + T.eq(p:apply(input, 1), input, kind .. " exact fallback") + T.eq(p:active(), false, kind .. " retired") + local _, depth = state() + T.eq(depth, 0, kind .. " balanced stack") + T.eq(p:apply(input, 1), input, kind .. " no repeated failing work") + end + T.check(p.error and p.error ~= "", kind .. " records error") + faults[kind] = nil + T.check(p:select(opts, "local:probe"), kind .. " explicit retry") +end +-- Failed switches preserve the last working effect; missing saved files fall +-- back to OFF without changing a stored choice or touching gameplay data. +do + local p, files, fs, g, faults, count, texture = fixture() + local opts = { shaderPreset = "builtin:warm" } + T.check(p:applyOptions(opts), "working saved preset") + local sh = p.shader + faults.compile = true + T.eq(p:select(opts, "local:probe"), nil, "broken switch rejected") + T.eq(p.shader, sh, "working shader retained") + T.eq(opts.shaderPreset, "builtin:warm", "working saved choice retained") + faults.compile = nil + opts.shaderPreset = "local:deleted" + local before = clone(opts) + T.eq(p:applyOptions(opts), nil, "missing saved preset refused") + T.eq(p:active(), false, "missing saved preset clears previous game shader") + T.same(opts, before, "restore does not rewrite options") + files["shader-presets/probe.glsl"] = string.rep("x", 65537) + T.eq(p:activate("local:probe"), nil, "oversized source refused") +end +-- Selection, parameter controls/reset, error page, scrolling and back use the +-- real state stack; controls bypass the selected shader so they stay readable. +do + local p = fixture() + local key, writes = nil, 0 + local game = { + save = { options = {} }, + stack = setmetatable({ states = {} }, { __index = StateStack }), + input = { + wasPressed = function(_, k) + return k == key + end, + }, + writeOptions = function() + writes = writes + 1 + end, + } + local menu = Menu.new(game, p) + game.stack:push(menu) + T.eq(menu.shaderPresetBypass, true, "shader picker readable") + local function press(k) + key = k + game.stack:top():update() + key = nil + end + press("down") + press("a") + T.eq(game.save.options.shaderPreset, "builtin:warm", "A selects preset") + press("select") + local params = game.stack:top() + T.eq(params.isParameters, true, "SELECT opens parameters") + press("right") + T.check(p.values.strength > 0.4, "RIGHT edits") + press("select") + T.eq(p.values.strength, 0.4, "SELECT resets parameter") + press("b") + T.eq(game.stack:top(), menu, "back from parameters") + press("up") + press("a") + T.eq(game.save.options.shaderPreset, "off", "OFF selection") + press("up") + press("a") + T.eq(game.stack:top(), nil, "fixed BACK row exits") + T.check(writes >= 4, "changes persist immediately") + local msg = Menu.message(game, string.rep("long compile error ", 30)) + T.eq(#msg.rows, 4, "error page bounded") + for _, row in ipairs(msg.rows) do + T.check(#row.label <= 17 and #row.value() <= 17, "error text fits") + end +end +-- Normal save serialization and per-generation option folds preserve both keys. +do + local options = + { shaderPreset = "local:probe", shaderPresetParams = { ["local:probe"] = { amount = 0.8 } } } + local round = SaveData.mergeOptions(SaveData.decode(SaveData.encode(options))) + T.same(round.shaderPresetParams, options.shaderPresetParams, "parameter save round trip") + local base = { shaderPreset = "off", shaderPresetParams = {}, perGeneration = { [2] = options } } + local resolved = GenOptions.resolve(base, 2) + T.eq(resolved.shaderPreset, "local:probe", "generation-specific preset") + resolved.shaderPreset = "builtin:warm" + local folded = GenOptions.fold(base, resolved, 2) + T.eq(folded.shaderPreset, "off", "shared setting unchanged") + T.eq(folded.perGeneration[2].shaderPreset, "builtin:warm", "scoped write retained") +end +-- Options routing and emergency reset through the actual game entry point. +do + local Game = require("src.core.Game") + local rows = require("src.ui.OptionsMenu").buildRows({ save = { options = {} } }) + local row + for _, r in ipairs(rows) do + if r.id == "shaderPreset" then + row = r + end + end + T.check(row and row.activate, "shared shader options row") + Presets.entry = { name = string.rep("X", 48) } + T.check(128 + #row.value({}) * 8 <= 232, "preset value fits GBA value column with margin") + Presets.entry = nil + T.eq(require("src.ui.OptionsMenu").shaderPresetBypass, true, "GB options bypass") + T.eq(require("src.ui.Gen3Options").shaderPresetBypass, true, "GBA options bypass") + T.eq(require("src.ui.Gen4Options").shaderPresetBypass, true, "DS options bypass") + for _, spec in ipairs({ { "emerald", "src.ui.Gen3Options" }, { "platinum", "src.ui.Gen4Options" } }) do + local GV = require("src.core.GameVersion") + local before = GV.get() + GV.set(spec[1]) + local screen = require(spec[2]).new({ data = { constants = {} }, save = { options = {} } }) + local found = false + for _, r in ipairs(screen.rows) do + if r.key == "shaderPreset" and r.engine and r.engine.activate then + found = true + end + end + T.check(found, spec[1] .. " shader option reachable") + GV.set(before) + end + local writes = 0 + local game = { + save = { options = { shaderPreset = "local:bad" } }, + writeOptions = function() + writes = writes + 1 + end, + stack = { + top = function() + return { + onKeyPressed = function() + error("F8 was captured") + end, + } + end, + }, + } + Game.keypressed(game, "f8") + T.eq(game.save.options.shaderPreset, "off", "F8 saves OFF") + T.eq(writes, 1, "F8 writes once before screen capture") + local p = fixture() + p:activate("builtin:warm") + local menu = Menu.parameters(game, p) + p:off() + T.eq(menu.rows[1].value(), "OFF", "stale parameter page stays readable after reset") + menu.rows[1].step(game, 1) + menu.rows[1].reset() +end +Presets:off() +T.finish("shader presets") diff --git a/tools/shader-preset-check/conf.lua b/tools/shader-preset-check/conf.lua new file mode 100644 index 000000000..5f3917ef7 --- /dev/null +++ b/tools/shader-preset-check/conf.lua @@ -0,0 +1,8 @@ +function love.conf(t) + t.identity = "gen2-shader-preset-check" + t.window.title = "Gen2Recomped shader preset checks" + t.window.width, t.window.height = 640, 576 + t.window.resizable = true + t.window.highdpi = true + t.modules.audio = false +end diff --git a/tools/shader-preset-check/main.lua b/tools/shader-preset-check/main.lua new file mode 100644 index 000000000..cbc04fb47 --- /dev/null +++ b/tools/shader-preset-check/main.lua @@ -0,0 +1,406 @@ +-- Run from the checkout: love tools/shader-preset-check +-- Synthetic pixels and the ROM-free font fixture; writes no game settings. +local root = love.filesystem.getSource():match("^(.*)/tools/shader%-preset%-check$") + or love.filesystem.getWorkingDirectory() +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +love.filesystem.mount(root, "", true) +local Presets = require("src.render.ShaderPresets") +local Json = require("src.link.Json") +local Renderer = require("src.render.Renderer") +local Pipelines = require("src.render.Pipelines") +local GBCFX = require("src.render.GBCFX") +local Correction = package.loaded["src.render.GbcColorCorrection"] +local Menu = require("src.ui.ShaderPresetMenu") +local Font = require("src.render.Font") +local Palette = require("src.render.PaletteFX") +local Version = require("src.core.GameVersion") +local checks = 0 +local function check(ok, message) + checks = checks + 1 + assert(ok, message) +end +local function near(a, b) + return math.abs(a - b) < 1.1 / 255 +end +local function equal(a, b, label) + check(a:getWidth() == b:getWidth() and a:getHeight() == b:getHeight(), label .. " dimensions") + for y = 0, a:getHeight() - 1 do + for x = 0, a:getWidth() - 1 do + local av, bv = { a:getPixel(x, y) }, { b:getPixel(x, y) } + for i = 1, 4 do + check(near(av[i], bv[i]), label .. " pixel " .. x .. "," .. y .. " channel " .. i) + end + end + end +end +local files = {} +local fs = { + getDirectoryItems = function() + return { "identity.json", "bad.json", "sizes.json" } + end, + getInfo = function(p) + if files[p] then + return { type = "file", size = #files[p] } + end + end, + read = function(p) + return files[p] + end, +} +local identity = "vec4 effect(vec4 c, Image t, vec2 u, vec2 p) { return Texel(t,u)*c; }" +files["shader-presets/identity.json"] = + Json.encode({ format = 1, name = "IDENTITY", shader = "identity.glsl" }) +files["shader-presets/identity.glsl"] = identity +files["shader-presets/bad.json"] = Json.encode({ format = 1, name = "BAD SHADER", shader = "bad.glsl" }) +files["shader-presets/bad.glsl"] = "THIS IS NOT GLSL" +files["shader-presets/sizes.json"] = Json.encode({ format = 1, name = "DIMENSIONS", shader = "sizes.glsl" }) +files["shader-presets/sizes.glsl"] = [[ +extern vec2 sourceSize; +extern number pixelScale; +vec4 effect(vec4 c, Image t, vec2 u, vec2 p) { return vec4(sourceSize / 1024.0,pixelScale / 16.0,1.0); } +]] +Presets.fs = fs +local panels = {} +local function inputCanvas(w, h, dpi) + local pw, ph = math.floor(w * dpi + 0.5), math.floor(h * dpi + 0.5) + local pixels = love.image.newImageData(pw, ph) + for y = 0, ph - 1 do + for x = 0, pw - 1 do + pixels:setPixel(x, y, x / (pw - 1), y / (ph - 1), 0.4, (x % 3 + 1) / 3) + end + end + local image = love.graphics.newImage(pixels) + local canvas = love.graphics.newCanvas(w, h, { dpiscale = dpi }) + love.graphics.push("all") + love.graphics.setCanvas(canvas) + love.graphics.origin() + love.graphics.setBlendMode("replace", "premultiplied") + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(image, 0, 0, 0, 1 / dpi, 1 / dpi) + love.graphics.pop() + image:release() + return canvas, pixels +end +local function run() + -- Exercise the actual mounted game-data filesystem, not only injected fs. + local dir = "shader-presets" + love.filesystem.createDirectory(dir) + local descriptor = dir .. "/qa_disk_roundtrip.json" + local fragment = dir .. "/qa_disk_roundtrip.glsl" + assert( + love.filesystem.write( + descriptor, + Json.encode({ format = 1, name = "DISK CHECK", shader = "qa_disk_roundtrip.glsl" }) + ) + ) + assert(love.filesystem.write(fragment, identity)) + local disk = Presets.new() + disk:refresh() + check(disk.byId["local:qa_disk_roundtrip"] ~= nil, "real filesystem discovery") + check(disk:activate("local:qa_disk_roundtrip"), "real filesystem source read and compile") + disk:off() + love.filesystem.remove(descriptor) + love.filesystem.remove(fragment) + + for _, entry in ipairs(Presets:refresh()) do + if entry.source then + for _, gles in ipairs({ false, true }) do + local ok, why = love.graphics.validateShader(gles, entry.source) + check(ok, "GL/GLES shader validation " .. entry.id .. ": " .. tostring(why)) + end + end + end + for _, dpi in ipairs({ 1, 2, 2.75 }) do + local input, original = inputCanvas(32, 24, dpi) + Presets:off() + check(Presets:apply(input, 4) == input, "OFF returns exact input") + check(Presets.output == nil, "OFF no target") + assert(Presets:activate("local:identity")) + local pixels = Presets:apply(input, 4):newImageData() + equal(pixels, original, "identity DPI " .. dpi) + pixels:release() + local opts = {} + assert(Presets:select(opts, "builtin:warm")) + for _, strength in ipairs({ 0, 0.4, 1 }) do + assert(Presets:setParameter(opts, "strength", strength)) + local out = Presets:apply(input, 4) + check(out:getDPIScale() == input:getDPIScale(), "preserves DPI") + pixels = out:newImageData() + for y = 0, pixels:getHeight() - 1 do + for x = 0, pixels:getWidth() - 1 do + local r, g, b, a = original:getPixel(x, y) + local actual = { pixels:getPixel(x, y) } + local expected = { r, g * (1 - 0.06 * strength), b * (1 - 0.2 * strength), a } + for i = 1, 4 do + check(near(actual[i], expected[i]), "warm RGB/alpha at DPI " .. dpi) + end + end + end + pixels:release() + end + assert(Presets:select(opts, "builtin:soft-lcd")) + assert(Presets:setParameter(opts, "strength", 0.5)) + pixels = Presets:apply(input, 4):newImageData() + for y = 0, pixels:getHeight() - 1 do + for x = 0, pixels:getWidth() - 1 do + local expected = { original:getPixel(x, y) } + local scale = 1 - 0.5 * (math.floor((y + 0.5) / 4) % 2) + local actual = { pixels:getPixel(x, y) } + for i = 1, 3 do + check(near(actual[i], expected[i] * scale), "LCD grid pixel scale at DPI " .. dpi) + end + check(near(actual[4], expected[4]), "LCD alpha preserved") + end + end + pixels:release() + assert(Presets:activate("local:sizes")) + pixels = Presets:apply(input, 6):newImageData() + local r, g, b = pixels:getPixel(0, 0) + check( + near(r, original:getWidth() / 1024) and near(g, original:getHeight() / 1024) and near(b, 6 / 16), + "physical source size uniforms" + ) + pixels:release() + original:release() + input:release() + end + -- Real graphics state is restored, even when a draw throws after setup. + local input, original = inputCanvas(12, 8, 1) + assert(Presets:activate("builtin:warm")) + local previous = love.graphics.newCanvas(4, 4) + local shader = love.graphics.newShader(identity) + love.graphics.setCanvas(previous) + love.graphics.setShader(shader) + love.graphics.translate(3, 5) + love.graphics.setColor(0.2, 0.3, 0.4, 0.5) + love.graphics.setScissor(1, 2, 2, 1) + love.graphics.setBlendMode("add", "premultiplied") + local depth = love.graphics.getStackDepth() + local realDraw = love.graphics.draw + love.graphics.draw = function() + error("injected GPU draw failure") + end + check(Presets:apply(input, 2) == input, "draw failure identity fallback") + love.graphics.draw = realDraw + check(not Presets:active(), "broken effect retired") + check( + love.graphics.getCanvas() == previous and love.graphics.getShader() == shader, + "canvas/shader restored" + ) + check(love.graphics.getStackDepth() == depth, "stack restored") + local r, g, b, a = love.graphics.getColor() + check(near(r, 0.2) and near(g, 0.3) and near(b, 0.4) and near(a, 0.5), "color restored") + local x, y = love.graphics.transformPoint(0, 0) + check(x == 3 and y == 5, "transform restored") + local blend, alpha = love.graphics.getBlendMode() + check(blend == "add" and alpha == "premultiplied", "blend restored") + local sx, sy, sw, sh = love.graphics.getScissor() + check(sx == 1 and sy == 2 and sw == 2 and sh == 1, "scissor restored") + love.graphics.setCanvas() + love.graphics.setShader() + love.graphics.origin() + love.graphics.setScissor() + love.graphics.setBlendMode("alpha") + love.graphics.setColor(1, 1, 1, 1) + previous:release() + shader:release() + input:release() + original:release() + local opts = { shaderPreset = "builtin:warm" } + assert(Presets:applyOptions(opts)) + local old = Presets.shader + check(not Presets:select(opts, "local:bad"), "real compile error rejected") + check( + Presets.shader == old and opts.shaderPreset == "builtin:warm", + "bad selection retains last working effect" + ) + Presets:off() + GBCFX.setLevel(0) + Palette.setMode("dmg") + Version.set("red") + local fixture = require("tests.modkit.fixtures").fresh() + fixture.font.source = "Gen2 scaffold" + Font.load(fixture) + Renderer:init() +end +local phase, capturing, baseline = 1, false, nil +local resizeBaseline +function love.load() + local ok, err = xpcall(run, debug.traceback) + if not ok then + io.stderr:write(err .. "\n") + love.event.quit(1) + end +end +local function drawScene() + Renderer:setUISize( + phase >= 9 and 256 or phase >= 7 and 240 or 160, + phase >= 9 and 192 or phase >= 7 and 160 or 144 + ) + Renderer:beginFrame() + love.graphics.clear(1, 1, 1, 1) + love.graphics.setColor(0.15, 0.35, 0.7, 1) + love.graphics.rectangle("fill", 0, 0, 160, 72) + love.graphics.setColor(0, 0, 0, 1) + Font.draw("SHADER PRESET TEST", 8, 88) + Font.draw("WORLD + DIALOGUE", 8, 104) + love.graphics.setColor(1, 1, 1, 1) +end +function love.draw() + if phase == 11 then + love.graphics.clear(0.2, 0.2, 0.2, 1) + love.graphics.setColor(1, 1, 1, 1) + for i, image in ipairs(panels) do + love.graphics.draw( + image, + ((i - 1) % 3) * 320, + math.floor((i - 1) / 3) * 288, + 0, + 320 / image:getWidth(), + 288 / image:getHeight() + ) + end + if not capturing then + capturing = true + love.graphics.captureScreenshot(function(pixels) + local path = os.getenv("SHADER_PRESET_CHECK_SHOT") + if path then + local png = pixels:encode("png") + local f = assert(io.open(path, "wb")) + f:write(png:getString()) + f:close() + png:release() + end + pixels:release() + print(("SHADER PRESETS GPU: %d checks passed"):format(checks)) + love.event.quit(0) + end) + end + return + end + if capturing then + return + end + local ok, err = xpcall(function() + local trace = {} + if phase == 1 or phase == 4 or phase == 7 or phase == 9 then + Presets:off() + elseif phase == 2 or phase == 8 or phase == 10 then + assert(Presets:activate("local:identity")) + elseif phase == 3 then + assert(Presets:activate("builtin:warm", { strength = 1 })) + elseif phase == 5 then + assert(Presets:activate("builtin:soft-lcd", { strength = 0.3 })) + end + drawScene() + local oldP, oldS, oldF = Pipelines.present, Presets.apply, GBCFX.present + local oldC = Correction and Correction.apply + if Correction then + if phase == 5 then + Version.set("gold") + elseif phase == 6 then + Version.set("red") + end + Palette.setMode(phase == 5 and "gbc" or "dmg") + Correction.setMode(phase == 5 and "accurate" or "off") + if phase == 5 then + check(Correction.active(), "colour correction and preset active together") + end + Correction.apply = function(...) + trace[#trace + 1] = "correction" + return oldC(...) + end + end + Pipelines.present = function(...) + trace[#trace + 1] = "pipeline" + return oldP(...) + end + Presets.apply = function(...) + trace[#trace + 1] = "preset" + return oldS(...) + end + GBCFX.present = function(...) + trace[#trace + 1] = "gbc" + return oldF(...) + end + GBCFX.setLevel(phase == 5 and 1 or 0) + Renderer.shaderPresetBypass = phase == 6 + if phase == 6 then + local game = { + data = require("tests.modkit.fixtures").fresh(), + save = { options = { shaderPreset = "builtin:soft-lcd" } }, + stack = {}, + } + Menu.new(game):draw() + end + Renderer:endFrame() + Pipelines.present, Presets.apply, GBCFX.present = oldP, oldS, oldF + if Correction then + Correction.apply = oldC + end + if phase == 5 then + check( + table.concat(trace, ",") == (Correction and "correction,pipeline,preset,gbc" or "pipeline,preset,gbc"), + "final stage ordering" + ) + end + if phase == 6 then + check(not table.concat(trace, ","):find("preset", 1, true), "shader controls bypass preset") + end + capturing = true + love.graphics.captureScreenshot(function(pixels) + local captured, why = xpcall(function() + if phase == 1 then + baseline = pixels:clone() + elseif phase == 2 or phase == 4 then + equal(pixels, baseline, "Renderer identity/OFF") + elseif phase == 3 then + local changed = false + for y = 0, pixels:getHeight() - 1, 8 do + for x = 0, pixels:getWidth() - 1, 8 do + local _, g, b = pixels:getPixel(x, y) + local _, bg, bb = baseline:getPixel(x, y) + if math.abs(g - bg) > 0.01 or math.abs(b - bb) > 0.01 then + changed = true + end + end + end + check(changed, "Renderer active preset changes final frame") + end + if phase == 7 or phase == 9 then + if resizeBaseline then + resizeBaseline:release() + end + resizeBaseline = pixels:clone() + elseif phase == 8 or phase == 10 then + equal(pixels, resizeBaseline, "Renderer resized native surface identity") + end + if phase <= 6 then + local image = love.graphics.newImage(pixels) + image:setFilter("nearest", "nearest") + panels[#panels + 1] = image + end + pixels:release() + phase = phase + 1 + capturing = false + if phase == 7 then + Version.set("emerald") + love.window.setMode(480, 432, { highdpi = true }) + elseif phase == 9 then + Version.set("platinum") + love.window.setMode(800, 600, { highdpi = true }) + elseif phase == 11 then + love.window.setMode(960, 576, { resizable = false }) + end + end, debug.traceback) + if not captured then + io.stderr:write(why .. "\n") + love.event.quit(1) + end + end) + end, debug.traceback) + if not ok then + io.stderr:write(err .. "\n") + love.event.quit(1) + end +end From 7f7e92d79f58d2a94302195c41f9ddd8ea80c854 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:06:42 +0200 Subject: [PATCH 014/138] Add preflighted single-pass RetroArch shader import --- .github/workflows/shader-import.yml | 55 + docs/shader-presets.md | 98 +- src/render/ShaderPresets.lua | 113 +- tests/engine/slang_presets.lua | 234 ++++ tools/slang-import-check/conf.lua | 7 + tools/slang-import-check/main.lua | 221 ++++ tools/slang-import/.gitignore | 1 + tools/slang-import/Cargo.lock | 1073 ++++++++++++++++++ tools/slang-import/Cargo.toml | 14 + tools/slang-import/fixtures/probe.inc | 1 + tools/slang-import/fixtures/probe.slang | 33 + tools/slang-import/fixtures/probe.slangp | 8 + tools/slang-import/fixtures/reference.slangp | 3 + tools/slang-import/fixtures/wrap.slang | 13 + tools/slang-import/fixtures/wrap.slangp | 4 + tools/slang-import/src/lib.rs | 374 ++++++ tools/slang-import/src/main.rs | 38 + tools/slang-import/src/validate.lua | 55 + tools/slang-import/tests/import.rs | 165 +++ 19 files changed, 2497 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/shader-import.yml create mode 100644 tests/engine/slang_presets.lua create mode 100644 tools/slang-import-check/conf.lua create mode 100644 tools/slang-import-check/main.lua create mode 100644 tools/slang-import/.gitignore create mode 100644 tools/slang-import/Cargo.lock create mode 100644 tools/slang-import/Cargo.toml create mode 100644 tools/slang-import/fixtures/probe.inc create mode 100644 tools/slang-import/fixtures/probe.slang create mode 100644 tools/slang-import/fixtures/probe.slangp create mode 100644 tools/slang-import/fixtures/reference.slangp create mode 100644 tools/slang-import/fixtures/wrap.slang create mode 100644 tools/slang-import/fixtures/wrap.slangp create mode 100644 tools/slang-import/src/lib.rs create mode 100644 tools/slang-import/src/main.rs create mode 100644 tools/slang-import/src/validate.lua create mode 100644 tools/slang-import/tests/import.rs diff --git a/.github/workflows/shader-import.yml b/.github/workflows/shader-import.yml new file mode 100644 index 000000000..2fdb12b88 --- /dev/null +++ b/.github/workflows/shader-import.yml @@ -0,0 +1,55 @@ +name: shader import +on: + pull_request: + paths: + - '.github/workflows/shader-import.yml' + - 'tools/slang-import/**' + - 'tools/slang-import-check/**' + - 'src/render/ShaderPresets.lua' + - 'tests/engine/slang_presets.lua' + push: + branches: [main, dev] + paths: + - '.github/workflows/shader-import.yml' + - 'tools/slang-import/**' + - 'tools/slang-import-check/**' + - 'src/render/ShaderPresets.lua' + - 'tests/engine/slang_presets.lua' +permissions: + contents: read +concurrency: + group: shader-import-${{ github.ref }} + cancel-in-progress: true +jobs: + importer: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - name: install Rust + run: rustup toolchain install 1.99.1 --profile minimal --component rustfmt --component clippy + - name: format + run: cargo +1.99.1 fmt --manifest-path tools/slang-import/Cargo.toml --check + - name: lint + run: cargo +1.99.1 clippy --manifest-path tools/slang-import/Cargo.toml --locked --release --all-targets -- -D warnings + - name: compiler, rejection and publication tests + run: cargo +1.99.1 test --manifest-path tools/slang-import/Cargo.toml --locked --release + - name: install LÖVE and virtual display + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y love xvfb + - name: import fixtures and compare GPU pixels + if: runner.os == 'Linux' + shell: bash + env: + LIBGL_ALWAYS_SOFTWARE: '1' + run: | + destination="$RUNNER_TEMP/slang-presets" + mkdir -p "$destination" + for name in probe wrap; do + xvfb-run -a tools/slang-import/target/release/gen2-slang-import \ + "tools/slang-import/fixtures/$name.slangp" "$destination" --name "${name^^}" + done + SLANG_IMPORT_CHECK_DIR="$destination" xvfb-run -a love tools/slang-import-check diff --git a/docs/shader-presets.md b/docs/shader-presets.md index 91b016466..9af0eeebc 100644 --- a/docs/shader-presets.md +++ b/docs/shader-presets.md @@ -74,9 +74,9 @@ Two optional uniforms are supplied when the shader uses them: | `extern number pixelScale` | Renderer's physical pixels per native game pixel, at least 1 | Presets must use LÖVE's `effect()` convention and support the target's GLSL -or GLSL ES dialect. This feature does not translate RetroArch `.slangp` -presets. There is one shader pass and one selected preset, with no download, -Rust/native bridge, LUT graph, frame history or pass feedback. +or GLSL ES dialect. The offline `.slangp` importer described below can generate +compatible local presets. There is one selected preset; the game loads no +native compiler or shader-pack downloader. ## Rendering and persistence @@ -116,3 +116,95 @@ validates bundled sources for desktop GL and GLES, compares real pixels at DPI and injects a draw error to check fallback/state restoration. Set `SHADER_PRESET_CHECK_SHOT=/absolute/path.png` to export its comparison sheet. It uses synthetic pixels and the engine's fallback font, with no ROM or saves. + +## Importing RetroArch `.slangp` presets + +The optional offline importer builds on this preset picker. It uses +librashader 0.12 (preprocessing, SPIR-V compilation/reflection and GLSL +emission), adapts both vertex and fragment stages to LÖVE, and writes a +local preset. The game does not load Rust, invoke a compiler or download +shader packs. Conversion is a command-line step; there is no `.slangp` +file picker in the game in this PR. + +Install Rust and LÖVE 11.5 on a desktop with a working graphics display, +then run from the checkout: + +```sh +cargo build --manifest-path tools/slang-import/Cargo.toml --release --locked +# Supply your own shader pack and the active game-data folder. +tools/slang-import/target/release/gen2-slang-import \ + /path/to/slang-shaders/handheld/lcd3x.slangp \ + /path/to/game-data/shader-presets --name "LCD3X" +``` + +Windows uses `gen2-slang-import.exe`. If `love` is not on PATH, add +`--love /absolute/path/to/love` (on macOS, the executable inside +`love.app/Contents/MacOS/love`). Building the importer needs a C++ compiler +for its compiler dependencies; it is independent of normal game builds. +Cargo.lock pins the dependency graph. CI builds/tests the tool on Linux, +macOS and Windows and exercises LÖVE under a virtual display on Linux. + +The tool follows local preset references and shader includes through +librashader. Before writing any preset files it validates both GL 1.20 and +GLES 1.00 shader pairs with LÖVE, compiles the current GPU's pair, checks +parameter/size/sampler uniforms and NPOT wrapping, and renders/reads back a +small test target. A failed conversion or preflight leaves the destination +untouched. Success writes four GLSL files and one JSON descriptor, with +content-derived names and exclusive file creation. Existing files are never +overwritten; a duplicate import reports an error. Sources are synced before +the descriptor is written; a handled write failure removes only that import's +new files. A process interruption can leave orphaned files, which can be +removed manually. Reopen OPTIONS -> SHADER PRESET to discover the result. +Keep the original shader pack and its licence notices; converted shaders +remain subject to their original licences. No third-party shader pack is +bundled here. + +This first importer intentionally supports a bounded subset: + +- One pass with default output scaling or explicit viewport scaling of 1x. +- `Source`/`Original` samplers, `SourceSize`/`OriginalSize`, `OutputSize`, + `FinalViewportSize`, the vertex MVP and up to eight scalar parameters, + including preset overrides. Optimized-out parameters remain editable but + have no GPU uniform to send. +- Nearest/linear sampling and clamp-to-border, clamp-to-edge, repeat or + mirrored-repeat addressing when supported by the target GPU. +- Normal RGBA8 output. LUTs, multipass graphs, history/feedback textures, + mipmaps, float/sRGB targets, other scaling, temporal/sensor/HDR uniforms, + unsigned arithmetic and other unsupported GLSL interfaces are rejected. + +LCD3x and zfast LCD are examples of this subset. Import success is not a +claim that every RetroArch shader works, or that a shader tested on desktop +will compile or perform well on mobile/console GPUs. GLES is validated as +shader source on desktop; mobile hardware execution needs separate testing. + +Generated descriptors use `format: 2`, add `vertex`, `esShader`, `esVertex`, +`filter` and `wrap`, and retain the same parameter and persistence schema. +The renderer selects the matching GL/GLES pair. Generated uniform names are +reserved: `g2_p_`, `g2_Source`, and `g2_`. +Native format-1 presets keep their existing behavior. + +For format 2, a private canvas reconstructs the completed primary view on its +native pixel grid: physical input dimensions divided by the renderer's pixel +scale, rounded to the nearest positive integer. `SourceSize` and +`OriginalSize` describe that actual texture as `(w,h,1/w,1/h)`; +`OutputSize` and `FinalViewportSize` describe the physical output. The primary +view includes its current widescreen area and any letterboxing, rather than +an independent original cartridge framebuffer. A mod's preceding presentation +pass is sampled as supplied. Filter/wrap changes apply only to the private +texture. Output dimensions/DPI and the stage ordering above are preserved; +resize, scale changes, replacement, OFF and failures release owned targets. +An imported selection is compiled and checked before replacing a working +preset; later render failures use the existing OFF fallback. + +```sh +cargo test --manifest-path tools/slang-import/Cargo.toml --release --locked +luajit tests/engine/slang_presets.lua +# Import the original PROBE and WRAP fixtures using the command above, then: +SLANG_IMPORT_CHECK_DIR=/absolute/path/to/test-presets love tools/slang-import-check +``` + +The GPU tool checks both shader dialects, custom vertex varyings, parameters, +source/output sizes, wrapping/filtering, alpha, resize/DPI 1/2/2.75 and failed +selection rollback. If the directory also contains imports named `LCD3X` and +`ZFAST LCD`, it compares their real output with independent CPU equations. +`SLANG_IMPORT_CHECK_SHOT=/absolute/path.png` exports a comparison sheet. diff --git a/src/render/ShaderPresets.lua b/src/render/ShaderPresets.lua index a18645961..c9651581a 100644 --- a/src/render/ShaderPresets.lua +++ b/src/render/ShaderPresets.lua @@ -27,13 +27,27 @@ local function value(p, n) return math.max(p.min, math.min(p.max, p.min + steps * p.step)) end local function validate(raw, id) - if type(raw) ~= "table" or raw.format ~= 1 or not label(raw.name) then + if type(raw) ~= "table" or (raw.format ~= 1 and raw.format ~= 2) or not label(raw.name) then return nil, "invalid preset metadata" end if not filename(raw.shader, "glsl") and not raw.builtinSource then return nil, "invalid shader filename" end local out = { id = id, name = raw.name, shader = raw.shader, source = raw.builtinSource, parameters = {} } + if raw.format == 2 then + if + raw.builtinSource + or not filename(raw.vertex, "glsl") + or not filename(raw.esShader, "glsl") + or not filename(raw.esVertex, "glsl") + or (raw.filter ~= "nearest" and raw.filter ~= "linear") + or not ({ clampzero = true, clamp = true, ["repeat"] = true, mirroredrepeat = true })[raw.wrap] + then + return nil, "invalid imported preset metadata" + end + out.imported, out.vertex, out.esShader, out.esVertex = true, raw.vertex, raw.esShader, raw.esVertex + out.filter, out.wrap = raw.filter, raw.wrap + end local params = raw.parameters or {} if type(params) ~= "table" or #params > 8 then return nil, "invalid parameter list" @@ -52,6 +66,7 @@ local function validate(raw, id) or not p.id:match("^[%a_][%w_]*$") or seen[p.id] or p.id:match("^love_") + or (out.imported and p.id:match("^g2_")) or not label(p.name) or not finite(p.min) or not finite(p.max) @@ -181,6 +196,8 @@ end function Presets:off() release(self.shader) release(self.output) + release(self.importInput) + self.importInput = nil self.shader, self.output, self.entry, self.values = nil, nil, nil, nil self.error = nil end @@ -200,33 +217,65 @@ function Presets:activate(id, params) if entry.error then return self:_fail(entry.error) end + local g = self:_graphics() + local es = false + if entry.imported and g and g.getRendererInfo then + local name, version = g.getRendererInfo() + es = (tostring(name) .. tostring(version)):find("OpenGL ES", 1, true) ~= nil + end local source, why = entry.source if not source then - source, why = read(self:_fs(), ROOT .. "/" .. entry.shader, MAX_SOURCE) + source, why = read(self:_fs(), ROOT .. "/" .. (es and entry.esShader or entry.shader), MAX_SOURCE) + end + local vertex + if entry.imported then + vertex, why = read(self:_fs(), ROOT .. "/" .. (es and entry.esVertex or entry.vertex), MAX_SOURCE) + if not vertex then + return self:_fail(why) + end end if not source then return self:_fail(why) end - local g = self:_graphics() if not (g and g.newShader and g.newCanvas) then return self:_fail("shader graphics unavailable") end - local shader + local shader, importInput local values = {} local ok, err = pcall(function() - shader = g.newShader(source) + shader = vertex and g.newShader(source, vertex) or g.newShader(source) + if entry.imported then + -- Check NPOT wrapping and standardized uniform types before retiring the old effect. + importInput = g.newCanvas(3, 5, { dpiscale = 1 }) + importInput:setFilter(entry.filter, entry.filter) + assert(importInput:setWrap(entry.wrap, entry.wrap), "unsupported preset texture wrapping") + for _, name in ipairs({ "SourceSize", "OriginalSize", "OutputSize", "FinalViewportSize" }) do + if shader:hasUniform("g2_" .. name) then + shader:send("g2_" .. name, { 3, 5, 1 / 3, 1 / 5 }) + end + end + if shader:hasUniform("g2_Source") then + shader:send("g2_Source", importInput) + end + end for _, p in ipairs(entry.parameters) do - assert(shader:hasUniform(p.id), "missing parameter uniform: " .. p.id) + local uniform = entry.imported and ("g2_p_" .. p.id) or p.id + -- Slang declares parameters in both blocks even when optimized out. + assert(entry.imported or shader:hasUniform(uniform), "missing parameter uniform: " .. uniform) values[p.id] = value(p, type(params) == "table" and params[p.id] or nil) - shader:send(p.id, values[p.id]) + if shader:hasUniform(uniform) then + shader:send(uniform, values[p.id]) + end end end) if not ok then release(shader) + release(importInput) return self:_fail(err) end self:off() self.shader, self.entry, self.values = shader, entry, values + self.importInput = importInput return true end function Presets:applyOptions(opts) @@ -268,7 +317,7 @@ function Presets:setParameter(opts, id, n) return nil, "unknown parameter" end function Presets:apply(input, pixelScale) - if not self:active() or input == self.output then + if not self:active() or (input == self.output or input == self.importInput) then return input end local g = self:_graphics() @@ -307,8 +356,47 @@ function Presets:apply(input, pixelScale) g.setColorMask(true, true, true, true) g.setBlendMode("replace", "premultiplied") g.setColor(1, 1, 1, 1) + local sampled = input + if self.entry.imported then + local pw, ph = input:getPixelDimensions() + local scale = finite(pixelScale) and math.max(1, pixelScale) or 1 + local nw, nh = math.max(1, math.floor(pw / scale + 0.5)), math.max(1, math.floor(ph / scale + 0.5)) + if not self.importInput or self.importInput:getWidth() ~= nw or self.importInput:getHeight() ~= nh then + release(self.importInput) + self.importInput = nil + self.importInput = g.newCanvas(nw, nh, { dpiscale = 1 }) + self.importInput:setFilter(self.entry.filter, self.entry.filter) + assert( + self.importInput:setWrap(self.entry.wrap, self.entry.wrap), + "unsupported preset texture wrapping" + ) + end + -- Reconstruct the primary view's native-pixel grid in a private texture. + -- Its filter/wrap never change a mod's or the renderer's input texture. + g.setCanvas(self.importInput) + g.setShader() + local iw, ih = input:getDimensions() + g.draw(input, 0, 0, 0, nw / iw, nh / ih) + sampled = self.importInput + g.setCanvas(self.output) + local function send(id, v) + if self.shader:hasUniform(id) then + self.shader:send(id, v) + end + end + local ow, oh = input:getPixelDimensions() + local size = { nw, nh, 1 / nw, 1 / nh } + send("g2_Source", sampled) + send("g2_SourceSize", size) + send("g2_OriginalSize", size) + send("g2_OutputSize", { ow, oh, 1 / ow, 1 / oh }) + send("g2_FinalViewportSize", { ow, oh, 1 / ow, 1 / oh }) + end for id, n in pairs(self.values) do - self.shader:send(id, n) + local uniform = self.entry.imported and ("g2_p_" .. id) or id + if self.shader:hasUniform(uniform) then + self.shader:send(uniform, n) + end end if self.shader:hasUniform("sourceSize") then local w, h = input:getPixelDimensions() @@ -318,7 +406,12 @@ function Presets:apply(input, pixelScale) self.shader:send("pixelScale", finite(pixelScale) and math.max(1, pixelScale) or 1) end g.setShader(self.shader) - g.draw(input, 0, 0) + if self.entry.imported then + local w, h = input:getDimensions() + g.draw(sampled, 0, 0, 0, w / sampled:getWidth(), h / sampled:getHeight()) + else + g.draw(input, 0, 0) + end end) g.pop() if not ok then diff --git a/tests/engine/slang_presets.lua b/tests/engine/slang_presets.lua new file mode 100644 index 000000000..b21e39911 --- /dev/null +++ b/tests/engine/slang_presets.lua @@ -0,0 +1,234 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Json = require("src.link.Json") +local Presets = require("src.render.ShaderPresets") +local function fixture() + local meta = { + format = 2, + name = "IMPORTED", + shader = "f.glsl", + vertex = "v.glsl", + esShader = "ef.glsl", + esVertex = "ev.glsl", + filter = "linear", + wrap = "repeat", + parameters = { + { id = "gain", name = "GAIN", min = 0, max = 1, step = 0.1, default = 0.5 }, + { id = "unused", name = "UNUSED", min = 0, max = 1, step = 0.1, default = 0.5 }, + }, + } + local files = + { ["f.glsl"] = "fragment", ["v.glsl"] = "vertex", ["ef.glsl"] = "ES fragment", ["ev.glsl"] = "ES vertex" } + local fs = { + getDirectoryItems = function() + return { "test.json" } + end, + getInfo = function(path) + local name = path:match("[^/]+$") + local s = name == "test.json" and Json.encode(meta) or files[name] + if s then + return { type = "file", size = #s } + end + end, + read = function(path) + local name = path:match("[^/]+$") + return name == "test.json" and Json.encode(meta) or files[name] + end, + } + local objects, draws, stack, compiled = {}, {}, 0, {} + local current, faults = nil, {} + local function texture(w, h, dpi) + local o = { w = w, h = h, dpi = dpi or 1 } + objects[#objects + 1] = o + function o:getWidth() + return self.w + end + function o:getHeight() + return self.h + end + function o:getDimensions() + return self.w, self.h + end + function o:getDPIScale() + return self.dpi + end + function o:getPixelDimensions() + return self.w * self.dpi, self.h * self.dpi + end + function o:setFilter(a, b) + self.filter = { a, b } + end + function o:setWrap(a, b) + if faults.wrap then + error("unsupported wrap") + end + if faults.wrapFalse then + return false + end + self.wrap = { a, b } + return true + end + function o:release() + T.check(not self.released, "resource released once") + self.released = true + end + return o + end + local uniforms = {} + local g = { + getRendererInfo = function() + return faults.es and "OpenGL ES" or "OpenGL", "2.0" + end, + newShader = function(f, v) + if faults.compile then + error("bad vertex") + end + compiled[#compiled + 1] = { f, v } + local s = texture(0, 0) + function s:hasUniform(id) + return id ~= "g2_p_unused" + end + function s:send(id, value) + uniforms[id] = value + end + return s + end, + newCanvas = function(w, h, opts) + if faults.canvas then + error("allocation failed") + end + return texture(w, h, opts.dpiscale) + end, + push = function() + stack = stack + 1 + end, + pop = function() + stack = stack - 1 + end, + setCanvas = function(c) + current = c + end, + setShader = function() end, + origin = function() end, + setScissor = function() end, + setStencilTest = function() end, + setDepthMode = function() end, + setColorMask = function() end, + setBlendMode = function() end, + setColor = function() end, + draw = function(input, x, y, r, sx, sy) + if faults.draw then + error("draw failed") + end + T.check(input ~= current, "never samples current target") + draws[#draws + 1] = { input, sx, sy } + end, + } + return Presets.new({ fs = fs, graphics = g }), + meta, + files, + faults, + uniforms, + objects, + draws, + compiled, + texture, + function() + return stack + end +end +-- Both stages must exist; rejected choices preserve the current effect and options. +do + local p, meta, files, faults, uniforms, objects, draws, compiled = fixture() + T.check(p:activate("builtin:warm"), "activate fallback") + local old = p.shader + local opts = { shaderPreset = "builtin:warm" } + files["v.glsl"] = nil + T.check(not p:select(opts, "local:test"), "missing vertex rejected") + T.eq(p.shader, old, "missing vertex preserves working effect") + T.eq(opts.shaderPreset, "builtin:warm", "missing vertex preserves save") + files["v.glsl"] = "vertex" + faults.compile = true + T.check(not p:select(opts, "local:test"), "GPU compile failure rejected") + T.eq(p.shader, old, "compile failure preserves working effect") + faults.compile, faults.wrapFalse = nil, true + T.check(not p:select(opts, "local:test"), "GPU wrapping rejection aborts selection") + T.eq(p.shader, old, "unsupported wrapping preserves working effect") + T.eq(opts.shaderPreset, "builtin:warm", "unsupported wrapping preserves save") + faults.wrapFalse, faults.es = nil, true + T.check(p:select(opts, "local:test"), "GLES import selected") + T.same(compiled[#compiled], { "ES fragment", "ES vertex" }, "GLES selects matching pair") + T.eq(uniforms.g2_p_gain, 0.5, "parameter alias sent") + T.eq(uniforms.g2_p_unused, nil, "optimized-out parameter allowed") + T.check(p:setParameter(opts, "gain", 0.79), "public parameter ID") + T.check(math.abs(opts.shaderPresetParams["local:test"].gain - 0.8) < 0.001, "saved using public ID") + p:off() +end +-- Imported metadata cannot inject paths, filtering modes or reserved parameter names. +for _, change in ipairs({ + function(m) + m.vertex = "../escape.glsl" + end, + function(m) + m.esShader = false + end, + function(m) + m.esVertex = nil + end, + function(m) + m.filter = "cubic" + end, + function(m) + m.wrap = "unknown" + end, + function(m) + m.parameters[1].id = "g2_Source" + end, +}) do + local p, meta = fixture() + change(meta) + T.check(p:list()[3].error ~= nil, "malformed imported descriptor remains INVALID") + T.check(not p:activate("local:test"), "malformed imported descriptor cannot activate") +end +-- Physical input size, private filtering/wrapping, target lifetime and error recovery. +do + local p, meta, files, faults, uniforms, objects, draws, compiled, texture, stack = fixture() + local input = texture(80, 60, 2.75) + T.check(p:activate("local:test"), "desktop import selected") + T.same(compiled[1], { "fragment", "vertex" }, "desktop selects matching pair") + local output = p:apply(input, 11) + T.neq(output, input, "post-process returns output") + T.eq(output:getDPIScale(), 2.75, "DPI preserved") + T.same(uniforms.g2_SourceSize, { 20, 15, 1 / 20, 1 / 15 }, "physical native source size and reciprocals") + T.same(uniforms.g2_OriginalSize, uniforms.g2_SourceSize, "original is source for first pass") + T.same(uniforms.g2_OutputSize, { 220, 165, 1 / 220, 1 / 165 }, "physical output size and reciprocals") + T.same(uniforms.g2_FinalViewportSize, uniforms.g2_OutputSize, "single-pass final viewport") + local sampled = p.importInput + T.eq(uniforms.g2_Source, sampled, "source sampler receives private texture") + T.same(sampled.filter, { "linear", "linear" }, "preset filter only on private source") + T.same(sampled.wrap, { "repeat", "repeat" }, "preset wrap only on private source") + T.eq(input.filter, nil, "input texture untouched") + T.eq(p:apply(output, 11), output, "output feedback guarded") + T.eq(p:apply(sampled, 11), sampled, "private input feedback guarded") + local n = #objects + p:apply(input, 11) + T.eq(#objects, n, "stable dimensions reuse targets") + p:apply(input, 5.5) + T.check(sampled.released, "old native grid released on scale change") + T.eq(p.importInput:getWidth(), 40, "new native grid allocated") + faults.draw = true + T.eq(p:apply(input, 5.5), input, "draw error returns unfiltered input") + T.eq(stack(), 0, "draw error restores graphics stack") + T.check(not p:active() and p.importInput == nil, "draw error releases private resources") + T.check(not input.released, "caller input never released") +end +for _, fault in ipairs({ "wrap", "canvas" }) do + local p, meta, files, faults, uniforms, objects, draws, compiled, texture, stack = fixture() + T.check(p:activate("local:test"), "activate before " .. fault .. " failure") + faults[fault] = true + local input = texture(32, 24) + T.eq(p:apply(input, 2), input, fault .. " error returns input") + T.check(not p:active() and p.importInput == nil and p.output == nil, fault .. " error releases resources") + T.eq(stack(), 0, fault .. " error balances graphics stack") +end +T.finish("slang presets") diff --git a/tools/slang-import-check/conf.lua b/tools/slang-import-check/conf.lua new file mode 100644 index 000000000..18f7e58cd --- /dev/null +++ b/tools/slang-import-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + t.identity = "gen2-slang-import-check" + t.window.title = "Gen2Recomped slang import checks" + t.window.width, t.window.height = 640, 480 + t.window.visible = false + t.modules.audio = false +end diff --git a/tools/slang-import-check/main.lua b/tools/slang-import-check/main.lua new file mode 100644 index 000000000..c0085ca7c --- /dev/null +++ b/tools/slang-import-check/main.lua @@ -0,0 +1,221 @@ +-- Run after importing PROBE and WRAP from tools/slang-import/fixtures. +-- Optional LCD3X and ZFAST LCD imports are compared against independent CPU equations. +local root = love.filesystem.getSource():match("^(.*)/tools/slang%-import%-check$") + or love.filesystem.getWorkingDirectory() +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +local Presets = require("src.render.ShaderPresets") +local Json = require("src.link.Json") +local CacheFs = require("src.import.CacheFs") +local g = love.graphics +local checks, panels = 0, {} +local function check(v, why) + checks = checks + 1 + assert(v, why) +end +local function near(a, b, why) + check(math.abs(a - b) < 1.6 / 255, why .. (" (%.5f / %.5f)"):format(a, b)) +end +local function pattern(x, y) + return { (x * 11 + 7) / 255, (y * 17 + 13) / 255, ((x + y) * 7 + 3) / 255, y % 2 == 0 and 1 or 0.5 } +end +local function input(w, h, dpi) + local data = love.image.newImageData(16, 12) + for y = 0, 11 do + for x = 0, 15 do + data:setPixel(x, y, unpack(pattern(x, y))) + end + end + local image = g.newImage(data) + image:setFilter("nearest", "nearest") + local canvas = g.newCanvas(w, h, { dpiscale = dpi }) + canvas:setFilter("nearest", "nearest") + g.push("all") + g.setCanvas(canvas) + g.origin() + g.setShader() + g.setColor(1, 1, 1, 1) + g.setBlendMode("replace", "premultiplied") + g.draw(image, 0, 0, 0, w / 16, h / 12) + g.pop() + image:release() + data:release() + return canvas +end +local function oracle(name, x, y, pw, ph, gain) + local u, v = (x + 0.5) / pw, (y + 0.5) / ph + local c = pattern(math.floor(u * 16), math.floor(v * 12)) + if name == "PROBE" then + return { c[1] * gain, 16 / 64, ph * 2 / 512, c[4] } + end + if name == "WRAP" then + return { (pattern(0, 0)[1] + pattern(15, 0)[1]) / 2 } + end + if name == "LCD3X" then + local ay, ax = v * 12 * 2 * math.pi, u * 16 * 2 * math.pi + local yf = (16 + math.sin(ay)) / 17 + for i = 1, 3 do + c[i] = c[i] * yf * (4 + math.sin(ax + math.pi / 2 - (i - 1) * 2 * math.pi / 3)) / 5 + end + c[4] = 1 + return c + end + if name == "ZFAST LCD" then + local dx, dy = math.abs(math.floor(u * 16) + 0.5 - u * 16), math.abs(math.floor(v * 12) + 0.5 - v * 12) + local yy = math.max(dx, dy) ^ 2 + local weight = 1 - 14 * (yy ^ 2 - 2.7 * yy ^ 3) + for i = 1, 3 do + c[i] = c[i] * weight + c[i] = c[i] * (0.6 + 0.4 * c[i]) + end + c[4] = 1 + return c + end +end +local function run() + local fs = { + getDirectoryItems = function() + return love.filesystem.getDirectoryItems("imports") + end, + getInfo = function(p) + return love.filesystem.getInfo((p:gsub("^shader%-presets", "imports"))) + end, + read = function(p) + return love.filesystem.read((p:gsub("^shader%-presets", "imports"))) + end, + } + local p = Presets.new({ fs = fs }) + local entries = {} + for _, e in ipairs(p:list()) do + if e.imported then + entries[e.name] = e + end + end + check(entries.PROBE and entries.WRAP, "import the PROBE and WRAP fixtures first") + for _, name in ipairs({ "PROBE", "WRAP", "LCD3X", "ZFAST LCD" }) do + local entry = entries[name] + if entry then + check(p:activate(entry.id), name .. " activation") + -- Validate exactly the files loaded by the game for both shader dialects. + for _, es in ipairs({ false, true }) do + local fragment = fs.read("shader-presets/" .. (es and entry.esShader or entry.shader)) + local vertex = fs.read("shader-presets/" .. (es and entry.esVertex or entry.vertex)) + local ok, why = g.validateShader(es, fragment, vertex) + check(ok, name .. " dialect: " .. tostring(why)) + end + local gain = 0.5 + for _, dpi in ipairs({ 1, 2, 2.75 }) do + -- Also exercise render-target replacement on resize. + for _, w in ipairs({ 64, 80 }) do + local h = w * 3 / 4 + local canvas = input(w, h, dpi) + local inputFilter = canvas:getFilter() + g.setColor(0.2, 0.3, 0.4, 0.6) + local output = p:apply(canvas, w * dpi / 16) + check(output ~= canvas and p:active(), name .. " processed") + check(output:getWidth() == w and output:getDPIScale() == dpi, "output dimensions/DPI") + check(p.importInput:getWidth() == 16 and p.importInput:getHeight() == 12, "native source grid") + check(canvas:getFilter() == inputFilter, "input texture state unchanged") + local r, green, b, a = g.getColor() + near(r, 0.2, "red state") + near(green, 0.3, "green state") + near(b, 0.4, "blue state") + near(a, 0.6, "alpha state") + local data = output:newImageData() + local pw, ph = data:getDimensions() + for y = 0, ph - 1 do + for x = 0, pw - 1 do + local expected = oracle(name, x, y, pw, ph, gain) + local actual = { data:getPixel(x, y) } + local tx, ty = (x + 0.5) * 16 / pw, (y + 0.5) * 12 / ph + -- Exact nearest-sampling ties can fall on either texel after varying interpolation. + local tie = math.abs(tx - math.floor(tx + 0.5)) < 0.00001 + or math.abs(ty - math.floor(ty + 0.5)) < 0.00001 + if name == "WRAP" or not tie then + for i, n in ipairs(expected) do + near( + actual[i], + n, + name .. " dpi=" .. dpi .. " w=" .. w .. " pixel " .. x .. "," .. y .. " channel " .. i + ) + end + end + end + end + if dpi == 1 and w == 64 then + local preview = g.newImage(data) + preview:setFilter("nearest", "nearest") + panels[#panels + 1] = { name, preview } + end + data:release() + if name == "PROBE" then + local opts = {} + check(p:setParameter(opts, "gain", 0.8), "parameter update") + check(opts.shaderPresetParams[entry.id].gain > 0.79, "parameter persisted by public ID") + gain = 0.8 + end + canvas:release() + end + end + p:off() + check(p.importInput == nil, "private source released on OFF") + end + end + -- A bad imported vertex must preserve the last working effect and saved choice. + check(p:activate("builtin:warm"), "working fallback") + local shader = p.shader + local oldRead = fs.read + fs.read = function(path) + if path:find("-v.glsl", 1, true) then + return "THIS IS NOT A VERTEX SHADER" + end + return oldRead(path) + end + local opts = { shaderPreset = "builtin:warm" } + check(not p:select(opts, entries.PROBE.id), "invalid vertex rejected") + check(p.shader == shader and opts.shaderPreset == "builtin:warm", "working effect and save preserved") + p:off() + local shot = os.getenv("SLANG_IMPORT_CHECK_SHOT") + if shot then + local sheet = g.newCanvas(640, 480) + g.push("all") + g.setCanvas(sheet) + g.origin() + g.setShader() + g.clear(0.08, 0.08, 0.1, 1) + g.setColor(1, 1, 1, 1) + for i, panel in ipairs(panels) do + local x, y = ((i - 1) % 2) * 320 + 16, math.floor((i - 1) / 2) * 240 + 12 + g.print(panel[1], x, y) + g.draw(panel[2], x, y + 24, 0, 4, 4) + end + g.pop() + local png = sheet:newImageData():encode("png") + local f = assert(io.open(shot, "wb")) + f:write(png:getString()) + f:close() + sheet:release() + end + for _, panel in ipairs(panels) do + panel[2]:release() + end + print(checks .. " GPU checks passed (slang import)") + return true +end +function love.load() + local ok, why = pcall(function() + check( + CacheFs.withMounted(assert(os.getenv("SLANG_IMPORT_CHECK_DIR")), "imports", run), + "fixture directory mounted" + ) + end) + if not ok then + io.stderr:write(tostring(why) .. "\n") + end + love.event.quit(ok and 0 or 1) +end +function love.errorhandler(message) + io.stderr:write(tostring(message) .. "\n") + return function() + return 1 + end +end diff --git a/tools/slang-import/.gitignore b/tools/slang-import/.gitignore new file mode 100644 index 000000000..b83d22266 --- /dev/null +++ b/tools/slang-import/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/tools/slang-import/Cargo.lock b/tools/slang-import/Cargo.lock new file mode 100644 index 000000000..f3173b321 --- /dev/null +++ b/tools/slang-import/Cargo.lock @@ -0,0 +1,1073 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cc" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "gen2-slang-import" +version = "0.1.0" +dependencies = [ + "librashader-common", + "librashader-preprocess", + "librashader-presets", + "librashader-reflect", + "regex", + "serde_json", + "sha2", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "glslang" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0db73ac0e8bd6f9c253b70205f2bb1c63845b6e263665ac8063316ce16f408c8" +dependencies = [ + "bitflags", + "glslang-sys", + "rustc-hash 2.1.3", + "smartstring", + "thiserror 2.0.21", +] + +[[package]] +name = "glslang-sys" +version = "0.8.1+275822a" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec80baca8831e8414db40c562fd76ca6a32a792c1db990451d4e7d3cb246e1ed" +dependencies = [ + "cc", + "glob", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "serde", + "zerocopy", +] + +[[package]] +name = "halfbrown" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7ed2f2edad8a14c8186b847909a41fbb9c3eafa44f88bd891114ed5019da09" +dependencies = [ + "hashbrown 0.16.1", + "serde", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "librashader-common" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75fcb7b016f592e08241eb627ab94d8b2e435769b99f2705a2ba11c063a1b5fc" +dependencies = [ + "bitflags", + "halfbrown", + "num-traits", + "rustc-hash 2.1.3", + "serde", + "strumbra", +] + +[[package]] +name = "librashader-pack" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e1405d8ae8996621cacbc5d1f9220486eda4cafaf229da02c02eecfbbe2cf" +dependencies = [ + "image", + "librashader-common", + "librashader-preprocess", + "librashader-presets", + "rayon", +] + +[[package]] +name = "librashader-preprocess" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62507ee532a6f3320acac509e3d3b32e8828c8bd1161a74bc576351729c820e2" +dependencies = [ + "encoding_rs", + "librashader-common", + "nom", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "librashader-presets" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d70306212493fbb4be617cdb98b7048cb896b16182951aeb17856bee3d14aeff" +dependencies = [ + "librashader-common", + "librashader-preprocess", + "nom", + "nom_locate", + "num-traits", + "once_cell", + "regex", + "serde", + "thiserror 2.0.21", + "vec_extract_if_polyfill", +] + +[[package]] +name = "librashader-reflect" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923a7ea3724e2fca281a08598217fe2daa5b6a245a278448251c851f19c0b290" +dependencies = [ + "bitflags", + "bytemuck", + "glslang", + "librashader-common", + "librashader-pack", + "librashader-preprocess", + "librashader-presets", + "naga", + "rspirv", + "rustc-hash 2.1.3", + "spirv", + "spirv-cross2", + "thiserror 2.0.21", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "petgraph", + "rustc-hash 1.1.0", + "serde", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rspirv" +version = "0.13.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091dca2e1d6fd3098417b5ec88e77e80d1ba5945750943419dc976858082c296" +dependencies = [ + "rustc-hash 1.1.0", + "spirv", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "spirv-cross-sys" +version = "0.7.4+aa217ae" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4a12082fc9d2414042b776646d6ed9fd650410f500e611dedd3f713cf7bd1d" +dependencies = [ + "bytemuck", + "cc", + "num-derive", + "num-traits", +] + +[[package]] +name = "spirv-cross2" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a95a3933e4d55d029b09cd3b1ce91a6b52a6cec0a7659aa4a4aeffddc260836f" +dependencies = [ + "bitflags", + "bytemuck", + "memchr", + "spirv", + "spirv-cross-sys", + "spirv-cross2-derive", + "thiserror 1.0.69", +] + +[[package]] +name = "spirv-cross2-derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18017a288e6ce64dd5d56510166baeabb01849483555c031f573c091b6934a64" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strumbra" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b498044485e2789b38e047fdb4c7ac827bd72d66407af9e5d15626f9c45364e" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "vec_extract_if_polyfill" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c9cb5fb67c2692310b6eb3fce7dd4b6e4c9a75be4f2f46b27f0b2b7799759c" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/tools/slang-import/Cargo.toml b/tools/slang-import/Cargo.toml new file mode 100644 index 000000000..67eeda5b4 --- /dev/null +++ b/tools/slang-import/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "gen2-slang-import" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +librashader-preprocess = "=0.12.0" +librashader-presets = "=0.12.0" +librashader-reflect = "=0.12.0" +librashader-common = "=0.12.0" +regex = "1" +serde_json = "1" +sha2 = "0.10" diff --git a/tools/slang-import/fixtures/probe.inc b/tools/slang-import/fixtures/probe.inc new file mode 100644 index 000000000..14ff0f9a4 --- /dev/null +++ b/tools/slang-import/fixtures/probe.inc @@ -0,0 +1 @@ +float probeGain(float red, float gain) { return red * gain; } diff --git a/tools/slang-import/fixtures/probe.slang b/tools/slang-import/fixtures/probe.slang new file mode 100644 index 000000000..559ae2869 --- /dev/null +++ b/tools/slang-import/fixtures/probe.slang @@ -0,0 +1,33 @@ +#version 450 +// Original ROM-free test shader. Also exercises includes, parameters and a vertex varying. +#include "probe.inc" +#pragma parameter gain "GAIN" 1.0 0.0 1.0 0.1 +#pragma parameter unused "UNUSED PARAMETER" 0.5 0.0 1.0 0.1 +layout(push_constant) uniform Push { + vec4 SourceSize; + vec4 OriginalSize; + vec4 OutputSize; + vec4 FinalViewportSize; + float gain; + float unused; +} params; +layout(std140, set=0, binding=0) uniform UBO { mat4 MVP; } global; +#pragma stage vertex +layout(location=0) in vec4 Position; +layout(location=1) in vec2 TexCoord; +layout(location=0) out vec2 sourcePixel; +void main() { + gl_Position = global.MVP * Position; + sourcePixel = TexCoord * params.SourceSize.xy; +} +#pragma stage fragment +layout(location=0) in vec2 sourcePixel; +layout(location=0) out vec4 FragColor; +layout(set=0, binding=2) uniform sampler2D Source; +layout(set=0, binding=3) uniform sampler2D Original; +void main() { + vec2 uv = sourcePixel * params.OriginalSize.zw; + vec4 c = (texture(Source, uv) + texture(Original, uv)) * 0.5; + FragColor = vec4(probeGain(c.r, params.gain), params.SourceSize.x / 64.0, + (params.OutputSize.y + params.FinalViewportSize.y) / 512.0, c.a); +} diff --git a/tools/slang-import/fixtures/probe.slangp b/tools/slang-import/fixtures/probe.slangp new file mode 100644 index 000000000..6cf32d862 --- /dev/null +++ b/tools/slang-import/fixtures/probe.slangp @@ -0,0 +1,8 @@ +shaders = 1 +shader0 = "probe.slang" +filter_linear0 = false +wrap_mode0 = "clamp_to_edge" +scale_type0 = "viewport" +scale0 = 1.0 +parameters = "gain" +gain = 0.5 diff --git a/tools/slang-import/fixtures/reference.slangp b/tools/slang-import/fixtures/reference.slangp new file mode 100644 index 000000000..6339422bb --- /dev/null +++ b/tools/slang-import/fixtures/reference.slangp @@ -0,0 +1,3 @@ +#reference "probe.slangp" +parameters = "gain" +gain = 0.7 diff --git a/tools/slang-import/fixtures/wrap.slang b/tools/slang-import/fixtures/wrap.slang new file mode 100644 index 000000000..bb61d67b0 --- /dev/null +++ b/tools/slang-import/fixtures/wrap.slang @@ -0,0 +1,13 @@ +#version 450 +// Original ROM-free test shader: a one-pixel-wide linear sample across the repeat seam. +layout(std140, set=0, binding=0) uniform UBO { mat4 MVP; } global; +#pragma stage vertex +layout(location=0) in vec4 Position; +layout(location=1) in vec2 TexCoord; +layout(location=0) out vec2 uv; +void main() { gl_Position = global.MVP * Position; uv = TexCoord; } +#pragma stage fragment +layout(location=0) in vec2 uv; +layout(location=0) out vec4 FragColor; +layout(set=0, binding=2) uniform sampler2D Source; +void main() { FragColor = texture(Source, vec2(0.0, uv.y)); } diff --git a/tools/slang-import/fixtures/wrap.slangp b/tools/slang-import/fixtures/wrap.slangp new file mode 100644 index 000000000..c22c01443 --- /dev/null +++ b/tools/slang-import/fixtures/wrap.slangp @@ -0,0 +1,4 @@ +shaders = 1 +shader0 = "wrap.slang" +filter_linear0 = true +wrap_mode0 = "repeat" diff --git a/tools/slang-import/src/lib.rs b/tools/slang-import/src/lib.rs new file mode 100644 index 000000000..e0cfb91ae --- /dev/null +++ b/tools/slang-import/src/lib.rs @@ -0,0 +1,374 @@ +//! Offline, single-pass slang -> LÖVE importer. No compiler is loaded by the game. +use librashader_common::{FilterMode, ImageFormat, WrapMode}; +use librashader_preprocess::ShaderSource; +use librashader_presets::{ScaleFactor, ScaleType, ShaderFeatures, ShaderPreset}; +use librashader_reflect::{ + back::{CompileShader, FromCompilation, glsl::GlslVersion, targets::GLSL}, + front::{Glslang, ShaderInputCompiler}, + reflect::{ + ReflectShader, + semantics::{ + ShaderSemantics, TextureSemanticMap, TextureSemantics, UniqueSemanticMap, + UniqueSemantics, + }, + }, +}; +use regex::Regex; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + error::Error, + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + process::Command, +}; +pub type Result = std::result::Result>; + +pub struct Converted { + pub metadata: Value, + pub sources: [String; 4], // desktop fragment/vertex, GLES fragment/vertex +} +fn rx(pattern: &str) -> Regex { + Regex::new(pattern).expect("internal regex") +} +fn replace_word(source: &str, word: &str, replacement: &str) -> String { + rx(&format!(r"\b{}\b", regex::escape(word))) + .replace_all(source, replacement) + .into_owned() +} +fn printable(s: &str) -> bool { + !s.is_empty() && s.len() <= 48 && s.bytes().all(|b| (32..=126).contains(&b)) +} +fn identifier(s: &str) -> bool { + s.len() <= 32 + && rx(r"^[A-Za-z_][A-Za-z0-9_]*$").is_match(s) + && !s.starts_with("love_") + && !s.starts_with("g2_") + && s != "sourceSize" + && s != "pixelScale" +} + +/// Adapt compiler-generated GLSL, never the original Vulkan shader text. +fn adapt( + input: &str, + vertex: bool, + semantics: &ShaderSemantics, + params: &[Value], +) -> Result { + if input.contains("g2_") { + return Err("reserved g2_ identifier in shader".into()); + } + let mut s = + rx(r"(?m)^#version[^\n]*\n|^#extension GL_EXT_gpu_shader4[^\n]*\n|^precision [^\n]*\n") + .replace_all(input, "") + .into_owned(); + let blocks = rx( + r"(?s)struct (LIBRA_(?:PUSH|UBO)_(?:VERTEX|FRAGMENT))\s*\{([^}]+)\};\s*uniform\s+\w+\s+(\w+);\s*", + ); + let mut declarations = String::new(); + for cap in blocks.captures_iter(&s.clone()) { + let instance = &cap[3]; + for field in cap[2].split(';').map(str::trim).filter(|v| !v.is_empty()) { + let parts: Vec<_> = field.split_whitespace().collect(); + let [ty, name] = parts.as_slice() else { + // The ES backend can add precision to integer members. + if parts.len() != 3 || !["lowp", "mediump", "highp"].contains(&parts[0]) { + return Err(format!("unsupported uniform field: {field}").into()); + } + continue; + }; + let qualified = format!("{instance}.{name}"); + if !rx(&format!(r"\b{}\b", regex::escape(&qualified))).is_match(&s) { + continue; + } // unused block members have no runtime requirements + let binding = if let Some(p) = params.iter().find(|p| p["id"] == *name) { + if *ty != "float" { + return Err("parameters must be scalar floats".into()); + } + format!("g2_p_{}", p["id"].as_str().unwrap()) + } else if let Some(v) = semantics.uniform_semantics.unique_semantic(name) { + match v.semantics { + UniqueSemantics::MVP if vertex && *ty == "mat4" => { + "transform_projection".into() + } + UniqueSemantics::Output if *ty == "vec4" => "g2_OutputSize".into(), + UniqueSemantics::FinalViewport if *ty == "vec4" => { + "g2_FinalViewportSize".into() + } + // Unsigned arithmetic is deliberately outside GLSL 1.20/ES 1.00 import. + _ => return Err(format!("unsupported uniform: {name}").into()), + } + } else if let Some(v) = semantics.uniform_semantics.texture_semantic(name) { + match (v.semantics, v.index, *ty) { + (TextureSemantics::Source, 0, "vec4") => "g2_SourceSize".into(), + (TextureSemantics::Original, 0, "vec4") => "g2_OriginalSize".into(), + _ => return Err(format!("unsupported texture-size uniform: {name}").into()), + } + } else { + return Err(format!("unknown uniform: {name}").into()); + }; + if binding != "transform_projection" { + declarations.push_str(&format!("uniform {ty} {binding};\n")); + } + s = replace_word(&s, &qualified, &binding); + } + // Precision-qualified members are supported only when unused, never silently omitted. + if let Some(member) = rx(&format!( + r"\b{}\.([A-Za-z_][A-Za-z0-9_]*)", + regex::escape(instance) + )) + .captures_iter(&s) + .next() + { + return Err(format!("unsupported uniform: {}", &member[1]).into()); + } + } + s = blocks.replace_all(&s, "").into_owned(); + let samplers = rx(r"uniform\s+(?:(?:highp|mediump|lowp)\s+)?sampler2D\s+(\w+);\s*"); + for cap in samplers.captures_iter(&s.clone()) { + let name = &cap[1]; + let logical = name + .strip_prefix("LIBRA_TEXTURE_") + .ok_or("unknown sampler")?; + let semantic = semantics + .texture_semantics + .texture_semantic(logical) + .ok_or("unknown sampler semantic")?; + if !matches!( + (semantic.semantics, semantic.index), + (TextureSemantics::Source | TextureSemantics::Original, 0) + ) { + return Err(format!("unsupported sampler: {logical}").into()); + } + s = replace_word(&s, name, "g2_Source"); + } + // Repeated Source/Original aliases refer to the same input in a single pass. + s = samplers.replace_all(&s, "").into_owned(); + if s.contains("g2_Source") { + declarations.push_str("uniform Image g2_Source;\n"); + } + if vertex { + s = rx(r"attribute vec4 Position;\s*|attribute vec2 TexCoord;\s*") + .replace_all(&s, "") + .into_owned(); + s = replace_word(&s, "Position", "vertex_position"); + s = replace_word(&s, "TexCoord", "VertexTexCoord.xy"); + s = replace_word(&s, "gl_Position", "g2_result"); + } else { + s = s.replace("gl_FragData[0]", "g2_result"); + } + // This bounded adapter accepts the one main() emitted last by SPIRV-Cross. + let main = rx(r"void main\(\)\s*\{"); + if main.find_iter(&s).count() != 1 || !s.trim_end().ends_with('}') { + return Err("unsupported entry point".into()); + } + let header = if vertex { + "vec4 position(mat4 transform_projection, vec4 vertex_position) { vec4 g2_result = vec4(0.0);" + } else { + "vec4 effect(vec4 g2_unusedColor, Image g2_unusedImage, vec2 g2_unusedUv, vec2 g2_unusedPixel) { vec4 g2_result = vec4(0.0);" + }; + s = main.replace(&s, header).into_owned(); + s = rx(r"\breturn\s*;") + .replace_all(&s, "return g2_result;") + .into_owned(); + let end = s.rfind('}').ok_or("missing main body")?; + s.insert_str(end, "return g2_result;\n"); + // Catch extension-dependent operations and unrecognized interfaces rather than change their meaning. + if rx(r"\b(uint|uvec[234]|gl_FragData|gl_FragColor|attribute|LIBRA_(?:PUSH|UBO|TEXTURE)_\w*)\b|(?m)^#extension").is_match(&s) { + return Err("shader needs an unsupported GLSL interface or extension".into()); + } + // LÖVE supplies stage-specific precision; retain the same varying precision in both stages. + Ok(format!("{declarations}\n{s}")) +} + +pub fn convert(path: &Path, name: &str) -> Result { + if !printable(name) { + return Err("name must contain 1-48 printable ASCII characters".into()); + } + if path.extension().and_then(|e| e.to_str()) != Some("slangp") { + return Err("expected a .slangp preset".into()); + } + let p = ShaderPreset::try_parse(path, ShaderFeatures::empty())?; + if p.pass_count != 1 || p.passes.len() != 1 { + return Err("only single-pass presets are supported".into()); + } + if !p.textures.is_empty() { + return Err("LUT textures are not supported".into()); + } + let pass = &p.passes[0]; + if pass.meta.srgb_framebuffer || pass.meta.float_framebuffer || pass.meta.mipmap_input { + return Err("special framebuffer/mipmap formats are not supported".into()); + } + if pass.meta.scaling.valid && [&pass.meta.scaling.x, &pass.meta.scaling.y].iter().any(|s| !matches!((&s.scale_type, &s.factor), (ScaleType::Viewport, ScaleFactor::Float(f)) if *f == 1.0)) { + return Err("only default or viewport 1x output scaling is supported".into()); + } + let source = ShaderSource::load(&pass.path, p.features)?; + if !matches!( + source.format, + ImageFormat::Unknown | ImageFormat::R8G8B8A8Unorm + ) { + return Err("shader requires an unsupported output format".into()); + } + let mut parameters: Vec = source.parameters.values().map(|v| json!({"id":v.id.to_string(),"name":v.description,"default":v.initial,"min":v.minimum,"max":v.maximum,"step":v.step})).collect(); + parameters.sort_by_key(|v| v["id"].as_str().unwrap_or_default().to_string()); + if parameters.len() > 8 { + return Err("at most eight parameters are supported".into()); + } + for param in &mut parameters { + if let Some(v) = p + .parameters + .iter() + .rev() // referenced presets come first; later overrides take precedence + .find(|v| v.name.as_str() == param["id"].as_str().unwrap()) + { + param["default"] = json!(v.value); + } + let id = param["id"].as_str().unwrap(); + if !identifier(id) || !printable(param["name"].as_str().unwrap_or("")) { + return Err("invalid parameter identifier or label".into()); + } + let nums: Vec<_> = ["min", "max", "step", "default"] + .iter() + .map(|k| param[*k].as_f64().unwrap_or(f64::NAN)) + .collect(); + let (lo, hi, step, def) = (nums[0], nums[1], nums[2], nums[3]); + if nums.iter().any(|v| !v.is_finite()) + || lo >= hi + || step <= 0.0 + || step > hi - lo + || (hi - lo) / step > 1_000_000.0 + || def < lo + || def > hi + { + return Err("invalid parameter range/default".into()); + } + } + let semantics = ShaderSemantics::create_pass_semantics::>(&p, 0)?; + let mut sources: Vec = Vec::new(); + for version in [GlslVersion::Glsl120, GlslVersion::Glsl100Es] { + let compiled = Glslang::compile(&source)?; + let mut target = GLSL::from_compilation(compiled)?; + target.reflect(0, &semantics)?; + let out = target.compile(version)?; + sources.push(adapt(&out.fragment, false, &semantics, ¶meters)?); + sources.push(adapt(&out.vertex, true, &semantics, ¶meters)?); + } + if sources.iter().any(|s| s.len() > 65536) { + return Err("converted shader exceeds 64 KiB".into()); + } + let wrap = match pass.meta.wrap_mode { + WrapMode::ClampToBorder => "clampzero", + WrapMode::ClampToEdge => "clamp", + WrapMode::Repeat => "repeat", + WrapMode::MirroredRepeat => "mirroredrepeat", + }; + Ok(Converted { + metadata: json!({"format":2,"name":name,"parameters":parameters,"filter": match pass.meta.filter { FilterMode::Linear => "linear", FilterMode::Nearest => "nearest" },"wrap":wrap}), + sources: sources.try_into().unwrap(), + }) +} + +/// Validate both dialects and compile the current GPU's pair BEFORE publication. +/// Runs LÖVE directly with argument boundaries, never through a shell. +pub fn preflight(converted: &Converted, love: &Path) -> Result<()> { + let dir = std::env::temp_dir().join(format!( + "gen2-slang-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + )); + fs::create_dir(&dir)?; + let result = (|| -> Result<()> { + fs::write( + dir.join("conf.lua"), + "function love.conf(t) t.identity='gen2-slang-preflight'; t.window.visible=false; t.window.width=32; t.window.height=32; t.modules.audio=false; t.modules.joystick=false end", + )?; + fs::write(dir.join("main.lua"), include_str!("validate.lua"))?; + for (i, source) in converted.sources.iter().enumerate() { + fs::write(dir.join(format!("{i}.glsl")), source)?; + } + fs::write( + dir.join("settings.txt"), + format!( + "{} {}", + converted.metadata["filter"].as_str().unwrap(), + converted.metadata["wrap"].as_str().unwrap() + ), + )?; + // JSON parameters never become executable Lua. + fs::write( + dir.join("params.txt"), + converted.metadata["parameters"] + .as_array() + .unwrap() + .iter() + .map(|p| format!("g2_p_{} {}\n", p["id"].as_str().unwrap(), p["default"])) + .collect::(), + )?; + let output = Command::new(love).arg(&dir).output()?; + if !output.status.success() { + return Err(format!( + "LÖVE preflight failed:\n{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + Ok(()) + })(); + let _ = fs::remove_dir_all(&dir); + result +} + +/// Content-addressed, create-new-only files, descriptor published last. +/// An interrupted import cannot expose a descriptor with partial source files. +pub fn publish(converted: &Converted, destination: &Path) -> Result { + let mut hash = Sha256::new(); + hash.update(converted.metadata.to_string()); + for s in &converted.sources { + hash.update((s.len() as u64).to_le_bytes()); + hash.update(s); + } + let stem = format!("slang-{:x}", hash.finalize()); + let stem = &stem[..38]; + let names: Vec<_> = ["-f.glsl", "-v.glsl", "-ef.glsl", "-ev.glsl"] + .iter() + .map(|s| format!("{stem}{s}")) + .collect(); + let mut meta = converted.metadata.clone(); + for (key, name) in ["shader", "vertex", "esShader", "esVertex"] + .iter() + .zip(&names) + { + meta[*key] = json!(name); + } + let descriptor = serde_json::to_string_pretty(&meta)?; + if descriptor.len() > 16384 { + return Err("descriptor exceeds 16 KiB".into()); + } + fs::create_dir_all(destination)?; + let path = destination.join(format!("{stem}.json")); + let mut created = Vec::new(); + let result = (|| -> Result { + for (name, contents) in names + .iter() + .zip(&converted.sources) + .chain(std::iter::once((&format!("{stem}.json"), &descriptor))) + { + let p = destination.join(name); + let mut f = OpenOptions::new().write(true).create_new(true).open(&p)?; + created.push(p); + f.write_all(contents.as_bytes())?; + f.sync_all()?; + } + Ok(path) + })(); + if result.is_err() { + for p in created { + let _ = fs::remove_file(p); + } + } + result +} diff --git a/tools/slang-import/src/main.rs b/tools/slang-import/src/main.rs new file mode 100644 index 000000000..1bea30399 --- /dev/null +++ b/tools/slang-import/src/main.rs @@ -0,0 +1,38 @@ +use std::path::PathBuf; +fn run() -> gen2_slang_import::Result<()> { + let mut args = std::env::args_os().skip(1); + let preset = PathBuf::from(args.next().ok_or( + "usage: gen2-slang-import PRESET.slangp DESTINATION --name NAME [--love EXECUTABLE]", + )?); + let destination = PathBuf::from( + args.next() + .ok_or("missing destination shader-presets directory")?, + ); + let mut name = None; + let mut love = PathBuf::from("love"); + while let Some(key) = args.next() { + match key.to_str() { + Some("--name") => { + name = Some( + args.next() + .ok_or("missing name")? + .into_string() + .map_err(|_| "name must be UTF-8")?, + ) + } + Some("--love") => love = PathBuf::from(args.next().ok_or("missing LÖVE executable")?), + _ => return Err("unknown argument; expected --name NAME or --love EXECUTABLE".into()), + } + } + let converted = gen2_slang_import::convert(&preset, &name.ok_or("--name NAME is required")?)?; + gen2_slang_import::preflight(&converted, &love)?; + let descriptor = gen2_slang_import::publish(&converted, &destination)?; + println!("Imported {}", descriptor.display()); + Ok(()) +} +fn main() { + if let Err(error) = run() { + eprintln!("Import failed: {error}"); + std::process::exit(1); + } +} diff --git a/tools/slang-import/src/validate.lua b/tools/slang-import/src/validate.lua new file mode 100644 index 000000000..698200b1f --- /dev/null +++ b/tools/slang-import/src/validate.lua @@ -0,0 +1,55 @@ +function love.load() + local ok, why = pcall(function() + local s = {} + for i = 0, 3 do + s[i + 1] = assert(love.filesystem.read(i .. ".glsl")) + end + for _, es in ipairs({ false, true }) do + local i = es and 3 or 1 + local valid, message = love.graphics.validateShader(es, s[i], s[i + 1]) + assert(valid, (es and "GLES: " or "GL: ") .. tostring(message)) + end + local renderer, version = love.graphics.getRendererInfo() + local es = (renderer .. version):find("OpenGL ES", 1, true) + local i = es and 3 or 1 + local shader = love.graphics.newShader(s[i], s[i + 1]) + local input = love.graphics.newCanvas(3, 5) + local filter, wrap = love.filesystem.read("settings.txt"):match("(%S+) (%S+)") + input:setFilter(filter, filter) + assert(input:setWrap(wrap, wrap), "unsupported preset texture wrapping") + local sizes = { 3, 5, 1 / 3, 1 / 5 } + for _, name in ipairs({ "SourceSize", "OriginalSize", "OutputSize", "FinalViewportSize" }) do + if shader:hasUniform("g2_" .. name) then + shader:send("g2_" .. name, sizes) + end + end + if shader:hasUniform("g2_Source") then + shader:send("g2_Source", input) + end + for id, value in love.filesystem.read("params.txt"):gmatch("(%S+) (%S+)") do + if shader:hasUniform(id) then + shader:send(id, assert(tonumber(value))) + end + end + local output = love.graphics.newCanvas(4, 4) + love.graphics.setCanvas(output) + love.graphics.setShader(shader) + love.graphics.draw(input) + love.graphics.setShader() + love.graphics.setCanvas() + assert(output:newImageData()) -- force completion/readback + shader:release() + input:release() + output:release() + end) + if not ok then + io.stderr:write(tostring(why) .. "\n") + end + love.event.quit(ok and 0 or 1) +end +function love.errorhandler(message) + io.stderr:write(tostring(message) .. "\n") + return function() + return 1 + end +end diff --git a/tools/slang-import/tests/import.rs b/tools/slang-import/tests/import.rs new file mode 100644 index 000000000..53f6dab50 --- /dev/null +++ b/tools/slang-import/tests/import.rs @@ -0,0 +1,165 @@ +use gen2_slang_import::{convert, publish}; +use std::{ + fs, + path::{Path, PathBuf}, +}; +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("fixtures") + .join(name) +} +struct Scratch(PathBuf); +impl Scratch { + fn new() -> Self { + let p = std::env::temp_dir().join(format!( + "gen2-import-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir(&p).unwrap(); + Self(p) + } + fn preset(&self, text: &str) -> PathBuf { + let p = self.0.join("test.slangp"); + let shader = fixture("probe.slang") + .display() + .to_string() + .replace('\\', "/"); + fs::write( + &p, + format!( + "{}shader0=\"{shader}\"\n{text}\n", + if text.contains("shaders=") { + "" + } else { + "shaders=1\n" + } + ), + ) + .unwrap(); + p + } +} +impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +#[test] +fn compiles_both_stages_and_dialects_with_includes_and_overrides() { + let c = convert(&fixture("probe.slangp"), "PROBE").unwrap(); + assert_eq!(c.metadata["parameters"][0]["default"], 0.5); + assert_eq!(c.metadata["parameters"][1]["id"], "unused"); + assert_eq!(c.metadata["filter"], "nearest"); + assert_eq!(c.metadata["wrap"], "clamp"); + for pair in c.sources.as_chunks::<2>().0 { + assert!(pair[0].contains("vec4 effect(")); + assert!(pair[1].contains("vec4 position(")); + assert!(pair[0].contains("g2_p_gain")); + assert!(pair[1].contains("g2_SourceSize")); + assert!(!pair[0].contains("LIBRA_TEXTURE_")); + assert!(!pair[1].contains("LIBRA_UBO_")); + } +} +#[test] +fn referenced_presets_resolve_relative_sources_and_override_parameters() { + let c = convert(&fixture("reference.slangp"), "REFERENCE").unwrap(); + assert!((c.metadata["parameters"][0]["default"].as_f64().unwrap() - 0.7).abs() < 0.00001); +} +#[test] +fn rejects_unsupported_pass_graph_formats_scaling_and_bad_metadata() { + let t = Scratch::new(); + fs::write(t.0.join("probe.png"), "").unwrap(); + for (extra, error) in [ + ("shaders=2", "single-pass"), + ("textures=lut\nlut=probe.png", "LUT"), + ("float_framebuffer0=true", "framebuffer"), + ("srgb_framebuffer0=true", "framebuffer"), + ("mipmap_input0=true", "mipmap"), + ("scale_type0=source\nscale0=2.0", "scaling"), + ("scale_type0=absolute\nscale0=64", "scaling"), + ("parameters=gain\ngain=2.0", "parameter range"), + ] { + assert!( + convert(&t.preset(extra), "TEST") + .err() + .unwrap() + .to_string() + .contains(error), + "{extra}" + ); + } + assert!(convert(&fixture("probe.slangp"), "\nINVALID").is_err()); + assert!(convert(&fixture("probe.slang"), "TEST").is_err()); + assert!(convert(&t.0.join("missing.slangp"), "TEST").is_err()); +} +#[test] +fn rejects_history_feedback_time_and_source_format_requirements() { + let t = Scratch::new(); + let original = fs::read_to_string(fixture("probe.slang")).unwrap().replace( + "#include \"probe.inc\"", + "float probeGain(float r,float g){return r*g;}", + ); + for text in [ + original + .replace("sampler2D Original", "sampler2D OriginalHistory1") + .replace("texture(Original,", "texture(OriginalHistory1,"), + original + .replace("sampler2D Original", "sampler2D PassFeedback0") + .replace("texture(Original,", "texture(PassFeedback0,"), + original + .replace("float unused;", "float unused; uint FrameCount;") + .replace("params.gain)", "float(params.FrameCount))"), + original.replace( + "#version 450", + "#version 450\n#pragma format R16G16B16A16_SFLOAT", + ), + ] { + fs::write(t.0.join("bad.slang"), text).unwrap(); + fs::write(t.0.join("bad.slangp"), "shaders=1\nshader0=bad.slang").unwrap(); + assert!(convert(&t.0.join("bad.slangp"), "BAD").is_err()); + } +} +#[test] +fn publication_never_overwrites_and_rolls_back_only_its_own_files() { + let t = Scratch::new(); + let c = convert(&fixture("probe.slangp"), "TEST").unwrap(); + let path = publish(&c, &t.0).unwrap(); + let snapshot = fs::read(&path).unwrap(); + assert_eq!(fs::read_dir(&t.0).unwrap().count(), 5); + assert!(publish(&c, &t.0).is_err()); + assert_eq!(fs::read(&path).unwrap(), snapshot); + let json: serde_json::Value = serde_json::from_slice(&snapshot).unwrap(); + for key in ["shader", "vertex", "esShader", "esVertex"] { + let file = json[key].as_str().unwrap(); + assert!(file.len() <= 64 && t.0.join(file).is_file()); + fs::remove_file(t.0.join(file)).unwrap(); + } + // A collision at the descriptor is reached after four new source writes. + assert!(publish(&c, &t.0).is_err()); + assert_eq!(fs::read_dir(&t.0).unwrap().count(), 1); + assert_eq!(fs::read(&path).unwrap(), snapshot); +} +#[test] +fn failed_cli_preflight_preserves_existing_destination() { + let t = Scratch::new(); + let destination = t.0.join("shader presets with spaces"); + fs::create_dir(&destination).unwrap(); + fs::write(destination.join("keep.json"), "existing preset").unwrap(); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_gen2-slang-import")) + .arg(fixture("probe.slangp")) + .arg(&destination) + .args(["--name", "TEST ; $()", "--love", "/missing/love/executable"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("Import failed:")); + assert_eq!(fs::read_dir(&destination).unwrap().count(), 1); + assert_eq!( + fs::read_to_string(destination.join("keep.json")).unwrap(), + "existing preset" + ); +} From 69c3474958ec09b48b91b55ff8cfd0ed15501d46 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:55:26 +0200 Subject: [PATCH 015/138] Add shared API 3 entry and pinned cross-engine contracts --- .github/workflows/api3-contract.yml | 56 ++++ docs/api3-portability.md | 145 ++++++++++ docs/modding.md | 17 +- src/core/Version.lua | 2 +- src/mods/Api3.lua | 199 ++++++++++++++ src/mods/Api3Catalog.lua | 57 ++++ src/mods/Api3Records.lua | 59 ++++ src/mods/CallbackRecords.lua | 79 +++++- src/mods/Loader.lua | 9 +- src/mods/Manifest.lua | 12 +- src/mods/UnifiedCallbacks.lua | 14 +- tests/api3_contract/mods/portable/main.lua | 110 ++++++++ .../api3_contract/mods/portable/manifest.json | 1 + tests/api3_contract/mods/rollback/main.lua | 6 + .../api3_contract/mods/rollback/manifest.json | 1 + tests/api3_contract/run.lua | 256 ++++++++++++++++++ tests/api3_contract/worker.lua | 10 + tests/engine/api3_compat.lua | 226 ++++++++++++++++ tests/engine/api3_entry.lua | 16 ++ tools/api3-contract-check/check.py | 82 ++++++ tools/modkit.py | 34 ++- 21 files changed, 1357 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/api3-contract.yml create mode 100644 docs/api3-portability.md create mode 100644 src/mods/Api3.lua create mode 100644 src/mods/Api3Catalog.lua create mode 100644 src/mods/Api3Records.lua create mode 100644 tests/api3_contract/mods/portable/main.lua create mode 100644 tests/api3_contract/mods/portable/manifest.json create mode 100644 tests/api3_contract/mods/rollback/main.lua create mode 100644 tests/api3_contract/mods/rollback/manifest.json create mode 100644 tests/api3_contract/run.lua create mode 100644 tests/api3_contract/worker.lua create mode 100644 tests/engine/api3_compat.lua create mode 100644 tests/engine/api3_entry.lua create mode 100644 tools/api3-contract-check/check.py diff --git a/.github/workflows/api3-contract.yml b/.github/workflows/api3-contract.yml new file mode 100644 index 000000000..fe5b47952 --- /dev/null +++ b/.github/workflows/api3-contract.yml @@ -0,0 +1,56 @@ +name: API 3 contracts +on: + pull_request: + paths: + - '.github/workflows/api3-contract.yml' + - 'src/**' + - 'tests/api3_contract/**' + - 'tests/engine/api3_*.lua' + - 'tools/api3-contract-check/**' + push: + branches: [main, dev] + paths: + - '.github/workflows/api3-contract.yml' + - 'src/**' + - 'tests/api3_contract/**' + - 'tests/engine/api3_*.lua' + - 'tools/api3-contract-check/**' +permissions: + contents: read +concurrency: + group: api3-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + compare: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + path: gen2 + sparse-checkout: | + src + data + tests + tools/api3-contract-check + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: gen1-reference + sparse-checkout: | + src + data + tests + assets/fonts + - name: install LuaJIT + run: sudo apt-get update && sudo apt-get install -y luajit + - name: identical mod contracts on both engines + run: >- + python3 gen2/tools/api3-contract-check/check.py + --gen1 gen1-reference --gen2 gen2 + --report "${{ runner.temp }}/api3-report.json" + - uses: actions/upload-artifact@v7 + with: + name: api3-contract-report + path: ${{ runner.temp }}/api3-report.json + if-no-files-found: error diff --git a/docs/api3-portability.md b/docs/api3-portability.md new file mode 100644 index 000000000..52f592613 --- /dev/null +++ b/docs/api3-portability.md @@ -0,0 +1,145 @@ +# Shared API 3 entry + +Use the same manifest and entry file on Gen1Recomp and Gen2Recomped: + +```json +{ + "id": "portable_example", + "name": "Portable Example", + "version": "1.0.0", + "api": 3, + "games": ["gen1", "gen2", "gen3"], + "entry": "main.lua" +} +``` + +```lua +local mod = ... +if mod.content.moves:has("TACKLE") then + local ok, reason = mod.content.moves:patch("TACKLE", {power = 45}) + if not ok then mod.log:warn("%s", reason) end +end +``` + +Create a portable starter with: + +```sh +python3 tools/modkit.py scaffold portable_example --api 3 +``` + +It generates the shared games list and a guarded canonical move patch. It +omits an engine-release range because Gen1 and Gen2 have independent engine +version numbers. The default scaffold remains API 2 with its existing range +and profile templates. The `--api` value is now honored instead of silently +emitting API 2 for every value. + +API 3 automatically selects unified content, callbacks and portable scripts. +No `content_format`, `callback_format` or `script_format` fields are needed. +An explicit `"native"` for one of those fields is an error under API 3; +`"unified"` is accepted. API 1/2 retain native defaults, and API 2 retains +its existing individual opt-ins. The engine advertises `Version.modApi = 3`. +A higher unknown API remains a load error. + +This is a common entry and a tested compatibility subset, not a claim that +all of Gen1Recomp's services have been ported. It builds on PRs #90–#99. +The existing unified-content limitations still apply: unsupported writes +return `nil, reason` before recording an operation, native diagnostics are +read-only, and ordinary schema errors raise an attributed load error. +Entry failure rolls back the mod's content and subscriptions. Content is +frozen after loading. + +## Public contracts + +Core `pokemon`, `moves`, `items`, `trainers` and `encounters` handles use the +existing unified adapters. Other registries retain their native record +shapes, rather than inventing canonical fields. Every content handle exposes +`has(id)`, `supports()` and `available`. API-3 passthrough reads and iteration +return detached values too; change data through the write methods. + +The API-3 Pokemon boundary uses short statuses (`SLP`, `PSN`, `BRN`, `FRZ`, +`PAR`, `TOX`), nickname-first display names and a common Special alias. +Writing `stats.special` in a writable callback sets both later Special stats; +contradictory split values are rejected before commit. API-2 unified status +names and callback behavior are unchanged. DVs/shiny presentation is read-only +in the callback boundary. Party and battle reads remain detached snapshots. + +Capture `mod.world` and `mod.battle` at entry time. World methods can be called +before Game exists and return `nil, reason`; reading the facade never boots +Game. The same world handle follows the loader's current Game/save instead of +retaining an earlier one. Battle snapshots and intents retain their existing +revision/replay validation. + +The common world calls currently enabled for generations 1–3 are: + +- `overworld`, `current`, `party`, `activeBlockAt`, `liveMaps`, `mapOverview`, + `effectiveEncounters`; +- `canReorderParty`, `reorderParty`, `availableFieldActions`, `useFieldAction`, + `canFly`, `flyTo`; +- `getFlag`/`setFlag` for private `mod:` boolean flags; +- `queueScript` for the existing bounded portable script language. + +Check `mod:supports("world", name)` before relying on a call. The other +Gen1 world method names remain callable but explicitly return +`nil, "unsupported: ..."`: warp/object/block mutations, runtime NPC operations, +`startWildBattle` and `invalidateMap` have not yet been verified against the +Gen1 API-3 contract. Their native/API-2 counterparts are unaffected. Native +symbolic flag mappings and `mod.datasets:open` are also explicitly unavailable. +Gen4/5 do not claim the shared world surface in this first entry PR. + +Private flags use `save.modData[modId].worldFlags`, independently of native +flags and other mods. A read does not create a bucket. Clearing removes the +stored key. A change emits `flag.changed` with +`name = "mod.."` and the boolean value; setting the same value +emits nothing. Invalid save stores are refused without overwriting them. + +Gen1 API-3 callback names from the pinned reference are recognized even when +this engine has no call site for them. Registration is allowed; availability +still requires a native call site in the selected version. For example, +`render.output` can be subscribed to but reports unavailable here. Unknown +names remain errors. An accepted subscription is not proof that a feature +runs. Source-specific engine handles in lifecycle/UI payloads remain native. + +For scripts, use `mod:supports("scripts", verb)` to test Gen2Recomped's current +bounded vocabulary. A `queueScript` method does not imply support for every +Gen1 script verb. See [portable world scripts](portable-world-scripts.md). + +## Reproducible contract checks + +```sh +luajit tests/engine/api3_entry.lua +luajit tests/engine/api3_compat.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The external reference must be a clean checkout at +`bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. +Use a separate checkout; do not reset a working installation. No Gen1 engine +implementation is vendored into this repository. Only public callback names +are recorded as a compatibility catalog. + +The exact same manifest and mod entry run on both engines for Red, Blue, +Yellow, Gold, Silver, Crystal, FireRed and Emerald, in both success and +intentional-rollback cases. Each engine/version/case has its own LuaJIT +process. The shared harness constructs native-shaped synthetic datasets; +the mod fixtures contain no engine imports or engine-specific branches. +The runner also generates an API-3 mod through the real scaffold CLI and +loads those same generated files on both engines for each version. This adds +eight comparisons to the sixteen normal/rollback cases: 24 comparisons and +48 isolated loader runs. It compares JSON projections of public results and +records hashes of both shared and generated fixture files. It refuses a different or modified reference. + +Coverage includes canonical content reads/writes/iteration and native merge +results, detached core and passthrough reads, callable early handles, later +Game replacement, party fields, private flags, Gen1 battle snapshot/invalid-intent boundaries, callback payloads and native +writeback, custom events, unsupported-name handling, frozen content and failed +owner cleanup. Separate Gen2 tests check API-1/2 behavior, contradictory +manifest fields, honest availability and malformed flag stores. CI pins the +same external commit and uploads the report. + +These are ROM-free loader/runtime-boundary tests, not retail-ROM gameplay +or a proof of every exposed native callback's payload. Native action details +remain covered by the prerequisite PR suites. Extend these shared fixtures +whenever another API-3 service becomes portable; do not make a failing +comparison pass by changing only one engine's mod fixture. diff --git a/docs/modding.md b/docs/modding.md index 85a7401dc..a48460651 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -18,6 +18,12 @@ Regenerate the reference straight into a wiki checkout: luajit tools/gen_registry_docs.lua ../gen1recomp.wiki ``` +## Shared API 3 entry + +The [API-3 entry and cross-engine contracts](api3-portability.md) describe +using the same manifest and mod source on Gen1Recomp and Gen2Recomped. +API 3 selects the unified adapters automatically; API 1/2 keep their defaults. + ## Feature queries (`mod:supports`) `mod:supports(surface, name)` returns `true` when the selected host exposes an @@ -62,11 +68,12 @@ Oak-speech callbacks apply to Gen 1–2 except Prism; Birch-speech events apply to Emerald. Other versions use their own intros. An event still needs its trigger to occur; custom events also need another mod to emit them. -This is additive to Mod API 2: it neither normalizes content records, IDs or -callback payloads across generations nor provides Gen1Recomp's unified API 3. -Existing registration/subscription behavior remains unchanged. When adding a -native callback or changing screen routing, update `src/mods/Capabilities.lua` -and the capability tests in the same change. +For API 1/2, the query describes the existing interfaces and does not itself +normalize records, IDs or callback payloads. API 3 selects the portable +adapters described in [Shared API 3 entry](api3-portability.md), with explicit +limits on availability. Existing API-1/2 registration/subscription behavior +remains unchanged. When adding a native callback or changing screen routing, +update `src/mods/Capabilities.lua` and the capability tests in the same change. Additional read-only world summaries: [map overview and encounter queries](world-overview.md). diff --git a/src/core/Version.lua b/src/core/Version.lua index 82cad2f61..b7ba8b6df 100644 --- a/src/core/Version.lua +++ b/src/core/Version.lua @@ -16,7 +16,7 @@ local Version = { -- binary (e.g. a LOVE version bump); an older shell -- refuses to chainload a payload whose minShell -- exceeds the shell it provides. - modApi = 2, -- mod API major (manifest `api`) + modApi = 3, -- mod API major (manifest `api`) linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL) saveFormat = 4, -- save.meta.format cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker) diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua new file mode 100644 index 000000000..f5de4a039 --- /dev/null +++ b/src/mods/Api3.lua @@ -0,0 +1,199 @@ +-- API-3 entry over the existing native adapters. Unsupported operations have +-- callable handles but never delegate to a native method with a different contract. +local Records = require("src.mods.Api3Records") +local Runtime = require("src.mods.Runtime") +local copy = require("src.mods.Merge").deepCopy +local A = {} +local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty + availableFieldActions useFieldAction canFly flyTo mapOverview warpTo toggleObject + setFlag getFlag effectiveEncounters replaceBlock spawnNpc liveMaps removeNpc npc + queueScript startWildBattle invalidateMap]] +local SUPPORTED = {} +for name in + ([[overworld current activeBlockAt party canReorderParty reorderParty + availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters + setFlag getFlag liveMaps queueScript]]):gmatch("%S+") +do + SUPPORTED[name] = true +end +local function pack(...) + return { n = select("#", ...), ... } +end +local unpack = table.unpack or unpack +local function block(gen) + return { + tileSize = gen == 3 and 16 or 32, + cellSize = 16, + coordinateBase = 0, + kind = gen == 3 and "metatile" or "block", + } +end +function A.install(api, loader, readContext, overviewContext, engineRequire) + local gen = api.generation + local supportedGeneration = gen >= 1 and gen <= 3 + -- Passthrough registries keep native shapes but expose the same handle metadata. + for name, entry in pairs(api.content) do + local handle = entry + if type(handle) == "table" and handle.supports == nil then + local available, reason = api:supports("content", name) + handle.available = available + local get, each = handle.get, handle.each + if get then + handle.get = function(self, id) + local value, why = get(self, id) + return copy(value), why + end + end + if each then + handle.each = function(self) + local iter, state, key = each(self) + return function() + local id, value = iter(state, key) + key = id + return id, copy(value) + end + end + end + handle.supports = function() + return available, reason + end + end + if type(handle) == "table" and handle.has == nil and handle.get then + handle.has = function(_, id) + return handle:get(id) ~= nil + end + end + end + local scripts = api.world -- PortableScripts owns the per-loader/session queue. + local world = {} + local nativeGame, nativeWorld + local function current() + return loader:_game(true) -- Never bootstrap Game for an observational early call. + end + local function native(game) + if game ~= nativeGame then + nativeWorld = engineRequire("src.world.WorldAPI").new(game, api.id, readContext) + nativeWorld.overviewContext = overviewContext + nativeGame = game + end + return nativeWorld + end + local function dispatch(name, ...) + if not supportedGeneration or not SUPPORTED[name] then + return nil, "unsupported: API 3 world method " .. name + end + local game = current() + if not game then + return nil, "no game" + end + if name == "setFlag" or name == "getFlag" then + if not game.save then + return nil, "no save" + end + local key, value = ... + if type(key) ~= "string" or key:sub(1, 4) ~= "mod:" then + return nil, "unsupported: API 3 native flag mapping" + end + if name == "setFlag" and type(value) ~= "boolean" then + return nil, "flag value must be boolean" + end + local id = key:sub(5) + if #id == 0 or #id > 256 or id:find("%c") then + return nil, "invalid mod flag" + end + local store = game.save.modData + if store ~= nil and type(store) ~= "table" then + return nil, "invalid mod save store" + end + local bucket = store and store[api.id] + if bucket ~= nil and type(bucket) ~= "table" then + return nil, "invalid mod save store" + end + local flags = bucket and bucket.worldFlags + if flags ~= nil and type(flags) ~= "table" then + return nil, "invalid mod flag store" + end + if name == "getFlag" then + return flags ~= nil and flags[id] == true + end + local before = flags ~= nil and flags[id] == true + game.save.modData = store or {} + bucket = bucket or {} + game.save.modData[api.id] = bucket + flags = flags or {} + bucket.worldFlags = flags + flags[id] = value or nil + if before ~= value then + Runtime.emit("flag.changed", { name = "mod." .. api.id .. "." .. id, value = value }) + end + return true + end + if name == "queueScript" then + return scripts:queueScript(...) + end + local raw = native(game) + local result = pack(raw[name](raw, ...)) + if name == "party" and type(result[1]) == "table" then + for _, mon in ipairs(result[1]) do + Records.mon(mon, game.data, gen) + end + elseif (name == "current" or name == "mapOverview") and type(result[1]) == "table" then + result[1].block = block(gen) + end + return unpack(result, 1, result.n) + end + for name in WORLD:gmatch("%S+") do + local method = name + world[method] = function(_, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local values = pack(pcall(dispatch, method, ...)) + Runtime.currentMod = owner + if not values[1] then + return nil, "world operation failed: " .. tostring(values[2]) + end + return unpack(values, 2, values.n) + end + end + api.world = world + local battle = api.battle + api.battle = { + snapshot = function() + local snapshot, why = battle:snapshot() + if not snapshot then + return nil, why + end + local game = current() + local data = game and game.data + Records.mon(snapshot.player, data, gen) + Records.mon(snapshot.enemy, data, gen) + for _, name in ipairs({ "party", "battlers" }) do + for _, mon in ipairs(snapshot[name] or {}) do + Records.mon(mon, data, gen) + end + end + return snapshot + end, + submit = function(_, ...) + return battle:submit(...) + end, + } + -- Version datasets have no native Gen2Recomped adapter yet. Keeping the + -- handle callable allows the same mod to fall back without engine imports. + api.datasets = { + open = function() + return nil, "unsupported: version datasets" + end, + } + local supports = api.supports + function api:supports(surface, name) + if surface == "world" then + if supportedGeneration and SUPPORTED[name] then + return true + end + return false, "unsupported: API 3 world method " .. tostring(name) + end + return supports(self, surface, name) + end +end +return A diff --git a/src/mods/Api3Catalog.lua b/src/mods/Api3Catalog.lua new file mode 100644 index 000000000..0549286b9 --- /dev/null +++ b/src/mods/Api3Catalog.lua @@ -0,0 +1,57 @@ +-- Public callback-name snapshot from Gen1Recomp API 3, commit +-- 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36. Recognition is not availability: +-- Capabilities still requires a native Gen2Recomped call site. +local function names(text) + local out = {} + for name in text:gmatch("%S+") do + out[name] = true + end + return out +end +local C = {} +C.hooks = names([[ +battle.accuracy battle.bottom_ui_visible battle.catch_exp battle.caught_marker_visible +battle.charge_required battle.crit battle.damage battle.enemy_action +battle.enemy_switch_or_item battle.exp_award battle.field_residual battle.low_health_alarm +battle.menu_auxiliary battle.move_grid_navigation battle.overlay battle.run +battle.status_hud_visible battle.style battle.turn_order breeding.compatibility +catch.nickname catch.party_full catch.rate core.logic_speed +core.quit_to_launcher core.update encounter.fishing encounter.roll +encounter.species encounter.table evolution.check exp.gain +fieldmove.eligibility gender.roll held_item.trigger input.gamepad +input.key input.pointer input.step input.wheel +intro.oak_speech.build item.use link.fingerprint map.palette +movement.collision movement.speed music.select music.volume +phone.contact_list player.sprite pokemon.icon pokemon.level_visible +pokemon.sprite render.compose render.hud render.letterbox +render.output render.output_enabled render.viewport render.window +render.zones save.new_game save.write screen.render_visible +script.command shiny.roll trainer.before_battle trainer.party +transition.style ui.list_menu ui.naming.grid ui.options.rows +ui.party.grid_navigation ui.party.submenu ui.pc.items ui.start_menu.items +ui.title_menu.items warp.destination world.follower.spawn world.talk +world.tod zoom.range +]]) +C.events = names([[ +apricorn.converted assets.transformed battle.ball_thrown battle.battler_switched +battle.damage_dealt battle.ended battle.exp_gained battle.fainted +battle.move_used battle.started battle.status_inflicted battle.turn_ended +battle.turn_started breeding.egg_created bug_contest.scored checkpoint.restored +clock.day_changed discord.join_requested egg.hatched flag.changed +game.ready happiness.changed intro.boot.copyright intro.boot.gamefreak +intro.boot.movie intro.boot.movie_ended intro.boot.title intro.oak_speech.answered +intro.oak_speech.finished intro.oak_speech.started intro.oak_speech.step link.battle_ended +link.connected link.desync link.ended mail.read +mail.written map.entered map.exited map.reloaded +mod.options_changed mods.loaded music.started music.stopped +phone.call_received player.warped pokemon.before_give pokemon.caught +pokemon.evolved pokemon.level_up pokemon.move_learned pokemon.received +pokerus.infected radio.channel roamer.encountered roamer.moved +save.created save.loaded save.loading save.writing +screen.popped screen.pushed script.ended script.started +sound.played trade.completed unown.unlocked world.blacked_out +world.block_replaced world.boulder_moved world.interacted world.live_maps_preparing +world.live_maps_updated world.npc_spawned world.object_toggled world.stepped +world.tod_changed world.trainer_engaged +]]) +return C diff --git a/src/mods/Api3Records.lua b/src/mods/Api3Records.lua new file mode 100644 index 000000000..22d540b20 --- /dev/null +++ b/src/mods/Api3Records.lua @@ -0,0 +1,59 @@ +-- API-3 public Pokemon values. API-2 unified records keep their existing shape. +local R = {} +local Stats = require("src.pokemon.Stats") +local bit = require("bit") +local LONG = { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local SHORT = {} +for short, long in pairs(LONG) do + SHORT[long], SHORT[short] = short, short +end +function R.status(value) + if value == false or value == "" or value == 0 then + return nil + end + return SHORT[value] or value +end +function R.nativeStatus(value, previous) + if value == nil or value == false then + return nil + end + local short = R.status(value) + assert(LONG[short], "unknown status") + if previous and LONG[SHORT[previous]] == previous then + return LONG[short] + end + return short +end +function R.stats(out, gen) + out.special = gen ~= 1 and out.specialAttack or out.special + return out +end +function R.shiny(raw, gen) + if raw.shiny ~= nil then + return raw.shiny == true + end + if gen ~= 3 then + return Stats.isShiny(raw.dvs) + end + if raw.isShiny ~= nil then + return raw.isShiny == true + end + local p = (tonumber(raw.personality) or 0) % 4294967296 + local id = (tonumber(raw.otId or raw.trainerId) or 0) % 4294967296 + local secret = tonumber(raw.otSecretId) or math.floor(id / 65536) + return bit.bxor(id % 65536, secret, math.floor(p / 65536), p % 65536) < 8 +end +function R.mon(out, data, gen) + if type(out) ~= "table" then + return out + end + out.status = R.status(out.status) + out.stats = R.stats(out.stats or {}, gen) + local raw = out.native or {} + local def = ((data or {}).pokemon or {})[raw.species or out.species] or {} + out.name = out.nickname and out.nickname ~= "" and out.nickname or out.name or def.name or out.species + out.dvs = gen ~= 3 and type(raw.dvs) == "table" and raw.dvs or nil -- raw is already a detached snapshot + out.shiny = R.shiny(raw, gen) + return out +end +return R diff --git a/src/mods/CallbackRecords.lua b/src/mods/CallbackRecords.lua index 6d702f4b1..4ce1679c5 100644 --- a/src/mods/CallbackRecords.lua +++ b/src/mods/CallbackRecords.lua @@ -1,3 +1,4 @@ +local Api3Records = require("src.mods.Api3Records") -- A call-local transaction. Only known record roles cross this boundary; -- opaque engine handles (Game, battle, save, map, RNG, UI callbacks) stay native. local Ids = require("src.mods.UnifiedIds") @@ -139,8 +140,22 @@ local function array(t, label, max) assert(count == n, label .. " must be dense") end -function R.new(loader, name, generation) +function R.new(loader, name, generation, api3) local ids = Ids.new(loader) + local portable = api3 and Api3Records or nil + local function readStatus(value) + if portable then + return portable.status(value) + end + return status[value] or value + end + local function writeStatus(value, old) + if portable then + return portable.nativeStatus(value, old) + end + assert(value == nil or value == false or short[value], "unknown status") + return short[value] + end local byRaw, byView, entries = {}, {}, {} local view, restore, commit, refresh local open = true @@ -193,6 +208,9 @@ function R.new(loader, name, generation) end out.specialAttack = out.specialAttack or out.special out.specialDefense = out.specialDefense or out.special + if portable then + portable.stats(out, generation) + end return out end local function fields(raw, kind) @@ -201,12 +219,14 @@ function R.new(loader, name, generation) species = read("pokemon", raw.species), item = read("items", raw.item), nickname = raw.nickname, - name = raw.name, + name = portable and raw.nickname and raw.nickname ~= "" and raw.nickname or raw.name, level = raw.level, hp = raw.hp, maxHp = raw.stats and raw.stats.hp, - status = status[raw.status] or raw.status, + status = readStatus(raw.status), isEgg = raw.isEgg == true, + dvs = portable and generation ~= 3 and type(raw.dvs) == "table" and copy(raw.dvs) or nil, + shiny = portable and portable.shiny(raw, generation), stats = statView(raw.stats), moves = moveViews(raw.moves), native = copy(raw), @@ -284,7 +304,7 @@ function R.new(loader, name, generation) role = nil end if k == "status" then - out[k] = status[v] or v + out[k] = readStatus(v) elseif k == "move" and type(v) ~= "table" then out[k] = read("moves", v) elseif kind == "action" and k == "id" and not raw.special then @@ -348,6 +368,43 @@ function R.new(loader, name, generation) for k in pairs(public) do assert(stats[k], "unknown stat " .. tostring(k)) end + if portable then + local common = public.special ~= baseline.special and public.special or nil + if generation == 1 then + for _, k in ipairs({ "specialAttack", "specialDefense" }) do + if public[k] ~= baseline[k] then + assert(common == nil or common == public[k], "conflicting special stats") + common = public[k] + end + end + elseif common ~= nil then + for _, k in ipairs({ "specialAttack", "specialDefense" }) do + assert(public[k] == baseline[k] or public[k] == common, "conflicting special stats") + end + end + for k, n in pairs(stats) do + if k ~= "special" or generation == 1 then + local value = public[k] + if common ~= nil and (k == "special" or k == "specialAttack" or k == "specialDefense") then + value = common + end + if value ~= baseline[k] then + value = integer(value, 1, 65535, "stats." .. k) + if generation == 1 and (k == "specialAttack" or k == "specialDefense") then + n = "special" + end + local target = n + ops[#ops + 1] = function() + old[target] = value + if generation > 1 and target == "spatk" and old.special ~= nil then + old.special = value + end + end + end + end + end + return + end local specialValue for _, k in ipairs({ "special", "specialAttack", "specialDefense" }) do if public[k] ~= baseline[k] and (generation == 1 or k ~= "specialDefense") then @@ -442,10 +499,16 @@ function R.new(loader, name, generation) end elseif kind == "mon" then for k in pairs(value) do - assert(monFields[k], "unknown Pokemon field " .. tostring(k)) + assert( + monFields[k] or (portable and (k == "dvs" or k == "shiny")), + "unknown Pokemon field " .. tostring(k) + ) end assert(equal(value.native, before.native), "native snapshot is read-only") assert(value.name == before.name, "name is read-only; set nickname") + if portable then + assert(equal(value.dvs, before.dvs) and value.shiny == before.shiny, "DVs and shiny are read-only") + end for _, key in ipairs({ "species", "item", "nickname", "level", "hp", "maxHp", "status", "isEgg" }) do local v = value[key] if not equal(v, before[key]) then @@ -459,8 +522,7 @@ function R.new(loader, name, generation) elseif key == "maxHp" then v = integer(v, 1, 65535, key) elseif key == "status" then - assert(v == nil or v == false or short[v], "unknown status") - v = short[v] + v = writeStatus(v, raw.status) elseif key == "nickname" then assert(v == nil or type(v) == "string", "nickname must be a string") elseif key == "isEgg" then @@ -590,8 +652,7 @@ function R.new(loader, name, generation) elseif kind == "action" and k == "id" and not value.special and v ~= before[k] then stage(raw, k, native("moves", v, old)) elseif k == "status" and v ~= before[k] then - assert(v == nil or v == false or short[v], "unknown status") - stage(raw, k, short[v]) + stage(raw, k, writeStatus(v, old)) elseif idRoles[k] and v ~= before[k] then stage(raw, k, native(idRoles[k], v, old)) elseif k == "applyShare" and name == "battle.exp_award" then diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 7d027fb2f..ba46218d0 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -838,7 +838,7 @@ function Loader:_api(mod) -- new game and a new load. local hostGen, hostId, hostInfo = 1, nil, nil do - local okV, GameVersion = pcall(require, "src.core.GameVersion") + local okV, GameVersion = pcall(engineRequire, "src.core.GameVersion") if okV and GameVersion then local okG, g = pcall(GameVersion.generation) if okG and tonumber(g) then hostGen = tonumber(g) end @@ -852,7 +852,7 @@ function Loader:_api(mod) local battle = BattleSnapshots.new(function() return loader:_game(true) end, loader) local engineVersion do - local okE, V = pcall(require, "src.core.Version") + local okE, V = pcall(engineRequire, "src.core.Version") engineVersion = okE and V and V.engine or nil end local api = { @@ -894,7 +894,7 @@ function Loader:_api(mod) label = hostInfo and hostInfo.label or hostId, engine = engineVersion, modApi = (function() - local okE, V = pcall(require, "src.core.Version") + local okE, V = pcall(engineRequire, "src.core.Version") return okE and V and V.modApi or nil end)(), developer = loader.dev == true, @@ -1199,6 +1199,9 @@ function Loader:_api(mod) return supports(self, surface, name) end end + if (mod.manifest.api or 1) >= 3 then + engineRequire("src.mods.Api3").install(api, loader, readContext, overviewContext, engineRequire) + end return api end diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index 3a3f6ff4a..3267b2c54 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -223,6 +223,12 @@ function Manifest.validate(raw, path) assert(api <= Version.modApi, ("requires mod API %d; this engine provides %d") :format(api, Version.modApi)) local strict = api >= 2 + -- API 3 is one portable contract, not three independent API-2 opt-ins. + for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do + assert(api < 3 or raw[field] == nil or raw[field] == "unified", + "API 3 requires unified " .. field) + end + local defaultFormat = api >= 3 and "unified" or "native" assert(raw.content_format == nil or raw.content_format == "native" or raw.content_format == "unified", "content_format must be native or unified") assert(raw.content_format ~= "unified" or api >= 2, @@ -244,7 +250,7 @@ function Manifest.validate(raw, path) end end - local scriptFormat = raw.script_format == nil and "native" or raw.script_format + local scriptFormat = raw.script_format == nil and defaultFormat or raw.script_format assert(scriptFormat == "native" or scriptFormat == "unified", "script_format must be native or unified") assert(scriptFormat ~= "unified" or api >= 2, "unified scripts require mod API 2") @@ -317,7 +323,7 @@ function Manifest.validate(raw, path) version = raw.version, entry = raw.entry, api = api, - content_format = raw.content_format or "native", + content_format = raw.content_format or defaultFormat, priority = tonumber(raw.priority) or 0, dependencies = array(raw.dependencies), optional_dependencies = array(raw.optional_dependencies), @@ -329,7 +335,7 @@ function Manifest.validate(raw, path) category = raw.category or "OTHER", game_version = raw.game_version, description = raw.description or "", - callback_format = raw.callback_format or "native", + callback_format = raw.callback_format or defaultFormat, github = github, support = support, updateCheck = updateCheck, diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index ce1659a02..5e6a6195d 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -1,3 +1,4 @@ +local Api3Catalog = require("src.mods.Api3Catalog") local Records = require("src.mods.CallbackRecords") local Ids = require("src.mods.UnifiedIds") local C = {} @@ -83,12 +84,19 @@ function C.install(loader, api, owner, generation) -- Capture pristine alias identities before Loader merges them into separate -- records. Each call still builds a fresh index, including later mods' IDs. local ids = Ids.new(loader) + local api3 = (api.manifest.api or 1) >= 3 + local extra = api3 and Api3Catalog for _, kind in ipairs({ "pokemon", "moves", "items" }) do ids:entries(kind) end local function check(catalog, name) assert( - type(name) == "string" and (catalog[name] or (catalog == C.events and custom(name))), + type(name) == "string" + and ( + catalog[name] + or (extra and (catalog == C.events and extra.events[name] or catalog == C.hooks and extra.hooks[name])) + or (catalog == C.events and custom(name)) + ), "unknown unified callback: " .. tostring(name) ) end @@ -98,7 +106,7 @@ function C.install(loader, api, owner, generation) local wrapped = callback if not custom(name) and not nativeEvents[name] then wrapped = function(payload) - local boundary = Records.new(loader, name, generation) + local boundary = Records.new(loader, name, generation, api3) local public = boundary.view(payload, name == "pokemon.before_give" and "gift" or "context") local values = pack(callback(public)) if name == "pokemon.before_give" then @@ -130,7 +138,7 @@ function C.install(loader, api, owner, generation) check(C.hooks, name) assert(type(callback) == "function", "hook callback must be a function") return loader.hooks:wrap(name, function(nextFn, ...) - local boundary = Records.new(loader, name, generation) + local boundary = Records.new(loader, name, generation, api3) local roles = positions[name] or {} local function translate(args) local out = { n = args.n } diff --git a/tests/api3_contract/mods/portable/main.lua b/tests/api3_contract/mods/portable/main.lua new file mode 100644 index 000000000..a428b942f --- /dev/null +++ b/tests/api3_contract/mods/portable/main.lua @@ -0,0 +1,110 @@ +-- This exact entry and manifest run on both engines. No engine imports, +-- format switches or engine-specific branches belong in this fixture. +local mod = ... +local world, battle = mod.world, mod.battle +assert(type(world) == "table" and type(battle) == "table", "early handles") +local function refused(value, reason) + assert(value == nil and type(reason) == "string" and #reason > 0, tostring(reason)) +end +refused(world:party()) +refused(battle:snapshot()) +local no, why = mod:supports("world", "not_a_method") +assert(no == false and type(why) == "string") +assert(mod:supports("content", "pokemon")) +assert(mod:supports("hooks", "evolution.check")) +assert(mod:supports("events", "pokemon.received")) +assert(mod:supports("world", "party")) +-- Known API-3 names are registrable even when this host cannot execute them. +mod.hooks:wrap("render.output", function(next, ctx) + return next(ctx) +end) +mod.events:on("checkpoint.restored", function() end) +local c = mod.content +assert(c.pokemon:has("MEW")) +assert(c.maps:supports() and c.maps.available) +assert(c.maps:has("ABSENT_MAP") == false) +local map = assert(c.maps:get("PROBE")) +map.width = 99 +assert(c.maps:get("PROBE").width == 2) +for id, row in c.maps:each() do + if id == "api3Probe" then + row.value = 88 + end +end +assert(c.maps:get("PROBE").width == 2) +local before = assert(c.pokemon:get("MEW")) +assert(before.id == "MEW" and before.baseStats.hp == 100) +assert(before.level1Moves[1] == "POUND") +assert(before.baseStats.special == 100) +assert(c.moves:get("POUND").effect == "NO_ADDITIONAL_EFFECT") +before.baseStats.hp = 222 +assert(c.pokemon:get("MEW").baseStats.hp == 100, "detached read") +assert(c.pokemon:patch("MEW", { baseStats = { hp = 101 } })) +assert(c.moves:patch("POUND", { power = 45 })) +assert(c.items:patch("POTION", { price = 123 })) +local found = false +for id, row in c.pokemon:each() do + if id == "MEW" then + found = true + assert(row.baseStats.hp == 101) + end +end +assert(found) +local report = { species = "MEW", hp = 101, move = "POUND", power = 45, price = 123 } +local function monCheck(mon) + assert(mon.species == "MEW" and mon.nickname == "Scout" and mon.name == "Scout") + assert(mon.status == "PSN", "short status") + assert(type(mon.shiny) == "boolean", "shiny presentation") + assert(mon.stats.special == 24 and mon.stats.hp == 40, "common stats") + assert(mon.moves[1].id == "POUND" and mon.moves[1].pp == 30 and mon.moves[1].maxPp == 35) +end +mod.events:on("pokemon.received", function(payload) + monCheck(payload.mon) + payload.mon.hp = 1 + report.event = true +end) +mod.hooks:wrap("evolution.check", function(next, game, mon, evolution, trigger) + monCheck(mon) + mon.hp, mon.status = 27, "BRN" + mon.stats.special = 31 + report.hook = true + report.special = 31 + return next(game, mon, evolution, trigger) +end) +mod.events:on("mod.portable.probe", function(payload) + report.custom = payload.value +end) +mod.events:emit("mod.portable.probe", { value = 7 }) +mod.exports.exercise = function() + local first = assert(world:party()) + monCheck(first[1]) + first[1].hp = 1 + assert(world:party()[1].hp == 30, "detached party") + report.party = true + report.status, report.shiny = first[1].status, first[1].shiny + assert(world:getFlag("mod:contract.flag") == false) + assert(world:setFlag("mod:contract.flag", true)) + assert(world:getFlag("mod:contract.flag") == true) + assert(world:setFlag("mod:contract.flag", false)) + assert(world:getFlag("mod:contract.flag") == false) + report.flags = true +end +mod.exports.snapshot = function() + local first = assert(battle:snapshot()) + assert(first.kind == "wild" and first.prompt == "menu") + monCheck(first.player) + assert(first.player.hp == 30 and first.player.maxHp == 40) + assert(type(first.revision) == "number" and first.revision > 0) + first.player.hp = 1 + assert(battle:snapshot().player.hp == 30, "detached battle snapshot") + refused(battle:submit({ id = 1, revision = first.revision, kind = "not_an_intent" })) + report.battle = + { kind = first.kind, prompt = first.prompt, species = first.player.species, status = first.player.status } +end +mod.exports.late = function() + local ok, value, reason = pcall(function() + return c.moves:patch("POUND", { power = 99 }) + end) + assert(not ok or (value == nil and type(reason) == "string"), "frozen content") +end +mod.exports.world, mod.exports.battle, mod.exports.report = world, battle, report diff --git a/tests/api3_contract/mods/portable/manifest.json b/tests/api3_contract/mods/portable/manifest.json new file mode 100644 index 000000000..d98d5f821 --- /dev/null +++ b/tests/api3_contract/mods/portable/manifest.json @@ -0,0 +1 @@ +{"id":"portable","name":"Portable API contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/rollback/main.lua b/tests/api3_contract/mods/rollback/main.lua new file mode 100644 index 000000000..daf6abe4a --- /dev/null +++ b/tests/api3_contract/mods/rollback/main.lua @@ -0,0 +1,6 @@ +local mod = ... +assert(mod.content.pokemon:patch("MEW", { baseStats = { hp = 199 } })) +mod.events:on("mod.portable.probe", function() + error("failed subscription leaked") +end) +error("intentional API 3 rollback") diff --git a/tests/api3_contract/mods/rollback/manifest.json b/tests/api3_contract/mods/rollback/manifest.json new file mode 100644 index 000000000..ab532b029 --- /dev/null +++ b/tests/api3_contract/mods/rollback/manifest.json @@ -0,0 +1 @@ +{"id":"rollback","name":"Rollback contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua","dependencies":["portable"]} diff --git a/tests/api3_contract/run.lua b/tests/api3_contract/run.lua new file mode 100644 index 000000000..0024a01d9 --- /dev/null +++ b/tests/api3_contract/run.lua @@ -0,0 +1,256 @@ +-- Shared harness; only fixture construction knows the native engine layout. +local M = {} +local function read(path) + local f = assert(io.open(path, "rb")) + local body = f:read("*a") + f:close() + return body +end +local function dataFor(backend, generation) + if backend == "gen1" and generation == 3 then + local fixture = require("tests.modkit.sdk").gen3Data() + fixture.maps.PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } } + return fixture + end + local stats = { hp = 100, attack = 100, defense = 100, speed = 100, special = 100 } + local mon = { + id = "MEW", + name = "MEW", + dex = 151, + types = { "PSYCHIC" }, + baseStats = stats, + catchRate = 45, + baseExp = 64, + growthRate = "MEDIUM_FAST", + spriteFront = "fixture.png", + spriteBack = "fixture.png", + frontSize = 5, + level1Moves = { "POUND" }, + learnset = {}, + evolutions = {}, + } + local effect = "NO_ADDITIONAL_EFFECT" + if generation > 1 then + stats.spatk, stats.spdef = 100, 100 + end + if backend == "gen1" and generation == 2 then + stats.special, stats.spatk, stats.spdef = nil, nil, nil + stats.specialAttack, stats.specialDefense = 100, 100 + mon.level1Moves, mon.learnset, mon.frontSize = nil, nil, nil + mon.levelMoves, mon.picSize = { { level = 1, move = "POUND" } }, 5 + effect = "EFFECT_NORMAL_HIT" + end + local pokemon = { MEW = mon } + if backend == "gen1" and generation == 2 then + pokemon.growthRates = + { MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0 } } + end + return { + pokemon = pokemon, + moves = { + POUND = { + id = "POUND", + name = "POUND", + type = "NORMAL", + power = 40, + accuracy = 100, + pp = 35, + effect = effect, + }, + }, + items = { POTION = { id = "POTION", name = "POTION", price = 300, tossable = true } }, + maps = { PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } } }, + trainers = {}, + encounters = {}, + audio = {}, + type_chart = { types = { NORMAL = {}, PSYCHIC = {} }, matchups = {} }, + gen2Maps = backend == "gen1" and generation == 2 and { + PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } }, + } or nil, + } +end +local function nativeMon(backend, gen) + local stats = { hp = 40, attack = 21, defense = 22, speed = 23, special = 24 } + local mon = { + species = "MEW", + nickname = "Scout", + name = "Native name", + level = 20, + hp = 30, + status = "PSN", + stats = stats, + moves = { { id = "POUND", pp = 30, ppUps = 0 } }, + } + if gen > 1 then + stats.spatk, stats.spdef = 24, 25 + end + if backend == "gen1" and gen > 1 then + stats.special, stats.spatk, stats.spdef = nil, nil, nil + stats.specialAttack, stats.specialDefense = 24, 25 + mon.status = gen == 2 and "poison" or 8 + if gen == 3 then + mon.species, mon.moves[1].id = 151, 1 + end + end + return mon +end +function M.run(root, backend, version, failing, scaffold) + local Loader = require("src.mods.Loader") + local Runtime = require("src.mods.Runtime") + local Version = require("src.core.GameVersion") + local Json = require("src.link.Json") + Version.set(version) + local generation = Version.generation(version) + local data = dataFor(backend, generation) + local files = {} + for _, id in ipairs(failing and { "portable", "rollback" } or { "portable" }) do + for _, name in ipairs({ "manifest.json", "main.lua" }) do + files["mods/" .. id .. "/" .. name] = read(root .. "/mods/" .. id .. "/" .. name) + end + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(assert(files[p]), "@" .. p) + end, + getInfo = function(p) + if files[p] then + return { type = "file", size = #files[p] } + end + for key in pairs(files) do + if key:sub(1, #p + 1) == p .. "/" then + return { type = "directory" } + end + end + end, + getDirectoryItems = function(p) + if p == "mods" then + return failing and { "portable", "rollback" } or { "portable" } + end + return {} + end, + } + local loader = Loader.new({ fs = fs, dev = false, generation = generation, version = version }) + local game + loader._game = function(_, loadedOnly) + if backend == "gen2" then + assert(loadedOnly, "API 3 must not bootstrap Game") + end + return game + end + local ok = loader:load(data) + if scaffold then + assert(ok, table.concat(loader.errors, "; ")) + assert(loader.mods.portable.manifest.api == 3, "generated manifest selects API 3") + local power = backend == "gen1" and generation == 3 and data.gen3Moves._rom[1].power + or data.moves.POUND.power + assert(power == 45, "generated entry changed the native move") + local report = { scaffold = true, power = power } + print("API3_CONTRACT " .. Json.encode(report)) + return report + end + local errorCount = #loader.errors + local exported = assert(loader.exports.portable, table.concat(loader.errors, "; ")) + assert(exported.report, table.concat(loader.errors, "; ")) + if failing then + assert(not ok and table.concat(loader.errors, "; "):find("intentional API 3 rollback", 1, true)) + else + assert(ok, table.concat(loader.errors, "; ")) + end + local nativeHp = backend == "gen1" and generation == 3 and data.gen3Pokemon._stats[151].hp + or data.pokemon.MEW.baseStats.hp + assert(nativeHp == 101, "rollback/native merge") + assert( + (backend == "gen1" and generation == 2 and data.gen2Maps or data.maps).PROBE.width == 2, + "passthrough reads remain detached" + ) + local raw = nativeMon(backend, generation) + local save = { party = { raw }, flags = {}, modData = {}, money = 0 } + game = { data = data, save = save, stack = { states = {} } } + if backend == "gen1" and generation == 3 then + game.session = save + end + exported.exercise() + Runtime.emit("pokemon.received", { mon = raw }) + assert(raw.hp == 30, "event read did not mutate native Pokemon") + loader.hooks:call("evolution.check", function(_, mon) + assert(mon == raw and mon.hp == 27, "native hook forwarding") + assert( + mon.status + == ( + backend == "gen1" and generation == 3 and 16 + or backend == "gen1" and generation == 2 and "burn" + or "BRN" + ), + "native status writeback" + ) + local attack = mon.stats.spatk or mon.stats.specialAttack or mon.stats.special + local defense = mon.stats.spdef or mon.stats.specialDefense or mon.stats.special + assert(attack == 31 and defense == 31, "common Special writes both later stats") + return "UNCHANGED" + end, game, raw, {}, {}) + assert(raw.hp == 27) + exported.late() + Runtime.emit("mod.portable.probe", { value = 8 }) + assert(exported.report.custom == 8, "custom callback reached live owner") + assert(#loader.errors == errorCount, "no callback errors or failed-owner subscription leaks") + if generation == 1 then + require("src.battle.TypeChart").load(data) + local player, enemy = nativeMon(backend, generation), nativeMon(backend, generation) + local function battler(mon) + return { + mon = mon, + species = mon.species, + name = mon.nickname, + curStats = mon.stats, + curTypes = { "PSYCHIC" }, + curMoves = mon.moves, + stages = {}, + } + end + local battle = { + isBattleState = true, + game = game, + data = data, + kind = "wild", + phase = "menu", + queue = {}, + player = battler(player), + enemy = battler(enemy), + enemyParty = { enemy }, + ruleset = {}, + turnCount = 0, + sides = { { battlers = {} }, { battlers = {} } }, + } + function battle:battleKind() + return "wild" + end + function battle:visibleText() + return {} + end + function battle:effectRecord() + return { accuracyChecked = true } + end + game.save.party, game.save.inventory = { player }, {} + game.stack.states = { battle } + exported.snapshot() + assert(player.hp == 30, "invalid intent never dispatched") + end + -- The entry-time world handle must follow a later Game instead of retaining the old save. + local replacement = nativeMon(backend, generation) + replacement.hp = 19 + local nextSave = { party = { replacement }, modData = {}, flags = {} } + game = { data = data, save = nextSave, stack = { states = {} } } + if backend == "gen1" and generation == 3 then + game.session = nextSave + end + assert(exported.world:party()[1].hp == 19, "captured facade follows current game") + assert(exported.battle:snapshot() == nil, "no battle") + local result = exported.report + assert(result.event and result.hook and result.party and result.custom == 8) + print("API3_CONTRACT " .. Json.encode(result)) + return result +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua new file mode 100644 index 000000000..b7dcb960d --- /dev/null +++ b/tests/api3_contract/worker.lua @@ -0,0 +1,10 @@ +-- Separate process per engine/version: modules and runtime buses cannot leak. +package.path = "./?.lua;./?/init.lua;" .. package.path +love = nil +local root, backend, version, mode = arg[1], arg[2], arg[3], arg[4] +assert( + root and backend and version and (mode == "normal" or mode == "rollback" or mode == "scaffold"), + "invalid worker arguments" +) +local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") +dofile(codeRoot .. "/run.lua").run(root, backend, version, mode == "rollback", mode == "scaffold") diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua new file mode 100644 index 000000000..a34fe4ae9 --- /dev/null +++ b/tests/engine/api3_compat.lua @@ -0,0 +1,226 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Manifest = require("src.mods.Manifest") +local Loader = require("src.mods.Loader") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Json = require("src.link.Json") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love } +love = nil +local function manifest(api, extra) + local raw = { id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua", api = api } + for k, v in pairs(extra or {}) do + raw[k] = v + end + return raw +end +for _, api in ipairs({ 1, 2, 3 }) do + local m = Manifest.validate(manifest(api)) + local format = api == 3 and "unified" or "native" + for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do + T.eq(m[field], format, "API " .. api .. " default " .. field) + end +end +T.eq(Manifest.validate(manifest()).api, 1, "missing API remains legacy") +for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do + T.raises(function() + Manifest.validate(manifest(3, { [field] = "native" })) + end, "API 3 requires unified", "API 3 refuses contradictory " .. field) + T.eq( + Manifest.validate(manifest(2, { [field] = "unified" }))[field], + "unified", + "API 2 keeps explicit opt-in" + ) + T.raises(function() + Manifest.validate(manifest(1, { [field] = "unified" })) + end, "mod API 2", "API 1 opt-in remains rejected") +end +T.raises(function() + Manifest.validate(manifest(4)) +end, "provides 3", "future API not silently accepted") +T.raises(function() + Manifest.validate(manifest(3.5)) +end, "positive integer", "fractional API rejected") +local function boot(api, extra, second, version) + Version.set(version or "red") + local rows = { probe = manifest(api, extra) } + if second then + rows.other = manifest(api, extra) + rows.other.id = "other" + end + local files = {} + for id, raw in pairs(rows) do + files["mods/" .. id .. "/manifest.json"] = Json.encode(raw) + files["mods/" .. id .. "/main.lua"] = "local mod = ...; mod.exports.api = mod" + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getInfo = function(p) + if files[p] then + return { type = "file" } + end + if p == "mods" or rows[p:match("^mods/(.*)$")] then + return { type = "directory" } + end + end, + getDirectoryItems = function(p) + return p == "mods" and (second and { "probe", "other" } or { "probe" }) or {} + end, + } + local loader = Loader.new({ fs = fs, dev = true }) + local game, probes = nil, 0 + loader._game = function(_, loadedOnly) + probes = probes + 1 + if api == 3 then + assert(loadedOnly, "API 3 requested Game bootstrap") + end + return game + end + T.check(loader:load({ pokemon = {}, moves = {}, items = {} }), "real loader accepts API " .. api) + local mod = assert(loader.apis.probe) + return mod, loader, function(value) + game = value + end, function() + return probes + end +end +-- Real legacy loaders keep native defaults, API-2 status vocabulary and callback catalog. +for _, api in ipairs({ 1, 2 }) do + local m = boot(api) + T.eq(m.contentFormat, "native", "legacy content native") + T.eq(m.callbackFormat, "native", "legacy callbacks native") + T.eq(m.scriptFormat, "native", "legacy scripts native") +end +do + local m, loader, attach = + boot(2, { content_format = "unified", callback_format = "unified", script_format = "unified" }) + local mon = { + species = "MEW", + nickname = "Scout", + hp = 10, + status = "PSN", + stats = { hp = 20, special = 9 }, + moves = {}, + } + attach({ + save = { party = { mon } }, + data = { pokemon = {}, moves = {}, items = {} }, + stack = { + states = {}, + }, + }) + T.eq(m.world:party()[1].status, "poison", "API-2 unified party status unchanged") + local seen + m.events:on("pokemon.received", function(p) + seen = p.mon.status + end) + Runtime.emit("pokemon.received", { mon = mon }) + T.eq(seen, "poison", "API-2 unified event status unchanged") + T.raises(function() + m.events:on("checkpoint.restored", function() end) + end, "unknown unified callback", "API-2 catalog unchanged") +end +-- Availability must be honest, and a probe cannot construct Game or attach a world. +do + local m, loader, attach, probes = boot(3, nil, true) + T.eq(probes(), 0, "entry does not resolve Game") + local world, battle = m.world, m.battle + T.eq(type(world), "table", "world facade exists before Game") + T.eq(type(world.party), "function", "early world method callable") + T.eq(type(battle.snapshot), "function", "early battle method callable") + for _, name in ipairs({ + "party", + "current", + "activeBlockAt", + "liveMaps", + "queueScript", + "getFlag", + "setFlag", + }) do + T.eq(m:supports("world", name), true, "implemented world method " .. name) + end + for _, name in ipairs({ + "warpTo", + "toggleObject", + "replaceBlock", + "spawnNpc", + "removeNpc", + "npc", + "startWildBattle", + "invalidateMap", + }) do + local available, why = m:supports("world", name) + T.eq(available, false, "no unverified native promise for " .. name) + T.check(type(why) == "string", "unavailable reason for " .. name) + local ok, reason = world[name](world, "bad") + T.eq(ok, nil, "unsupported method does not dispatch " .. name) + T.check(reason:find("unsupported:", 1, true), "explicit refusal for " .. name) + end + T.eq(probes(), 0, "capability and unsupported calls do not resolve Game") + T.eq(world:party(), nil, "no-game party refuses safely") + T.eq(battle:snapshot(), nil, "no-game battle refuses safely") + T.eq(m.datasets:open("gold"), nil, "unavailable dataset API callable") + T.eq(m:supports("hooks", "render.output"), false, "recognized output hook is not falsely available") + T.check( + pcall(function() + m.hooks:wrap("render.output", function(next, ctx) + return next(ctx) + end) + end), + "known absent hook can be registered" + ) + T.check( + pcall(function() + m.events:on("checkpoint.restored", function() end) + end), + "known absent event can be registered" + ) + T.raises(function() + m.events:on("not.catalogued", function() end) + end, "unknown unified callback", "unknown API-3 name rejected") + local save = { party = {}, flags = {}, modData = {} } + attach({ save = save, data = {}, stack = { states = {} } }) + local flags = {} + m.events:on("flag.changed", function(p) + flags[#flags + 1] = { p.name, p.value } + end) + T.eq(world:getFlag("mod:quest.finished"), false, "flag read defaults false") + T.eq(next(save.modData), nil, "flag read allocates no bucket") + T.check(world:setFlag("mod:quest.finished", true), "private flag set") + T.check(world:setFlag("mod:quest.finished", true), "setting same flag accepted") + T.eq(#flags, 1, "unchanged flag emits no duplicate event") + T.eq(loader.apis.other.world:getFlag("mod:quest.finished"), false, "other mod cannot see flag") + T.check(world:setFlag("mod:quest.finished", false), "private flag cleared") + T.eq(save.modData.probe.worldFlags["quest.finished"], nil, "clear removes saved boolean") + T.same( + flags, + { { "mod.probe.quest.finished", true }, { "mod.probe.quest.finished", false } }, + "portable flag notifications" + ) + T.eq(next(save.flags), nil, "private flags never touch native flags") + T.eq(world:setFlag("mod:bad", "yes"), nil, "non-boolean flag rejected") + T.eq(world:setFlag("NATIVE_FLAG", true), nil, "unverified native flag mapping refused") + T.eq(world:setFlag("mod:", true), nil, "empty private flag rejected") + save.modData.probe.worldFlags = false + T.eq(world:setFlag("mod:test", true), nil, "malformed store rejected") + T.eq(save.modData.probe.worldFlags, false, "malformed store never overwritten") + attach({ save = { party = {} }, data = {}, stack = { states = {} } }) + T.eq(world:getFlag("mod:quest.finished"), false, "same handle follows replacement save") + T.eq(m.world, world, "world handle identity stable") + T.eq(m.battle, battle, "battle handle identity stable") +end +for _, version in ipairs({ "platinum", "black" }) do + local m = boot(3, nil, false, version) + T.eq(m:supports("world", "party"), false, "no shared Gen1 contract claimed for " .. version) + local value, why = m.world:party() + T.eq(value, nil, "unsupported generation call refuses") + T.check(why:find("unsupported:", 1, true), "unsupported generation is explicit") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, love = saved[2], saved[3], saved[4], saved[5] +T.finish("API 3 entry compatibility") diff --git a/tests/engine/api3_entry.lua b/tests/engine/api3_entry.lua new file mode 100644 index 000000000..7eb1a2712 --- /dev/null +++ b/tests/engine/api3_entry.lua @@ -0,0 +1,16 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.run") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love } +love = nil +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + for _, failing in ipairs({ false, true }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version, failing) + T.check(ok, version .. (failing and " rollback: " or ": ") .. tostring(why)) + end +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, love = saved[2], saved[3], saved[4], saved[5] +T.finish("API 3 cross-engine contracts") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py new file mode 100644 index 000000000..48ba0eb9b --- /dev/null +++ b/tools/api3-contract-check/check.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Run the identical API-3 mod files on Gen2Recomped and pinned Gen1Recomp.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +GEN1_REF = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" +VERSIONS = ("red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald") + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--gen1", required=True, type=Path, help="clean checkout at the pinned Gen1 commit") + parser.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + parser.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + parser.add_argument("--report", type=Path) + args = parser.parse_args() + gen1, gen2 = args.gen1.resolve(), args.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1_REF: + parser.error(f"Gen1 must be pinned to {GEN1_REF}") + if git(gen1, "status", "--porcelain", "--untracked-files=normal"): + parser.error("Gen1 reference has tracked modifications; use an isolated checkout") + fixtures = gen2 / "tests" / "api3_contract" + manifest = {} + for path in sorted((fixtures / "mods").rglob("*")): + if path.is_file(): + manifest[str(path.relative_to(fixtures))] = hashlib.sha256(path.read_bytes()).hexdigest() + results = [] + scaffold_temp = tempfile.TemporaryDirectory(prefix="api3-scaffold-") + scaffold_root = Path(scaffold_temp.name) + for name, options in (("portable", ["--api", "3"]), ("legacy", [])): + subprocess.run([sys.executable, str(gen2 / "tools/modkit.py"), "scaffold", name, + "--dest", str(scaffold_root / "mods"), "--quiet", *options], + cwd=gen2, check=True, capture_output=True, text=True, timeout=60) + generated = json.loads((scaffold_root / "mods/portable/manifest.json").read_text()) + assert generated["api"] == 3 and "game_version" not in generated + assert generated["games"] == ["gen1", "gen2", "gen3"] + legacy = json.loads((scaffold_root / "mods/legacy/manifest.json").read_text()) + assert legacy["api"] == 2 and legacy["game_version"].startswith(">=") + generated_hashes = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() + for path in (scaffold_root / "mods/portable").iterdir() + if path.is_file()} + for version in VERSIONS: + for mode in ("normal", "rollback", "scaffold"): + values = {} + for backend, engine in (("gen1", gen1), ("gen2", gen2)): + source = scaffold_root if mode == "scaffold" else fixtures + command = [args.lua, str(fixtures / "worker.lua"), str(source), backend, version, mode] + run = subprocess.run(command, cwd=engine, text=True, capture_output=True, timeout=60) + reports = [line.removeprefix("API3_CONTRACT ") for line in run.stdout.splitlines() + if line.startswith("API3_CONTRACT ")] + if run.returncode or len(reports) != 1: + raise RuntimeError(f"{backend}/{version}/{mode} failed:\n{run.stdout}{run.stderr}") + values[backend] = json.loads(reports[0]) + if values["gen1"] != values["gen2"]: + raise RuntimeError(f"contract mismatch for {version}/{mode}: {values}") + results.append({"version": version, "mode": mode, "result": values["gen1"]}) + print(f"PASS {version}/{mode}: identical mod and public result") + report = {"gen1_ref": GEN1_REF, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "gen2_tracked_changes": bool(git(gen2, "status", "--porcelain", "--untracked-files=no")), + "fixture_sha256": manifest, "scaffold_sha256": generated_hashes, "comparisons": results} + if args.report: + args.report.write_text(json.dumps(report, indent=2) + "\n") + scaffold_temp.cleanup() + print(f"API 3: {len(results)} cross-engine comparisons, {len(results) * 2} isolated loader runs passed") + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, subprocess.SubprocessError, OSError, ValueError, AssertionError) as error: + print(error, file=sys.stderr) + sys.exit(1) diff --git a/tools/modkit.py b/tools/modkit.py index fb0b91499..91c23ffa4 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -323,10 +323,9 @@ def check_requires(repo, mod_dir, manifest): "id": "{{id}}", "name": "{{name}}", "version": "0.1.0", - "api": 2, - "entry": "main.lua", - "profile": "{{profile}}", - "game_version": ">={{game_version}} <{{next_major}}.0.0", + "api": {{api}}, + "entry": "main.lua",{{games_line}} + "profile": "{{profile}}",{{engine_range_line}} "category": "GAMEPLAY", "priority": 100, "dependencies": [], @@ -378,7 +377,7 @@ def check_github_field(manifest): "MK001", "error", str(err), "manifest.json")) return findings, notes -MAIN_CONTENT = """-- {{id}}: a content-profile mod (api 2). +MAIN_CONTENT = """-- {{id}}: a content-profile mod (api {{api}}). -- The 10-minute loop: edit, save, F5 in a POKEPORT_DEV=1 game, repeat. return function(mod) -- patch, not override: every field you do not name keeps its base value @@ -391,7 +390,16 @@ def check_github_field(manifest): end """ -MAIN_OVERHAUL = """-- {{id}}: an overhaul-profile mod (api 2). +MAIN_PORTABLE = """-- {{id}}: portable API {{api}} entry for Gen1Recomp and Gen2Recomped. +return function(mod) + if mod.content.moves:has("POUND") then + local ok, reason = mod.content.moves:patch("POUND", { power = 45 }) + if not ok then mod.log:warn("%s", reason) end + end +end +""" + +MAIN_OVERHAUL = """-- {{id}}: an overhaul-profile mod (api {{api}}). return function(mod) mod.options:define({ { key = "difficulty", label = "DIFFICULTY", kind = "choice", @@ -411,7 +419,7 @@ def check_github_field(manifest): end """ -MAIN_TC = """-- {{id}}: a total-conversion-profile mod (api 2). +MAIN_TC = """-- {{id}}: a total-conversion-profile mod (api {{api}}). return function(mod) -- the new game itself: spawn, names, money (field.boot, D11) -- mod.content.field:patch("boot", { @@ -442,7 +450,7 @@ def check_github_field(manifest): README_TEMPLATE = """# {{name}} -A `{{profile}}` mod for the LOVE2D Pokemon Red engine (mod api 2). +A `{{profile}}` mod for {{target_engine}} (mod api {{api}}). ## Layout @@ -489,6 +497,10 @@ def cmd_scaffold(args, repo): "{{game_version}}": engine, "{{next_major}}": str(next_major), "{{extra}}": extra, "{{github_line}}": github_line, + "{{api}}": str(args.api), + "{{games_line}}": '\n "games": ["gen1", "gen2", "gen3"],' if args.api == 3 else "", + "{{engine_range_line}}": "" if args.api == 3 else f'\n "game_version": ">={engine} <{next_major}.0.0",', + "{{target_engine}}": "Gen1Recomp and Gen2Recomped" if args.api == 3 else "the LOVE2D Pokemon Red engine", "{{experimental}}": "true" if getattr(args, "experimental", False) else "false", } @@ -504,6 +516,8 @@ def emit(rel, template): main = {"content": MAIN_CONTENT, "overhaul": MAIN_OVERHAUL, "total_conversion": MAIN_TC}[profile] + if args.api == 3: + main = MAIN_PORTABLE layout_extra = "" if profile == "total_conversion": layout_extra = "- `transforms.lua` - asset transforms over the player's cache\n" @@ -519,7 +533,7 @@ def emit(rel, template): emit("transforms.lua", TRANSFORMS_TEMPLATE) if not args.quiet: - print(f"created {dest} ({profile} profile, api 2)") + print(f"created {dest} ({profile} profile, api {args.api})") print(f"next: python3 tools/modkit.py validate {args.id}") return 0 @@ -1964,7 +1978,7 @@ def main(argv): p.add_argument("id") p.add_argument("--profile", default="content", choices=["content", "overhaul", "total_conversion"]) - p.add_argument("--api", type=int, default=2) + p.add_argument("--api", type=int, choices=(1, 2, 3), default=2) p.add_argument("--github", default="", help="optional owner/repo (enables launcher auto-update)") p.add_argument("--experimental", action="store_true", From ab7d941dcdb58cb9d8a346ffb0aa6c6b8ef8a867 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:31:07 +0200 Subject: [PATCH 016/138] Add validated API 3 world warps and cross-engine contracts --- docs/api3-portability.md | 15 +- docs/api3-warp.md | 130 +++++++ docs/modding.md | 1 + src/mods/Api3.lua | 7 +- src/mods/Api3Warp.lua | 248 +++++++++++++ src/mods/Loader.lua | 4 + src/world/OverworldController.lua | 12 +- src/world/WorldActions.lua | 2 + tests/api3_contract/mods/warp/main.lua | 23 ++ tests/api3_contract/mods/warp/manifest.json | 1 + tests/api3_contract/run.lua | 1 + tests/api3_contract/warp.lua | 387 ++++++++++++++++++++ tests/api3_contract/worker.lua | 11 +- tests/engine/api3_compat.lua | 3 +- tests/engine/api3_warp.lua | 249 +++++++++++++ tools/api3-contract-check/check.py | 2 +- 16 files changed, 1084 insertions(+), 12 deletions(-) create mode 100644 docs/api3-warp.md create mode 100644 src/mods/Api3Warp.lua create mode 100644 tests/api3_contract/mods/warp/main.lua create mode 100644 tests/api3_contract/mods/warp/manifest.json create mode 100644 tests/api3_contract/warp.lua create mode 100644 tests/engine/api3_warp.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 52f592613..a484539fa 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -76,11 +76,12 @@ The common world calls currently enabled for generations 1–3 are: - `canReorderParty`, `reorderParty`, `availableFieldActions`, `useFieldAction`, `canFly`, `flyTo`; - `getFlag`/`setFlag` for private `mod:` boolean flags; -- `queueScript` for the existing bounded portable script language. +- `queueScript` for the existing bounded portable script language; +- [`warpTo`](api3-warp.md) for validated native map transitions. Check `mod:supports("world", name)` before relying on a call. The other Gen1 world method names remain callable but explicitly return -`nil, "unsupported: ..."`: warp/object/block mutations, runtime NPC operations, +`nil, "unsupported: ..."`: object/block mutations, runtime NPC operations, `startWildBattle` and `invalidateMap` have not yet been verified against the Gen1 API-3 contract. Their native/API-2 counterparts are unaffected. Native symbolic flag mappings and `mod.datasets:open` are also explicitly unavailable. @@ -126,15 +127,19 @@ process. The shared harness constructs native-shaped synthetic datasets; the mod fixtures contain no engine imports or engine-specific branches. The runner also generates an API-3 mod through the real scaffold CLI and loads those same generated files on both engines for each version. This adds -eight comparisons to the sixteen normal/rollback cases: 24 comparisons and -48 isolated loader runs. It compares JSON projections of public results and +eight comparisons to the sixteen normal/rollback cases. Eight additional warp +cases use the same public mod on both engines and native-shaped timing seams: +32 comparisons and 64 isolated loader runs. It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge results, detached core and passthrough reads, callable early handles, later Game replacement, party fields, private flags, Gen1 battle snapshot/invalid-intent boundaries, callback payloads and native writeback, custom events, unsupported-name handling, frozen content and failed -owner cleanup. Separate Gen2 tests check API-1/2 behavior, contradictory +owner cleanup. Warp cases also compare validation errors, native dispatch +arguments, destination events, boundary coordinates, arrival options, callback +argument holes/replay, stale sessions/owners, synchronous completion and error +attribution. Separate Gen2 tests check API-1/2 behavior, contradictory manifest fields, honest availability and malformed flag stores. CI pins the same external commit and uploads the report. diff --git a/docs/api3-warp.md b/docs/api3-warp.md new file mode 100644 index 000000000..4b86542dc --- /dev/null +++ b/docs/api3-warp.md @@ -0,0 +1,130 @@ +# Portable API-3 world warps + +API-3 mods can use the same public call on Gen1Recomp and Gen2Recomped: + +```lua +local mod = ... +local world = mod.world -- Safe to retain at entry; does not create Game. + +-- Call later, from an idle overworld interaction/update. +local function goToDestination(mapId, cellX, cellY) + if not mod:supports("world", "warpTo") then return end + local accepted, reason = world:warpTo(mapId, cellX, cellY, "down", { + keepMusic = true, + onDone = function() + mod.log:info("Arrived") + end, + }) + if not accepted then mod.log:warn("Warp declined: %s", reason) end +end +``` + +`warpTo(mapId, x, y, facing, options)` returns `true` after native admission, +or `nil, reason`. Admission does not mean arrival. It uses the existing native +`OverworldState:startWarpTo` transition, map-entry events, spawn bookkeeping +and arrival-tile guard. No direct player/save relocation bypasses that path. +This API-3 adapter does not change API-1/2 facade defaults or validation. +Generations 1–3 advertise support; API 3 still refuses this world operation on +Gen4/5. Availability is a capability, not a promise that the current world is +idle or the named map exists. Reading the facade or querying support does not +bootstrap Game. + +## Admission and coordinates + +The current Game must have a save and a live, topmost overworld. Screens, +native/portable scripts, queued/parallel scripts, movement, transitions, +field/controller locks, link sessions and another pending API-3 warp decline. +Gen2Recomped reuses its existing world-action admission guard and checks its +additional native foreground owners. Idle-state details differ between the +engines; both keep their own native readiness checks. + +Map IDs are nonempty strings of at most 256 bytes, without control characters. +The map must exist in the current merged `game.data.maps`; custom merged maps +are supported. Gen3 also accepts unprefixed names when the current version's +prefixed map exists (`FR_`, `EM_`, `RU_`, `SA_`). An exact ID wins over an alias. +There is no cross-game map-name translation: the mod selects a real map in +the active dataset. + +`x` and `y` are finite, nonnegative whole numbers in zero-based **16-pixel +walk cells**. Gen1/2 dimensions are twice the map's block dimensions; Gen3 +uses the map's metatile dimensions directly. The final valid cell is +`widthInCells - 1, heightInCells - 1`. Invalid/missing map dimensions refuse +before dispatch. Coordinates are bounded, not clamped; terrain passability is +not part of this contract. + +Facing is `up`, `down`, `left` or `right`. Omitted facing follows Gen1's common +contract: `down` on generation 1, current player facing on generations 2/3, +with `down` as fallback. Explicit facing is recommended for portable scenes. + +Options must be a plain table. Only these fields are accepted: + +| Field | Meaning | +| --- | --- | +| `arrive` | `"fly"` or `"teleport"`, using native arrival presentation | +| `keepMusic` | Boolean; forwarded to native map loading | +| `onDone` | Function, called at most once after accepted native completion | +| `fade` | Boolean, generation 3 only; false uses the same native midpoint/completion without pushing a fade screen | + +Unknown options, invalid types and unsupported `fade` use decline the whole +request before native dispatch. Options are copied; later caller edits cannot +change an accepted request. Invalid admission does not set arrival effects, +change maps/coordinates/saves, raise map-entry events or call `onDone`. +Representative reasons include `no game`, `no overworld`, `no save`, +`world is busy`, `invalid map id`, `unknown map: `, `invalid coordinates`, +`coordinates out of bounds`, `invalid facing`, `invalid options`, +`invalid arrival`, `invalid callback`, `unsupported fade` and +`unknown warp option: `. These are admission failures, not exceptions. +Native failures after dispatch can already have native effects; there is no +transactional rollback of an entered transition. + +## Callback ownership + +`onDone` runs only for an accepted request whose Game, save, data, stack, +overworld, player, version, loader buses and mod lease are still current, at +the requested destination. It preserves the native callback arguments, +including nil holes. Synchronous native completion is held until the native +return confirms admission. A rejected/throwing native dispatch cannot invoke +the mod callback, and duplicate native completions are ignored. + +A replaced session/owner observed at input-step boundaries, another map entry +or leaving the destination retires the completion permanently. Returning to an +older session cannot revive a completion already retired. Loader rollback, +reload and teardown retire the facade and its pending callback; a retained +facade returns `nil, "warp facade is retired"`. Retirement does not pop screens +or undo work belonging to the native controller. + +The callback is attributed to its mod, errors enter the loader's runtime error +list once, and the previous runtime owner is restored. An error never retries +a warp or the callback. A completion may start a new request after the native +world becomes idle. The adapter adds no synthetic map-entry event; normal +native map lifecycle remains in control. + +The portable `queueScript` language is unchanged: adding this direct method +does not enable a `warp` script row. + +## Verification + +```sh +luajit tests/engine/api3_warp.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The existing Gen1 pin remains +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same manifest/entry is loaded by +both real loaders for Red, Blue, Yellow, Gold, Silver, Crystal, FireRed and +Emerald. The shared driver builds each engine's native-shaped Game/world data +and replaces only native transition timing seams. It compares validation +reasons, dispatch arguments, public destination events and callback results. +The mod has no engine imports or backend-specific branches. Together with the +existing normal, rollback and generated-starter cases, CI now performs 32 +comparisons / 64 isolated loader runs. + +The local suite additionally drives Gen2Recomped's real native +`startWarpTo` and `Transition` using synthetic map materialization, including +fade/no-fade, music options, native bookkeeping, lock release and one-shot +completion. Further cases cover host-specific locks, malformed maps, callback +reentry, native refusal/exception, lease retirement and API-2 behavior. These +are ROM-free runtime-boundary tests; no retail-ROM playthrough or rendered +arrival-effect equivalence is claimed. diff --git a/docs/modding.md b/docs/modding.md index a48460651..f29c2f6c8 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -23,6 +23,7 @@ luajit tools/gen_registry_docs.lua ../gen1recomp.wiki The [API-3 entry and cross-engine contracts](api3-portability.md) describe using the same manifest and mod source on Gen1Recomp and Gen2Recomped. API 3 selects the unified adapters automatically; API 1/2 keep their defaults. +See [API-3 world warps](api3-warp.md) for validated map transitions and callback lifetime. ## Feature queries (`mod:supports`) diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index f5de4a039..6f2de9444 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -2,6 +2,7 @@ -- callable handles but never delegate to a native method with a different contract. local Records = require("src.mods.Api3Records") local Runtime = require("src.mods.Runtime") +local Warp = require("src.mods.Api3Warp") local copy = require("src.mods.Merge").deepCopy local A = {} local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty @@ -12,7 +13,7 @@ local SUPPORTED = {} for name in ([[overworld current activeBlockAt party canReorderParty reorderParty availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters - setFlag getFlag liveMaps queueScript]]):gmatch("%S+") + setFlag getFlag liveMaps queueScript warpTo]]):gmatch("%S+") do SUPPORTED[name] = true end @@ -78,10 +79,14 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) end return nativeWorld end + local warp = supportedGeneration and Warp.new(loader, api.id, gen, current, native) local function dispatch(name, ...) if not supportedGeneration or not SUPPORTED[name] then return nil, "unsupported: API 3 world method " .. name end + if name == "warpTo" then + return warp(...) + end local game = current() if not game then return nil, "no game" diff --git a/src/mods/Api3Warp.lua b/src/mods/Api3Warp.lua new file mode 100644 index 000000000..8deba26b3 --- /dev/null +++ b/src/mods/Api3Warp.lua @@ -0,0 +1,248 @@ +-- API-3 warp admission and callback lifetime over the native overworld path. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Actions = require("src.world.WorldActions") +local W = {} +local contexts = setmetatable({}, { __mode = "k" }) +local observers = setmetatable({}, { __mode = "k" }) +local pending = setmetatable({}, { __mode = "k" }) +local dirs = { up = true, down = true, left = true, right = true } +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function integer(n) + return type(n) == "number" and n == n and n >= 0 and n < math.huge and n % 1 == 0 +end +local function retire(token) + token.done = true + token.callback = nil + if pending[token.game] == token then + pending[token.game] = nil + end +end +function W.cancelAll(loader, owner) + for candidate, context in pairs(contexts) do + if not loader or candidate == loader then + if owner then + context.owners[owner] = nil + else + contexts[candidate] = nil + end + end + end + for _, token in pairs(pending) do + if (not loader or token.loader == loader) and (not owner or token.owner == owner) then + retire(token) + end + end +end +local function inspect(loader) + for _, token in pairs(pending) do + if token.loader == loader and not token.valid() then + retire(token) + end + end +end +local function observe(loader) + local old = observers[loader] + if old and old[1] == loader.hooks then + return + end + observers[loader] = setmetatable({ loader.hooks }, { __mode = "v" }) + -- Installed at facade creation, never spliced into a dispatching hook chain. + loader.hooks:wrap("input.step", function(nextFn, ...) + if next(pending) == nil then + return nextFn(...) + end + inspect(loader) + local result = pack(pcall(nextFn, ...)) + inspect(loader) + if not result[1] then + error(result[2], 0) + end + return unpack(result, 2, result.n) + end, math.huge, "engine:api3_warp") + for _, event in ipairs({ "map.entered", "map.exited" }) do + local name = event + loader.events:on(name, function(payload) + for _, token in pairs(pending) do + if token.loader == loader then + if not token.valid() then + retire(token) + elseif name == "map.entered" then + if token.entered or payload.mapId ~= token.destination then + retire(token) + else + token.entered = true + end + elseif token.entered then + retire(token) + end + end + end + end, math.huge, "engine:api3_warp") + end +end +function W.new(loader, owner, generation, current, native) + local context = contexts[loader] + if not context then + context = { owners = {} } + contexts[loader] = context + end + local lease = {} + context.owners[owner] = lease + local function licensed() + return contexts[loader] == context and context.owners[owner] == lease + end + observe(loader) + return function(mapId, x, y, facing, options) + if not licensed() then + return nil, "warp facade is retired" + end + local game = current() + if not game then + return nil, "no game" + end + local api = native(game) + local ow = api:overworld() + if not ow then + return nil, "no overworld" + end + local ready, why = Actions.ready(game, ow) + if not ready then + return nil, why + end + inspect(loader) + if pending[game] or ow.gen3Locked or #(ow.parallelRunners or {}) > 0 or #(ow.parallelQueue or {}) > 0 then + return nil, "world is busy" + end + if type(mapId) ~= "string" or #mapId == 0 or #mapId > 256 or mapId:find("%c") then + return nil, "invalid map id" + end + local maps = game.data and game.data.maps + local destination = mapId + if generation == 3 and maps and not maps[destination] then + local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_" + if maps[prefix .. destination] then + destination = prefix .. destination + end + end + local def = maps and maps[destination] + if not def then + return nil, "unknown map: " .. mapId + end + if not integer(x) or not integer(y) then + return nil, "invalid coordinates" + end + local scale = generation == 3 and 1 or 2 + if not integer(def.width) or def.width == 0 or not integer(def.height) or def.height == 0 then + return nil, "invalid map dimensions" + end + if x >= def.width * scale or y >= def.height * scale then + return nil, "coordinates out of bounds" + end + if facing ~= nil and not dirs[facing] then + return nil, "invalid facing" + end + if options ~= nil and (type(options) ~= "table" or getmetatable(options) ~= nil) then + return nil, "invalid options" + end + local opts = {} + for key, value in pairs(options or {}) do + if key == "arrive" then + if value ~= "fly" and value ~= "teleport" then + return nil, "invalid arrival" + end + elseif key == "onDone" then + if type(value) ~= "function" then + return nil, "invalid callback" + end + elseif key == "keepMusic" or key == "fade" then + if type(value) ~= "boolean" then + return nil, "invalid options" + end + if key == "fade" and generation ~= 3 then + return nil, "unsupported fade" + end + else + return nil, "unknown warp option: " .. tostring(key) + end + opts[key] = value + end + facing = facing or (generation ~= 1 and ow.player.facing) or "down" + local hooks, events = Runtime.hooks, Runtime.events + local token = + { game = game, loader = loader, owner = owner, destination = destination, callback = opts.onDone } + local save, data, stack, player, version = game.save, game.data, game.stack, ow.player, Version.get() + function token.valid() + return not token.done + and licensed() + and current() == game + and game.save == save + and game.data == data + and game.stack == stack + and ow.player == player + and Version.get() == version + and Runtime.hooks == hooks + and Runtime.events == events + and native(game):overworld() == ow + end + local accepted, deferred + local function finish(...) + if token.done then + return + end + if accepted == nil then + if not deferred then + deferred = pack(...) + end + return + end + local valid = token.valid() and ow.map and ow.map.id == destination + local callback = valid and token.callback + retire(token) + if not accepted or not callback then + return + end + local previous = Runtime.currentMod + Runtime.currentMod = owner + local result = pack(pcall(callback, ...)) + Runtime.currentMod = previous + if not result[1] then + Runtime.reportError(owner, "world warp callback failed: " .. tostring(result[2])) + end + end + pending[game] = token + local arrival = ow.arriveWarp + if opts.arrive then + ow.arriveWarp = opts.arrive + end + local called, result, detail = pcall( + ow.startWarpTo, + ow, + destination, + x, + y, + facing, + finish, + { via = "warp", keepMusic = opts.keepMusic, fade = opts.fade } + ) + -- The native controller is a void method. Explicit false/refusal and errors + -- never count as admission; a callback fired before that result is deferred. + accepted = called and result ~= false and not (result == nil and detail ~= nil) + if not accepted then + retire(token) + ow.arriveWarp = arrival + if not called then + return nil, "world operation failed: " .. tostring(result) + end + return nil, detail or "world operation failed" + end + if deferred then + finish(unpack(deferred, 1, deferred.n)) + end + return true + end +end +return W diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index ba46218d0..17ecd49e0 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -12,6 +12,7 @@ local Version = require("src.core.Version") local Assets = require("src.render.Assets") local ModUI = require("src.ui.ModUI") local PortableScripts = require("src.mods.PortableScripts") +local Api3Warp = require("src.mods.Api3Warp") local AssetTransform = require("src.mods.AssetTransform") local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") @@ -1262,6 +1263,7 @@ end -- migration it took goes with them. The journal only exists around an -- entry chunk; a later failure (script validation) purges every registry. function Loader:_rollback(modId) + Api3Warp.cancelAll(self, modId) PortableScripts.cancelAll(self, modId, "mod rolled back") for name in pairs(self.journal or self.content) do self.content[name]:rollback(modId) @@ -1377,6 +1379,7 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + Api3Warp.cancelAll() PortableScripts.cancelAll(nil, nil, "mods reloaded") -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST -- LOAD'S MODS. EXIT GAME -> launcher -> Play runs every mod's main.lua @@ -1721,6 +1724,7 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + Api3Warp.cancelAll() PortableScripts.cancelAll(nil, nil, "mods unloaded") local undone = select(1, ModSeams.unwindAll()) pcall(function() diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 05f6dc9d0..1df5b2b0c 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -14777,7 +14777,7 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) self.arriveWarp = nil local fromId = self.map.id local fromSection = self.map.def and self.map.def.regionMapSection - Game.stack:push(Transition.new(Game, function() + local transition = Transition.new(Game, function() self:setMap(mapId, x, y, facing or "down", opts) self:noteGen2Spawn(fromId) -- FIRERED'S SIDE STAIRS (ExitStairsMovement): you land ON the stair tile @@ -14967,7 +14967,15 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) -- side, which is what the scripts already do. if self.player then self.player.inputLocked = false end if onDone then onDone() end - end)) + end) + -- API 3 exposes the Gen 3 no-fade option. Reuse the exact native midpoint + -- and completion path; ordinary/native callers keep their existing fade. + if opts and opts.fade == false then + transition.onMidpoint() + transition.onDone() + else + Game.stack:push(transition) + end end -- Re-read a map record after its data changed (WorldAPI:invalidateMap, diff --git a/src/world/WorldActions.lua b/src/world/WorldActions.lua index d6172f82c..3244aa8a6 100644 --- a/src/world/WorldActions.lua +++ b/src/world/WorldActions.lua @@ -94,6 +94,8 @@ local function ready(game, ow) end return true end +-- Shared admission guard for the API-3 warp adapter. +A.ready = ready local function guardedCall(game, fn, ...) planning[game] = true local result = { pcall(fn, game, ...) } diff --git a/tests/api3_contract/mods/warp/main.lua b/tests/api3_contract/mods/warp/main.lua new file mode 100644 index 000000000..2dd8de861 --- /dev/null +++ b/tests/api3_contract/mods/warp/main.lua @@ -0,0 +1,23 @@ +-- Identical public-only mod on both engines: the harness owns native timing. +local mod = ... +local world = mod.world +assert(mod:supports("world", "warpTo")) +local early, reason = world:warpTo("DEST", 0, 0) +assert(early == nil and reason == "no game") +local calls, args, events = 0, nil, {} +mod.events:on("map.entered", function(ev) + events[#events + 1] = { mapId = ev.mapId, fromMapId = ev.fromMapId, via = ev.via } +end) +mod.exports.request = function(...) + return world:warpTo(...) +end +mod.exports.notify = function(...) + calls = calls + 1 + args = { n = select("#", ...), first = (...), third = select(3, ...) } +end +mod.exports.results = function() + return { calls = calls, args = args, events = events } +end +mod.exports.current = function() + return world:current() +end diff --git a/tests/api3_contract/mods/warp/manifest.json b/tests/api3_contract/mods/warp/manifest.json new file mode 100644 index 000000000..6bc2f80ea --- /dev/null +++ b/tests/api3_contract/mods/warp/manifest.json @@ -0,0 +1 @@ +{"id":"warp","name":"Portable Warp Contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/run.lua b/tests/api3_contract/run.lua index 0024a01d9..c9fe68f51 100644 --- a/tests/api3_contract/run.lua +++ b/tests/api3_contract/run.lua @@ -253,4 +253,5 @@ function M.run(root, backend, version, failing, scaffold) print("API3_CONTRACT " .. Json.encode(result)) return result end +M.dataFor = dataFor return M diff --git a/tests/api3_contract/warp.lua b/tests/api3_contract/warp.lua new file mode 100644 index 000000000..1b1e5d376 --- /dev/null +++ b/tests/api3_contract/warp.lua @@ -0,0 +1,387 @@ +-- Shared driver, real production loaders/adapters, native-shaped world seams. +-- No backend distinction is passed into the mod files or public assertions. +local M = {} +local unpack = table.unpack or unpack +local function read(path) + local f = assert(io.open(path, "rb")) + local value = f:read("*a") + f:close() + return value +end +function M.fixture(root, backend, version) + local Version = require("src.core.GameVersion") + local Runtime = require("src.mods.Runtime") + Version.set(version) + local gen = Version.generation(version) + local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/warp%.lua$") + local data = dofile(codeRoot .. "/run.lua").dataFor(backend, gen) + local prefix = gen == 3 and (version == "emerald" and "EM_" or "FR_") or "" + local home, dest = prefix .. "HOME", prefix .. "DEST" + for _, id in ipairs({ home, dest }) do + data.maps[id] = { + id = id, + width = 2, + height = 3, + tileset = "FIX", + blocks = { 1 }, + midLayout = gen == 3 and { width = 2, height = 3 } or nil, + } + if data.gen2Maps then + data.gen2Maps[id] = data.maps[id] + end + end + local files = {} + for _, name in ipairs({ "manifest.json", "main.lua" }) do + files["mods/warp/" .. name] = read(root .. "/mods/warp/" .. name) + end + local fs = { + read = function(path) + return files[path] + end, + load = function(path) + return loadstring(assert(files[path]), "@" .. path) + end, + getInfo = function(path) + if files[path] then + return { type = "file", size = #files[path] } + end + if path == "mods" or path == "mods/warp" then + return { type = "directory" } + end + end, + getDirectoryItems = function(path) + return path == "mods" and { "warp" } or {} + end, + } + local loader = require("src.mods.Loader").new({ fs = fs, dev = false, generation = gen, version = version }) + local f = { loader = loader, gen = gen, home = home, dest = dest, trace = {}, busy = false } + loader._game = function(_, loadedOnly) + assert(backend ~= "gen2" or loadedOnly, "no Game bootstrap") + return f.live + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + f.exports = assert(loader.exports.warp) + local save = { party = {}, flags = {}, modData = {} } + local player = { cellX = 0, cellY = 0, facing = "left" } + local ow = { + isOverworld = true, + player = player, + map = { id = home, def = data.maps[home] }, + maps = data.maps, + runner = { + isRunning = function() + return false + end, + }, + scriptMoves = {}, + } + local stack = { states = { ow } } + function stack:top() + return self.states[#self.states] + end + local game = { data = data, save = save, session = save, stack = stack, phase = "field", overworld = ow } + f.game, f.live, f.ow, f.player = game, game, ow, player + function f:dispatch(id, x, y, facing, opts) + self.trace[#self.trace + 1] = { + mapId = id, + x = x, + y = y, + facing = facing, + arrive = opts.arrive, + keepMusic = opts.keepMusic, + fade = opts.fade, + } + self.callback = opts.onDone + self.destination = { id, x, y, facing } + self.busy, ow.transitioning = true, true + if self.synchronous then + self:arrive() + self:complete("sync", nil, "tail") + end + if self.failure == "throw" then + error("native warp probe", 0) + end + if self.failure == "refuse" then + return false, "warp refused" + end + return true + end + function ow:startWarpTo(id, x, y, facing, onDone, opts) + local arrive = self.arriveWarp + self.arriveWarp = nil + local ok, why = f:dispatch( + id, + x, + y, + facing, + { onDone = onDone, arrive = arrive, keepMusic = opts and opts.keepMusic, fade = opts and opts.fade } + ) + if not ok then + return ok, why + end + -- Native Gen 1/controller returns no value on successful admission. + end + function ow:acceptsMenuInput() + return not (f.busy or player.moving) + end + function ow:warpToMapId(id, x, y, facing, opts) + return f:dispatch(id, x, y, facing, opts or {}) + end + if backend == "gen1" and gen == 2 then + game.world = ow + end + if backend == "gen1" and gen == 3 then + local field = { running = true, _game = game, _session = save, _locks = {} } + f.field = field + package.loaded["src.core.game3.field"] = field + package.loaded["src.core.game3.runtime"] = { + getSession = function() + return game.session + end, + uiBusy = function() + return f.busy + end, + } + local map = { + current = home, + currentDef = function() + return data.maps[f.map.current] + end, + } + f.map = map + package.loaded["src.core.game3.map"] = map + package.loaded["src.core.game3.player"] = player + package.loaded["src.core.game3.scripting.space"] = {} + package.loaded["src.core.game3.battle.init"] = { + isActive = function() + return false + end, + } + package.loaded["src.core.game3.warp"] = { + isBusy = function() + return f.busy + end, + request = function(_, owner, id, x, y, facing, opts) + assert(owner == game, "native request retains Game") + return f:dispatch(id, x, y, facing, opts) + end, + } + end + function f:arrive(id) + local d = assert(self.destination) + local from = ow.map.id + Runtime.emit("map.exited", { mapId = from, toMapId = id or d[1] }) + ow.map = { id = id or d[1], def = data.maps[id or d[1]] } + player.cellX, player.cellY, player.facing = d[2], d[3], d[4] + if self.map then + self.map.current = ow.map.id + end + Runtime.emit("map.entered", { mapId = ow.map.id, fromMapId = from, via = "warp", map = ow.map }) + end + function f:complete(...) + self.busy, ow.transitioning = false, false + if self.callback then + self.callback(...) + end + end + function f:update() + local name = backend == "gen1" and "core.update" or "input.step" + return loader.hooks:call(name, function() + return "updated", nil, "tail" + end, game, 0) + end + return f +end +function M.run(root, backend, version) + local Runtime = require("src.mods.Runtime") + local Json = require("src.link.Json") + local report = { invalid = {}, busy = {}, successful = {} } + local function fixture() + return M.fixture(root, backend, version) + end + local f = fixture() + local function refused(args, expected) + local before = #f.trace + local ok, why = f.exports.request(unpack(args, 1, args.n or #args)) + assert(ok == nil and why == expected, tostring(why) .. " ~= " .. expected) + assert(#f.trace == before and f.ow.map.id == f.home and f.player.cellX == 0) + assert(f.ow.arriveWarp == nil and #f.exports.results().events == 0) + report.invalid[#report.invalid + 1] = why + end + refused({ false, 0, 0 }, "invalid map id") + refused({ "", 0, 0 }, "invalid map id") + refused({ "bad\nmap", 0, 0 }, "invalid map id") + refused({ string.rep("x", 257), 0, 0 }, "invalid map id") + refused({ "UNKNOWN", 0, 0 }, "unknown map: UNKNOWN") + for _, coordinate in ipairs({ -1, 0.5, math.huge, -math.huge, 0 / 0, "0", false }) do + refused({ f.dest, coordinate, 0 }, "invalid coordinates") + refused({ f.dest, 0, coordinate }, "invalid coordinates") + end + local scale = f.gen == 3 and 1 or 2 + refused({ f.dest, 2 * scale, 0 }, "coordinates out of bounds") + refused({ f.dest, 0, 3 * scale }, "coordinates out of bounds") + refused({ f.dest, 0, 0, "north" }, "invalid facing") + refused({ f.dest, 0, 0, "up", false }, "invalid options") + refused({ f.dest, 0, 0, "up", { arrive = "boat" } }, "invalid arrival") + refused({ f.dest, 0, 0, "up", { onDone = false } }, "invalid callback") + refused({ f.dest, 0, 0, "up", { keepMusic = 1 } }, "invalid options") + refused({ f.dest, 0, 0, "up", { fade = 1 } }, "invalid options") + refused({ f.dest, 0, 0, "up", { extra = true } }, "unknown warp option: extra") + if f.gen ~= 3 then + refused({ f.dest, 0, 0, "up", { fade = false } }, "unsupported fade") + end + for _, lock in ipairs({ "transitioning", "moving", "script", "overlay", "modscript" }) do + f = fixture() + if lock == "transitioning" then + f.ow.transitioning, f.busy = true, true + if f.field then + f.field.locked = true + end + elseif lock == "moving" then + f.player.moving = true + elseif lock == "script" then + f.ow.runner.isRunning = function() + return true + end + f.busy = true + if f.field then + package.loaded["src.core.game3.scripting.space"] = + { vm = { + isRunning = function() + return true + end, + } } + end + elseif lock == "overlay" then + f.game.stack.states[2] = {} + f.busy = true + else + f.ow._modScriptLock = {} + f.busy = true + if f.field then + f.field._modScriptLock = {} + end + end + local ok, why = f.exports.request(f.dest, 0, 0) + assert(ok == nil and type(why) == "string" and #f.trace == 0, lock .. ": " .. tostring(why)) + report.busy[#report.busy + 1] = lock + end + for _, options in ipairs({ + {}, + { arrive = "fly", keepMusic = true }, + { arrive = "teleport", keepMusic = false }, + }) do + f = fixture() + options.onDone = f.exports.notify + assert(f.exports.request(f.dest, 2 * scale - 1, 3 * scale - 1, "right", options)) + assert(f.exports.results().calls == 0 and #f.trace == 1) + local expectedArrival, expectedMusic = options.arrive, options.keepMusic + options.arrive, options.keepMusic, options.onDone = + "changed", true, function() + error("mutable options") + end + assert(f.trace[1].arrive == expectedArrival and f.trace[1].keepMusic == expectedMusic) + f:arrive() + f:complete("done", nil, "tail") + f:complete("replay") + assert(f.exports.results().calls == 1 and f.exports.results().args.n == 3) + assert(f.exports.results().args.first == "done" and f.exports.results().args.third == "tail") + local location = assert(f.exports.current()) + assert(location.mapId == f.dest and location.x == 2 * scale - 1 and location.y == 3 * scale - 1) + report.successful[#report.successful + 1] = { native = f.trace[1], result = f.exports.results() } + end + f = fixture() + assert(f.exports.request(f.dest, 0, 0)) + assert(f.trace[1].facing == (f.gen == 1 and "down" or "left")) + f:arrive() + f:complete() + report.defaultFacing = f.trace[1].facing + if f.gen == 3 then + f = fixture() + assert(f.exports.request("DEST", 1, 2, "up", { fade = false, onDone = f.exports.notify })) + assert(f.trace[1].mapId == f.dest and f.trace[1].fade == false) + f:arrive() + f:complete() + report.gen3Alias = true + end + f = fixture() + f.synchronous = true + assert(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify })) + assert(f.exports.results().calls == 1 and f.exports.results().args.first == "sync") + report.synchronous = true + for _, change in ipairs({ "game", "save", "world", "bus", "owner", "other_map" }) do + f = fixture() + assert(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify })) + f:arrive() + local restore + if change == "game" then + local old = f.live + f.live = {} + restore = function() + f.live = old + end + elseif change == "save" then + local old = f.game.save + f.game.save = {} + f.game.session = f.game.save + restore = function() + f.game.save = old + f.game.session = old + end + elseif change == "world" then + if backend == "gen1" and f.gen == 3 then + local old = package.loaded["src.core.game3.field"] + package.loaded["src.core.game3.field"] = { running = true } + restore = function() + package.loaded["src.core.game3.field"] = old + end + else + local old = f.game.stack.states + f.game.stack.states = {} + f.game.overworld = nil + f.game.world = nil + restore = function() + f.game.stack.states = old + f.game.overworld = f.ow + if f.gen == 2 then + f.game.world = f.ow + end + end + end + elseif change == "bus" then + local old = Runtime.events + Runtime.events = require("src.mods.Events").new() + restore = function() + Runtime.events = old + end + elseif change == "owner" then + f.loader:_rollback("warp") + else + f:arrive(f.home) + end + f:update() + -- A stale native completion must suppress the callback permanently. + f:complete("stale") + if restore then + restore() + end + f:complete("revived") + assert(f.exports.results().calls == 0, change .. " callback survived invalidation") + end + report.stale = true + f = fixture() + assert(f.exports.request(f.dest, 0, 0, "up", { + onDone = function() + error("authored warp failure", 0) + end, + })) + f:arrive() + f:complete() + f:complete() + assert(#f.loader.errors == 1 and f.loader.errors[1]:find("warp: world warp callback failed:", 1, true)) + assert(Runtime.currentMod == nil) + report.attribution = true + print("API3_CONTRACT " .. Json.encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index b7dcb960d..c3766dea8 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -3,8 +3,15 @@ package.path = "./?.lua;./?/init.lua;" .. package.path love = nil local root, backend, version, mode = arg[1], arg[2], arg[3], arg[4] assert( - root and backend and version and (mode == "normal" or mode == "rollback" or mode == "scaffold"), + root + and backend + and version + and (mode == "normal" or mode == "rollback" or mode == "scaffold" or mode == "warp"), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -dofile(codeRoot .. "/run.lua").run(root, backend, version, mode == "rollback", mode == "scaffold") +if mode == "warp" then + dofile(codeRoot .. "/warp.lua").run(root, backend, version) +else + dofile(codeRoot .. "/run.lua").run(root, backend, version, mode == "rollback", mode == "scaffold") +end diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua index a34fe4ae9..4e5ca3a7f 100644 --- a/tests/engine/api3_compat.lua +++ b/tests/engine/api3_compat.lua @@ -134,6 +134,7 @@ do T.eq(type(world.party), "function", "early world method callable") T.eq(type(battle.snapshot), "function", "early battle method callable") for _, name in ipairs({ + "warpTo", "party", "current", "activeBlockAt", @@ -145,7 +146,6 @@ do T.eq(m:supports("world", name), true, "implemented world method " .. name) end for _, name in ipairs({ - "warpTo", "toggleObject", "replaceBlock", "spawnNpc", @@ -217,6 +217,7 @@ end for _, version in ipairs({ "platinum", "black" }) do local m = boot(3, nil, false, version) T.eq(m:supports("world", "party"), false, "no shared Gen1 contract claimed for " .. version) + T.eq(m:supports("world", "warpTo"), false, "no shared warp promised for " .. version) local value, why = m.world:party() T.eq(value, nil, "unsupported generation call refuses") T.check(why:find("unsupported:", 1, true), "unsupported generation is explicit") diff --git a/tests/engine/api3_warp.lua b/tests/engine/api3_warp.lua new file mode 100644 index 000000000..0bc8ddbb1 --- /dev/null +++ b/tests/engine/api3_warp.lua @@ -0,0 +1,249 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.warp") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +love = nil +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared warp contract: " .. tostring(why)) +end +for _, lock in ipairs({ + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen3Decorate", + "gen3Locked", + "frameScriptThisTick", + "_modScriptLock", + "parallelRunners", + "parallelQueue", + "pendingScripts", + "scriptMoves", + "stairExit", + "spinning", + "inputLocked", + "fieldMove", + "linkSession", + "linkNet", +}) do + local f = fixture() + if lock == "linkSession" then + f.game.linkSession = {} + elseif lock == "linkNet" then + f.game.linkNet = { closed = false } + elseif lock == "stairExit" or lock == "spinning" or lock == "inputLocked" or lock == "fieldMove" then + f.player[lock] = true + elseif lock:find("Runners") or lock:find("Queue") or lock == "pendingScripts" or lock == "scriptMoves" then + f.ow[lock] = { {} } + else + f.ow[lock] = true + end + local ok, why = f.exports.request(f.dest, 0, 0) + T.eq(ok, nil, lock .. " declines admission") + T.eq(why, "world is busy", lock .. " explains busy state") + T.eq(#f.trace, 0, lock .. " never dispatches") +end +do + local f = fixture() + f.game.save = nil + T.eq(f.exports.request(f.dest, 0, 0), nil, "missing save refuses") + f.live = nil + T.eq(f.exports.request(f.dest, 0, 0), nil, "missing game refuses") + T.eq(#f.trace, 0, "missing context never dispatches") +end +for _, size in ipairs({ false, -1, 0, 0.5, math.huge, 0 / 0 }) do + local f = fixture() + f.game.data.maps[f.dest].width = size + local ok, why = f.exports.request(f.dest, 0, 0) + T.eq(ok, nil, "malformed map dimensions refuse") + T.eq(why, "invalid map dimensions", "dimensions diagnosed before native work") + T.eq(#f.trace, 0, "malformed maps never dispatch") +end +do + local f = fixture() + local opts = setmetatable({}, { + __index = function() + error("must not run") + end, + }) + local ok, why = f.exports.request(f.dest, 0, 0, "down", opts) + T.eq(ok, nil, "metatable options rejected") + T.eq(why, "invalid options", "plain options required") + T.eq(#f.trace, 0, "metatable never reaches native warp") +end +for _, change in ipairs({ "data", "stack", "player", "version" }) do + local f = fixture() + T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start " .. change) + f:arrive() + local restore + if change == "version" then + Version.set("blue") + restore = function() + Version.set("red") + end + else + local obj = change == "player" and f.ow or f.game + local previous = obj[change] + obj[change] = {} + restore = function() + obj[change] = previous + end + end + f:update() + restore() + f:complete() + T.eq(f.exports.results().calls, 0, change .. " cannot revive callback after observed change") +end +for _, failure in ipairs({ "refuse", "throw" }) do + local f = fixture() + f.failure, f.synchronous = failure, true + f.ow.arriveWarp = "original" + local ok, why = f.exports.request(f.dest, 0, 0, "up", { arrive = "fly", onDone = f.exports.notify }) + T.eq(ok, nil, "native " .. failure .. " is not admitted") + T.check(type(why) == "string", "native failure explained") + T.eq(f.exports.results().calls, 0, "synchronous callback waits for native acceptance") + T.eq(f.ow.arriveWarp, "original", "failed dispatch restores borrowed arrival field") + f:complete() + T.eq(f.exports.results().calls, 0, "failed callback cannot replay") +end +do + local f = fixture() + f.synchronous = true + local ok = f.exports.request(f.dest, 0, 0, "up", { + onDone = function(...) + T.eq(Runtime.currentMod, "warp", "callback attributed to owning mod") + f.exports.notify(...) + f.synchronous = false + T.check( + f.exports.request(f.home, 1, 1, "down", { onDone = f.exports.notify }), + "callback may start next warp" + ) + end, + }) + T.check(ok, "synchronous native arrival admitted") + T.eq(#f.trace, 2, "one reentrant request dispatches once") + T.eq(Runtime.currentMod, nil, "callback restores previous runtime owner") + f:arrive() + f:complete() + T.eq(f.exports.results().calls, 2, "old completion does not clear next request") +end +for _, operation in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start before " .. operation) + f:arrive() + if operation == "rollback" then + f.loader:_rollback("warp") + elseif operation == "reload" then + fixture() -- A new loader/session retires every previous facade. + else + Loader.teardown() + end + f:complete() + T.eq(f.exports.results().calls, 0, operation .. " retires callback") + local ok, why = f.exports.request(f.dest, 0, 0) + T.eq(ok, nil, operation .. " retires facade") + T.eq(why, "warp facade is retired", "stale facade diagnostic") +end +for _, direction in ipairs({ "up", "down", "left", "right" }) do + local f = fixture() + T.check(f.exports.request(f.home, 1, 1, direction, { onDone = f.exports.notify }), "same-map " .. direction) + T.eq(f.trace[1].facing, direction, "direction reaches native warp unchanged") + f:arrive() + f:complete() + T.eq(f.exports.results().calls, 1, "same-map arrival not mistaken for stale completion") +end +do + local f = fixture() + T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start before false arrival") + f:complete() + T.eq(f.exports.results().calls, 0, "completion on old map never invokes mod") + f:arrive() + f:complete() + T.eq(f.exports.results().calls, 0, "false completion cannot be revived by later arrival") +end +-- Exercise the actual native transition; only map materialization is synthetic. +love = require("tests.love_stub") +local OW = require("src.world.OverworldController") +local Transition = require("src.render.Transition") +local StateStack = require("src.core.StateStack") +local function bind(game) + for i = 1, 100 do + local name, old = debug.getupvalue(OW.startWarpTo, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(OW.startWarpTo, i, game) + return function() + debug.setupvalue(OW.startWarpTo, i, old) + end + end + end + error("native Game upvalue missing") +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, fade in ipairs({ true, false }) do + if fade or Version.generation(version) == 3 then + local f = fixture(version) + setmetatable(f.game.stack, { __index = StateStack }) + f.ow.startWarpTo = OW.startWarpTo + local restore = bind(f.game) + function f.ow:frlgBehaviours() + return false + end + function f.ow:noteGen2Spawn(from) + self.notedFrom = from + end + function f.ow:setMap(id, x, y, facing, opts) + f.destination = { id, x, y, facing } + T.eq(opts.via, "warp", "native transition retains warp reason") + T.eq(opts.keepMusic, true, "native transition retains music request") + f:arrive() + end + local opts = { onDone = f.exports.notify, keepMusic = true } + if f.gen == 3 then + opts.fade = fade + end + T.check(f.exports.request(f.dest, 1, 2, "right", opts), "actual native warp admitted") + if fade then + local state = f.game.stack:top() + T.eq(getmetatable(state), Transition, "ordinary warp pushes native fade") + T.eq(f.exports.results().calls, 0, "callback waits for native transition") + for _ = 1, state.frames + state.framesIn + 2 do + if f.game.stack:top() == state then + state:update(1 / 60) + end + end + else + T.eq(#f.game.stack.states, 1, "no-fade warp creates no transition screen") + end + T.eq(f.ow.map.id, f.dest, "native transition arrives at destination") + T.eq(f.ow.notedFrom, f.home, "native spawn bookkeeping retained") + T.same(f.ow.warpEntryCell, { x = 1, y = 2 }, "native arrival tile guard retained") + T.eq(f.ow.transitioning, false, "native transition releases lock") + T.eq(f.exports.results().calls, 1, "actual native callback runs exactly once") + restore() + end + end +end +-- Legacy public facade continues to ignore fade, rather than opting into API 3. +do + local f = fixture("firered") + local native = require("src.world.WorldAPI").new(f.game, "legacy") + T.check(native:warpTo(f.dest, -1, 0, "north", { fade = false }), "legacy dispatch behavior unchanged") + T.eq(f.trace[1].fade, nil, "legacy fade option remains ignored") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 portable warp") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 48ba0eb9b..52ef00c31 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold"): + for mode in ("normal", "rollback", "scaffold", "warp"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 2ea78e3db7a07532c6ab08a2c0cd0e7b7edeee5d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:56:22 +0200 Subject: [PATCH 017/138] Add API 3 object visibility and block edits with shared contracts --- docs/api3-portability.md | 15 +- docs/api3-warp.md | 4 +- docs/api3-world-edits.md | 124 ++++++++++ docs/modding.md | 3 +- src/mods/Api3.lua | 15 +- src/mods/Api3Warp.lua | 4 +- src/mods/Api3WorldEdits.lua | 154 ++++++++++++ src/script/Gen2Commands.lua | 6 + tests/api3_contract/mods/edits/main.lua | 37 +++ tests/api3_contract/mods/edits/manifest.json | 1 + tests/api3_contract/warp.lua | 11 +- tests/api3_contract/worker.lua | 12 +- tests/api3_contract/world_edits.lua | 230 ++++++++++++++++++ tests/engine/api3_compat.lua | 6 +- tests/engine/api3_world_edits.lua | 241 +++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 16 files changed, 844 insertions(+), 21 deletions(-) create mode 100644 docs/api3-world-edits.md create mode 100644 src/mods/Api3WorldEdits.lua create mode 100644 tests/api3_contract/mods/edits/main.lua create mode 100644 tests/api3_contract/mods/edits/manifest.json create mode 100644 tests/api3_contract/world_edits.lua create mode 100644 tests/engine/api3_world_edits.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index a484539fa..0c7b584ed 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -77,11 +77,13 @@ The common world calls currently enabled for generations 1–3 are: `canFly`, `flyTo`; - `getFlag`/`setFlag` for private `mod:` boolean flags; - `queueScript` for the existing bounded portable script language; -- [`warpTo`](api3-warp.md) for validated native map transitions. +- [`warpTo`](api3-warp.md) for validated native map transitions; +- [`toggleObject` and `replaceBlock`](api3-world-edits.md) for validated native + visibility and active block/metatile edits. Check `mod:supports("world", name)` before relying on a call. The other Gen1 world method names remain callable but explicitly return -`nil, "unsupported: ..."`: object/block mutations, runtime NPC operations, +`nil, "unsupported: ..."`: runtime NPC operations, `startWildBattle` and `invalidateMap` have not yet been verified against the Gen1 API-3 contract. Their native/API-2 counterparts are unaffected. Native symbolic flag mappings and `mod.datasets:open` are also explicitly unavailable. @@ -128,8 +130,9 @@ the mod fixtures contain no engine imports or engine-specific branches. The runner also generates an API-3 mod through the real scaffold CLI and loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp -cases use the same public mod on both engines and native-shaped timing seams: -32 comparisons and 64 isolated loader runs. It compares JSON projections of public results and +cases use the same public mod on both engines and native-shaped timing seams. +Eight object/block cases extend this to 40 comparisons and 80 isolated loader +runs. It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge @@ -139,7 +142,9 @@ writeback, custom events, unsupported-name handling, frozen content and failed owner cleanup. Warp cases also compare validation errors, native dispatch arguments, destination events, boundary coordinates, arrival options, callback argument holes/replay, stale sessions/owners, synchronous completion and error -attribution. Separate Gen2 tests check API-1/2 behavior, contradictory +attribution. Object/block cases compare native reference routing, visibility/flag +changes, block geometry/bounds, native reads and one event per successful edit. +Separate Gen2 tests check API-1/2 behavior, contradictory manifest fields, honest availability and malformed flag stores. CI pins the same external commit and uploads the report. diff --git a/docs/api3-warp.md b/docs/api3-warp.md index 4b86542dc..dbb19a6a8 100644 --- a/docs/api3-warp.md +++ b/docs/api3-warp.md @@ -118,8 +118,8 @@ Emerald. The shared driver builds each engine's native-shaped Game/world data and replaces only native transition timing seams. It compares validation reasons, dispatch arguments, public destination events and callback results. The mod has no engine imports or backend-specific branches. Together with the -existing normal, rollback and generated-starter cases, CI now performs 32 -comparisons / 64 isolated loader runs. +existing normal, rollback, generated-starter and object/block cases, CI now +performs 40 comparisons / 80 isolated loader runs. The local suite additionally drives Gen2Recomped's real native `startWarpTo` and `Transition` using synthetic map materialization, including diff --git a/docs/api3-world-edits.md b/docs/api3-world-edits.md new file mode 100644 index 000000000..4297c602f --- /dev/null +++ b/docs/api3-world-edits.md @@ -0,0 +1,124 @@ +# API-3 object visibility and block edits + +API-3 mods can call the same public methods on both engines: + +```lua +local mod = ... +local world = mod.world -- Retain at entry; invoke later from an idle world. + +local function openGate(mapId, objectRef, blockX, blockY, openBlock) + if not mod:supports("world", "toggleObject") then return end + local ok, why = world:toggleObject(mapId, objectRef, false) + if not ok then return nil, why end + if mod:supports("world", "replaceBlock") then + return world:replaceBlock(blockX, blockY, openBlock) + end +end +``` + +Each call returns `true` or `nil, reason`. Calls are independent, not an atomic +multi-command transaction. Support is advertised for generations 1–3; API-3 +Gen4/5 still refuse both methods. A captured facade follows the current Game +without bootstrapping one. Rollback, reload and teardown retire its mutation +lease; retained calls return `nil, "world facade is retired"`. API-1/2 behavior +and the portable script vocabulary are unchanged. + +Both methods require a save and an idle, topmost overworld. Native scripts, +portable scripts, queued/parallel scripts, menus, movement, transitions, +controller locks and link sessions refuse admission. Reentrant object/block +edits from an edit's own event/native callback return `nil, "world is busy"`; +the guard is released on success and native exceptions. + +## `toggleObject(mapId, objectRef, visible)` + +`mapId` and string references are nonempty strings of at most 256 bytes without +control characters. Numeric references must be finite nonnegative integers; +`visible` must be an actual boolean. The map must exist in the active merged +dataset. Gen3 also resolves unprefixed map names using the current version's +prefix when an exact ID is absent, as `warpTo` does. + +Reference routing and persistence deliberately retain the Gen1 API-3/native +contracts, rather than treating all generations' object numbers as identical: + +| Generation | Reference | Scope and effects | +| --- | --- | --- | +| 1 | Native visibility key, normally the object's `name`; numeric keys are also accepted | May name an inactive map or a key with no currently defined actor. Writes `save.objectToggles`. An active map refresh uses the native seamless reload, retaining player cell/facing and music. | +| 2 | 1-based position in `map.def.objects`, or its `name` | Active map only. Resolves the public slot to its native template and uses the same effect helper as native appear/disappear. Writes the object's event flag and visit-scoped visibility, synchronizing only that actor. | +| 3 | Native local object ID, `name` or `id` | Active map only. Uses native add/remove commands, including event flags, spawned-object state and flagless visit state; synchronizes only the selected actor. | + +This API does not make map/object IDs portable between cartridges: select a +reference from the active dataset. On Gen2/3, an unknown reference returns +`nil, "no such object: "`; an inactive map returns +`nil, "map is not active: "`. Missing object lists and malformed native +object/store metadata refuse before native writes. Gen1's key store keeps its +existing ability to prepare a later map's visibility. + +Flagged Gen2/3 objects use their native persistent flags. Flagless objects +retain native visit-scoped behavior. A targeted sync does not respawn, relocate +or re-derive unrelated live actors, even when several templates share a flag. +Other native visibility gates (time, story flags, script-spawn rules) still +apply; acceptance is not a promise to override every native gate. + +A successful call emits one `world.object_toggled` event with +`mapId`, `objName = objectRef` and the boolean `visible`. Repeating an accepted +value still follows the native call/event contract. Gen1's active refresh may +also produce its normal native map lifecycle events. Gen2/3 do not reload the +whole map for this operation. + +## `replaceBlock(blockX, blockY, blockId)` + +This changes only the active map. Coordinates are zero-based whole **block** +coordinates, with `0 <= x < map.width` and `0 <= y < map.height`: + +| Generation | One coordinate unit | Valid ID range | +| --- | --- | --- | +| 1/2 | 32-pixel block, 2×2 walk cells | 0–255, and the block must exist in the active tileset | +| 3 | 16-pixel metatile, one walk cell | 0–1023 | + +Coordinates/IDs must be finite integers. Gen3 range validation does not promise +that every ID has artwork in a particular tileset. The adapter checks the +current block, runtime geometry and redraw entry before mutation. Unknown +blocks, unavailable geometry/renderers, fractions, NaN/infinity, negatives and +out-of-bounds coordinates are declined without writing a patch or emitting an +event. Typical reasons are `invalid block id`, `unknown block id`, +`invalid block coordinates`, `block coordinates out of bounds`, +`block unavailable`, `unsupported block geometry` and +`block renderer unavailable`. + +Gen1/2 use the existing native world block replacement. Gen3 uses the host's +native metatile command with its default non-impassable setting, including +clearing old collision/shut-cell overrides, and redraws the runtime map. +Terrain/collision tables remain native; there is no invented shared collision +registry. `activeBlockAt` immediately sees the change. One successful operation +emits one `world.block_replaced` event with `mapId`, `bx`, `by` and `block`. + +On Gen2Recomped this is an instance patch, leaving generated map data intact. +It is not a persistent content edit: permanent layout changes belong in the +map registry. Native execution failures can occur after a native write and do +not promise transactional rollback; preflight refusals have no such effects. + +## Verification + +```sh +luajit tests/engine/api3_world_edits.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The existing Gen1 pin stays at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same new fixture manifest/source +runs under both production loaders for Red, Blue, Yellow, Gold, Silver, +Crystal, FireRed and Emerald. Engine-specific setup stays in the shared +driver, never in the mod. Native-shaped map/render/object seams let the driver +compare refusal reasons, visibility/flag effects, native block reads, geometry +and event payloads. The full checker now runs 40 comparisons / 80 isolated +processes, including all previous entry, rollback, generated-starter and warp +cases. + +Local tests additionally exercise the actual Gen2Recomped native Map, +OverworldState block replacement and object synchronization with synthetic +pooled actors, including shared flags, retained positions, flagless resets, +packed metatiles, collision clearing, malformed stores, reentrancy and retired +owners. These are ROM-free runtime-boundary tests; they do not establish +rendered retail-ROM equivalence or full scene-playthrough coverage. diff --git a/docs/modding.md b/docs/modding.md index f29c2f6c8..153ac03e7 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -23,7 +23,8 @@ luajit tools/gen_registry_docs.lua ../gen1recomp.wiki The [API-3 entry and cross-engine contracts](api3-portability.md) describe using the same manifest and mod source on Gen1Recomp and Gen2Recomped. API 3 selects the unified adapters automatically; API 1/2 keep their defaults. -See [API-3 world warps](api3-warp.md) for validated map transitions and callback lifetime. +See [API-3 world warps](api3-warp.md) for validated map transitions and callback lifetime, +and [API-3 object/block edits](api3-world-edits.md) for native visibility and terrain changes. ## Feature queries (`mod:supports`) diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index 6f2de9444..29d7f0139 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -3,6 +3,7 @@ local Records = require("src.mods.Api3Records") local Runtime = require("src.mods.Runtime") local Warp = require("src.mods.Api3Warp") +local Edits = require("src.mods.Api3WorldEdits") local copy = require("src.mods.Merge").deepCopy local A = {} local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty @@ -13,7 +14,7 @@ local SUPPORTED = {} for name in ([[overworld current activeBlockAt party canReorderParty reorderParty availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters - setFlag getFlag liveMaps queueScript warpTo]]):gmatch("%S+") + setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock]]):gmatch("%S+") do SUPPORTED[name] = true end @@ -79,7 +80,10 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) end return nativeWorld end - local warp = supportedGeneration and Warp.new(loader, api.id, gen, current, native) + local warp, licensed + if supportedGeneration then + warp, licensed = Warp.new(loader, api.id, gen, current, native) + end local function dispatch(name, ...) if not supportedGeneration or not SUPPORTED[name] then return nil, "unsupported: API 3 world method " .. name @@ -87,10 +91,17 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) if name == "warpTo" then return warp(...) end + local edit = name == "toggleObject" or name == "replaceBlock" + if edit and not licensed() then + return nil, "world facade is retired" + end local game = current() if not game then return nil, "no game" end + if edit then + return Edits.call(game, native(game), gen, engineRequire, name, ...) + end if name == "setFlag" or name == "getFlag" then if not game.save then return nil, "no save" diff --git a/src/mods/Api3Warp.lua b/src/mods/Api3Warp.lua index 8deba26b3..400465d87 100644 --- a/src/mods/Api3Warp.lua +++ b/src/mods/Api3Warp.lua @@ -96,7 +96,7 @@ function W.new(loader, owner, generation, current, native) return contexts[loader] == context and context.owners[owner] == lease end observe(loader) - return function(mapId, x, y, facing, options) + local function warp(mapId, x, y, facing, options) if not licensed() then return nil, "warp facade is retired" end @@ -244,5 +244,7 @@ function W.new(loader, owner, generation, current, native) end return true end + -- Synchronous API-3 edits share this owner lease and loader lifecycle. + return warp, licensed end return W diff --git a/src/mods/Api3WorldEdits.lua b/src/mods/Api3WorldEdits.lua new file mode 100644 index 000000000..522adcb04 --- /dev/null +++ b/src/mods/Api3WorldEdits.lua @@ -0,0 +1,154 @@ +-- Validated API-3 object/block edits over this host's native runtime paths. +local Actions = require("src.world.WorldActions") +local Reads = require("src.mods.WorldReads") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local E = {} +local running = setmetatable({}, { __mode = "k" }) +local function integer(value, maximum) + return type(value) == "number" + and value == value + and value >= 0 + and value < math.huge + and value % 1 == 0 + and (not maximum or value <= maximum) +end +local function identifier(value) + return type(value) == "string" and #value > 0 and #value <= 256 and not value:find("%c") +end +local function store(value) + return value == nil or type(value) == "table" +end +local function toggle(game, ow, api, gen, req, mapId, ref, visible) + if not identifier(mapId) or not (identifier(ref) or integer(ref)) or type(visible) ~= "boolean" then + return nil, "invalid object toggle" + end + local maps = game.data and game.data.maps + local id = mapId + if gen == 3 and maps and not maps[id] then + local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_" + if maps[prefix .. id] then + id = prefix .. id + end + end + local def = maps and maps[id] + if not def then + return nil, "unknown map: " .. mapId + end + local save = game.save + if not store(save.objectToggles) or not store(save.objectToggles and save.objectToggles[id]) then + return nil, "invalid object toggle store" + end + if not store(save.flags) then + return nil, "invalid flag store" + end + if gen == 1 then + -- Gen 1 permits off-map keys and resolves their visibility on map load. + return api:toggleObject(id, ref, visible) + end + if ow.map.id ~= id then + return nil, "map is not active: " .. mapId + end + local objects = ow.map.def and ow.map.def.objects + if type(objects) ~= "table" then + return nil, "map has no objects" + end + local object + for i, candidate in ipairs(objects) do + local numeric = gen == 2 and i or tonumber(candidate.localId or candidate.index) + if numeric == ref or candidate.name == ref or gen == 3 and candidate.id == ref then + object = candidate + break + end + end + if not object then + return nil, "no such object: " .. tostring(ref) + end + if type(ow.syncObjectVisibility) ~= "function" then + return nil, "object runtime unavailable" + end + local ctx = { game = game, save = save, overworld = ow, mapId = id } + if gen == 2 then + if not store(save.g2ObjectToggles) then + return nil, "invalid object toggle store" + end + req("src.script.Gen2Commands").setObjectVisibility(ctx, object, id, visible) + else + local index = object.index + if not integer(index, 254) or index == 0 then + return nil, "invalid object definition" + end + if + not store(save.gen3Spawned) + or not store(save.gen3Spawned and save.gen3Spawned[id]) + or not store(save.gen3SessionObjects) + then + return nil, "invalid object toggle store" + end + req("src.script.Gen3Commands") + local commands = req("src.script.Commands") + local command = visible and commands.g3_show_object or commands.g3_hide_object + command(ctx, index) + end + Runtime.emit("world.object_toggled", { mapId = id, objName = ref, visible = visible }) + return true +end +local function replace(game, ow, api, gen, req, bx, by, block) + if not integer(block, gen == 3 and 1023 or 255) then + return nil, "invalid block id" + end + local present, metadata = Reads.activeBlockAt(ow.map, ow.map.id, bx, by) + if present == nil then + return nil, metadata + end + local cells = gen == 3 and 1 or 2 + if ow.map.blockCells ~= cells then + return nil, "unsupported block geometry" + end + if gen ~= 3 and not (ow.map.tileset and ow.map.tileset.blocks and ow.map.tileset.blocks[block + 1]) then + return nil, "unknown block id" + end + local renderer = ow.map.renderer + if not renderer or type(renderer.rebuild) ~= "function" then + return nil, "block renderer unavailable" + end + if gen ~= 3 then + return api:replaceBlock(bx, by, block) + end + -- Like Gen1's Gen3 Field.setMetatile default, use the native non-impassable + -- metatile command; it replaces the old collision override as well. + req("src.script.Gen3Commands") + req("src.script.Commands").g3_set_metatile( + { game = game, save = game.save, overworld = ow }, + bx, + by, + block, + 0 + ) + renderer:rebuild() + ow.map.blocksDirty = nil + Runtime.emit("world.block_replaced", { mapId = ow.map.id, bx = bx, by = by, block = block }) + return true +end +function E.call(game, api, generation, req, name, ...) + local ow = api:overworld() + if not ow then + return nil, "no overworld" + end + local ok, reason = Actions.ready(game, ow) + if not ok then + return nil, reason + end + if running[game] or ow.gen3Locked or #(ow.parallelRunners or {}) > 0 or #(ow.parallelQueue or {}) > 0 then + return nil, "world is busy" + end + running[game] = true + local operation = name == "toggleObject" and toggle or replace + local called, value, why = pcall(operation, game, ow, api, generation, req, ...) + running[game] = nil + if not called then + error(value, 0) + end + return value, why +end +return E diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..7219012da 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -1098,6 +1098,12 @@ function Commands.g2_object(ctx, index, visible) obj, mapId = objectByIndex(ctx, index) end if not obj or not mapId then return end + return Gen2Commands.setObjectVisibility(ctx, obj, mapId, visible) +end + +-- Shared native effect after resolving a map object. Public API adapters can +-- supply the resolved template without depending on cached script-ID numbering. +function Gen2Commands.setObjectVisibility(ctx, obj, mapId, visible) local OverworldState = require("src.world.OverworldController") local name = OverworldState.objectToggleKey(obj) -- The PERSISTENT half, which this used to leave to a `setevent` the ROM diff --git a/tests/api3_contract/mods/edits/main.lua b/tests/api3_contract/mods/edits/main.lua new file mode 100644 index 000000000..d26c5321e --- /dev/null +++ b/tests/api3_contract/mods/edits/main.lua @@ -0,0 +1,37 @@ +-- Identical source on both engines; native layout stays in the driver. +local mod = ... +local world = mod.world +assert(mod:supports("world", "toggleObject")) +assert(mod:supports("world", "replaceBlock")) +local ok, why = world:toggleObject("HOME", "NPC", false) +assert(ok == nil and why == "no game") +ok, why = world:replaceBlock(0, 0, 7) +assert(ok == nil and why == "no game") +local events, reenter, nested = {}, false, nil +mod.events:on("world.object_toggled", function(ev) + events[#events + 1] = { kind = "object", mapId = ev.mapId, ref = ev.objName, visible = ev.visible } + if reenter then + nested = { world:replaceBlock(0, 0, 7) } + end +end) +mod.events:on("world.block_replaced", function(ev) + events[#events + 1] = { kind = "block", mapId = ev.mapId, x = ev.bx, y = ev.by, block = ev.block } +end) +mod.exports.toggle = function(...) + return world:toggleObject(...) +end +mod.exports.replace = function(...) + return world:replaceBlock(...) +end +mod.exports.read = function(...) + return world:activeBlockAt(...) +end +mod.exports.events = function() + return events +end +mod.exports.reenter = function(value) + reenter = value +end +mod.exports.nested = function() + return nested +end diff --git a/tests/api3_contract/mods/edits/manifest.json b/tests/api3_contract/mods/edits/manifest.json new file mode 100644 index 000000000..8d0912a07 --- /dev/null +++ b/tests/api3_contract/mods/edits/manifest.json @@ -0,0 +1 @@ +{"id":"edits","name":"Portable World Edits","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/warp.lua b/tests/api3_contract/warp.lua index 1b1e5d376..5e7493d19 100644 --- a/tests/api3_contract/warp.lua +++ b/tests/api3_contract/warp.lua @@ -8,7 +8,8 @@ local function read(path) f:close() return value end -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, modId) + modId = modId or "warp" local Version = require("src.core.GameVersion") local Runtime = require("src.mods.Runtime") Version.set(version) @@ -32,7 +33,7 @@ function M.fixture(root, backend, version) end local files = {} for _, name in ipairs({ "manifest.json", "main.lua" }) do - files["mods/warp/" .. name] = read(root .. "/mods/warp/" .. name) + files["mods/" .. modId .. "/" .. name] = read(root .. "/mods/" .. modId .. "/" .. name) end local fs = { read = function(path) @@ -45,12 +46,12 @@ function M.fixture(root, backend, version) if files[path] then return { type = "file", size = #files[path] } end - if path == "mods" or path == "mods/warp" then + if path == "mods" or path == "mods/" .. modId then return { type = "directory" } end end, getDirectoryItems = function(path) - return path == "mods" and { "warp" } or {} + return path == "mods" and { modId } or {} end, } local loader = require("src.mods.Loader").new({ fs = fs, dev = false, generation = gen, version = version }) @@ -60,7 +61,7 @@ function M.fixture(root, backend, version) return f.live end assert(loader:load(data), table.concat(loader.errors, "; ")) - f.exports = assert(loader.exports.warp) + f.exports = assert(loader.exports[modId]) local save = { party = {}, flags = {}, modData = {} } local player = { cellX = 0, cellY = 0, facing = "left" } local ow = { diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index c3766dea8..0fe96b9ab 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -6,11 +6,19 @@ assert( root and backend and version - and (mode == "normal" or mode == "rollback" or mode == "scaffold" or mode == "warp"), + and ( + mode == "normal" + or mode == "rollback" + or mode == "scaffold" + or mode == "warp" + or mode == "world_edits" + ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "warp" then +if mode == "world_edits" then + dofile(codeRoot .. "/world_edits.lua").run(root, backend, version) +elseif mode == "warp" then dofile(codeRoot .. "/warp.lua").run(root, backend, version) else dofile(codeRoot .. "/run.lua").run(root, backend, version, mode == "rollback", mode == "scaffold") diff --git a/tests/api3_contract/world_edits.lua b/tests/api3_contract/world_edits.lua new file mode 100644 index 000000000..7d77c555b --- /dev/null +++ b/tests/api3_contract/world_edits.lua @@ -0,0 +1,230 @@ +-- Real loaders, adapters and host commands, with native-shaped map/render seams. +local M = {} +local unpack = table.unpack or unpack +function M.fixture(root, backend, version) + local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/world_edits%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "edits") + local Runtime = require("src.mods.Runtime") + local ow, game = f.ow, f.game + local def = game.data.maps[f.home] + local flagged = + { index = 4, localId = 4, name = "NPC", id = "npc", x = 1, y = 1, eventFlag = "FLAG_A", flag = 7 } + local flagless = { index = 8, localId = 8, name = "TEMP", x = 0, y = 1 } + def.objects = { flagged, flagless } + def.blocks = { 7, 8, 7, 8, 7, 8 } + local tiles = { + walkable = {}, + blocks = { [1] = {}, [8] = {}, [9] = {}, [256] = {} }, + collision = {}, + blockCells = f.gen == 3 and 1 or 2, + blockTiles = f.gen == 3 and 2 or 4, + } + f.flagged, f.flagless, f.visible = flagged, flagless, { NPC = true, TEMP = true } + f.rebuilds, f.syncs, f.reloads = 0, 0, 0 + if backend == "gen1" and f.gen == 2 then + -- Native Gen2 object definitions use list keys; public refs are still 1-based. + flagged.index, flagless.index, flagged.eventFlag = 1, 2, 7 + ow.map = require("src.world.gen2.Map").new(def, tiles) + elseif backend == "gen1" and f.gen == 3 then + -- Native Gen3 uses the reflected layout and field override service. + local overrides = {} + def.midLayout.midAt = function(_, x, y) + return def.blocks[y * def.width + x + 1] + end + f.field.metatileOverrideAt = function(_, x, y) + return overrides[y * 1024 + x] + end + f.field.setMetatile = function(x, y, block) + overrides[y * 1024 + x] = { metatile = block } + f.rebuilds = f.rebuilds + 1 + Runtime.emit("world.block_replaced", { mapId = f.home, bx = x, by = y, block = block }) + end + package.loaded["src.core.game3.objects"] = { + _defs = { flagged, flagless }, + addObject = function(id) + local obj = id == flagged.localId and flagged or flagless + f.visible[obj.name] = true + if obj.flag then + game.save.flags[obj.flag] = false + end + end, + removeObject = function(id) + local obj = id == flagged.localId and flagged or flagless + f.visible[obj.name] = false + if obj.flag then + game.save.flags[obj.flag] = true + end + end, + } + else + ow.map = require("src.world.Map").new(def, tiles) + end + ow.map.renderer = { + rebuild = function() + f.rebuilds = f.rebuilds + 1 + end, + } + function ow:replaceBlock(x, y, block) + self.map:setBlock(x, y, block) + self.map.renderer:rebuild() + self.map.blocksDirty = nil + Runtime.emit("world.block_replaced", { mapId = self.map.id, bx = x, by = y, block = block }) + end + function ow:changeBlock(x, y, block) + self.map.blocks[y * self.map.width + x + 1] = block + self.map.renderer:rebuild() + Runtime.emit("world.block_replaced", { mapId = self.map.id, bx = x, by = y, block = block }) + end + function ow:appearObject(id) + local obj = def.objects[id - 1] + f.visible[obj.name] = true + if obj.eventFlag then + game.save.flags[obj.eventFlag] = false + end + end + function ow:disappearObject(id) + local obj = def.objects[id - 1] + f.visible[obj.name] = false + if obj.eventFlag then + game.save.flags[obj.eventFlag] = true + end + end + function ow:syncObjectVisibility(obj) + f.syncs = f.syncs + 1 + local save = game.save + local session = f.gen == 2 and save.g2ObjectToggles or save.gen3SessionObjects + local key = f.gen == 2 and obj.name or obj.index + local value = session and session.mapId == f.home and session[key] + if obj.eventFlag and save.flags and save.flags[obj.eventFlag] ~= nil then + value = not save.flags[obj.eventFlag] + end + f.visible[obj.name] = value == nil and true or value + end + function ow:setMap(id, x, y, facing, opts) + f.reloads = f.reloads + 1 + assert(id == f.home and x == 0 and y == 0 and facing == "left", "seamless refresh preserves player") + assert(opts.seamless and opts.keepMusic and opts.via == "reload") + for _, obj in ipairs(def.objects) do + local toggles = game.save.objectToggles[id] + if toggles[obj.name] ~= nil then + f.visible[obj.name] = toggles[obj.name] + end + end + end + function f:flagValue() + local key = backend == "gen1" and self.gen > 1 and 7 or "FLAG_A" + return game.save.flags[key] + end + return f +end +function M.run(root, backend, version) + local Json = require("src.link.Json") + local f = M.fixture(root, backend, version) + local report = { invalid = {}, toggles = {}, blocks = {} } + local function unchanged() + assert(#f.exports.events() == 0 and f.rebuilds == 0 and f.reloads == 0 and f.syncs == 0) + assert(next(f.game.save.flags) == nil and f.game.save.objectToggles == nil) + assert(f.visible.NPC and f.visible.TEMP and f.player.cellX == 0 and f.player.cellY == 0) + end + local function refusal(method, args, reason) + local ok, why = f.exports[method](unpack(args, 1, args.n or #args)) + assert(ok == nil and why == reason, tostring(why) .. " ~= " .. reason) + unchanged() + report.invalid[#report.invalid + 1] = why + end + for _, bad in ipairs({ false, "", "bad\nname", string.rep("x", 257) }) do + refusal("toggle", { bad, "NPC", false }, "invalid object toggle") + refusal("toggle", { f.home, bad, false }, "invalid object toggle") + end + for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0 }) do + refusal("toggle", { f.home, bad, false }, "invalid object toggle") + end + refusal("toggle", { "UNKNOWN", "NPC", false }, "unknown map: UNKNOWN") + refusal("toggle", { f.home, "NPC", "yes" }, "invalid object toggle") + for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0, "7", false, f.gen == 3 and 1024 or 256 }) do + refusal("replace", { 0, 0, bad }, "invalid block id") + end + for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0, "0", false }) do + refusal("replace", { bad, 0, 7 }, "invalid block coordinates") + refusal("replace", { 0, bad, 7 }, "invalid block coordinates") + end + refusal("replace", { 2, 0, 7 }, "block coordinates out of bounds") + refusal("replace", { 0, 3, 7 }, "block coordinates out of bounds") + if f.gen ~= 3 then + refusal("replace", { 0, 0, 9 }, "unknown block id") + end + if f.gen > 1 then + refusal("toggle", { f.dest, "NPC", false }, "map is not active: " .. f.dest) + refusal("toggle", { f.home, "UNKNOWN", false }, "no such object: UNKNOWN") + refusal("toggle", { f.home, 0, false }, "no such object: 0") + end + for _, state in ipairs({ "overlay", "moving", "script", "transition" }) do + local other = M.fixture(root, backend, version) + if state == "overlay" then + other.game.stack.states[2] = {} + other.busy = true + elseif state == "moving" then + other.player.moving = true + elseif state == "script" then + other.busy = true + other.ow.runner.isRunning = function() + return true + end + if other.field then + package.loaded["src.core.game3.scripting.space"] = + { vm = { + isRunning = function() + return true + end, + } } + end + else + other.busy, other.ow.transitioning = true, true + if other.field then + other.field.locked = true + end + end + for _, method in ipairs({ "toggle", "replace" }) do + local ok, why + if method == "toggle" then + ok, why = other.exports.toggle(other.home, "NPC", false) + else + ok, why = other.exports.replace(0, 0, 7) + end + assert(ok == nil and type(why) == "string" and #other.exports.events() == 0) + end + end + f = M.fixture(root, backend, version) + for _, visible in ipairs({ false, true, true, false }) do + assert(f.exports.toggle(f.home, "NPC", visible)) + assert(f.visible.NPC == visible and f.visible.TEMP == true) + if f.gen > 1 then + assert(f:flagValue() == not visible) + end + report.toggles[#report.toggles + 1] = { visible = f.visible.NPC, flag = f:flagValue() } + end + -- Numeric refs retain native generation meaning: Gen1 a key, Gen2 list + -- position, Gen3 local id. A portable mod queries the active map definition. + if f.gen > 1 then + assert(f.exports.toggle(f.home, f.gen == 2 and 2 or 8, false)) + assert(not f.visible.TEMP) + assert(f.exports.toggle(f.home, "TEMP", true)) + assert(f.visible.TEMP) + else + assert(f.exports.toggle(f.dest, "LATER", false)) + assert(f.game.save.objectToggles[f.dest].LATER == false) + end + for _, op in ipairs({ { 0, 0, 8 }, { 1, 2, f.gen == 3 and 1023 or 255 }, { 0, 0, 0 } }) do + assert(f.exports.replace(unpack(op))) + local value, metadata = f.exports.read(f.home, op[1], op[2]) + assert(value == op[3] and metadata.coordinateBase == 0 and metadata.tileSize == (f.gen == 3 and 16 or 32)) + report.blocks[#report.blocks + 1] = { x = op[1], y = op[2], block = value, kind = metadata.kind } + end + assert(f.rebuilds == 3 and f.player.cellX == 0 and f.player.cellY == 0) + assert(#f.loader.errors == 0) + report.events = f.exports.events() + report.busy = true + print("API3_CONTRACT " .. Json.encode(report)) + return report +end +return M diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua index 4e5ca3a7f..2ce5e14a9 100644 --- a/tests/engine/api3_compat.lua +++ b/tests/engine/api3_compat.lua @@ -135,6 +135,8 @@ do T.eq(type(battle.snapshot), "function", "early battle method callable") for _, name in ipairs({ "warpTo", + "toggleObject", + "replaceBlock", "party", "current", "activeBlockAt", @@ -146,8 +148,6 @@ do T.eq(m:supports("world", name), true, "implemented world method " .. name) end for _, name in ipairs({ - "toggleObject", - "replaceBlock", "spawnNpc", "removeNpc", "npc", @@ -218,6 +218,8 @@ for _, version in ipairs({ "platinum", "black" }) do local m = boot(3, nil, false, version) T.eq(m:supports("world", "party"), false, "no shared Gen1 contract claimed for " .. version) T.eq(m:supports("world", "warpTo"), false, "no shared warp promised for " .. version) + T.eq(m:supports("world", "toggleObject"), false, "no shared object edits promised for " .. version) + T.eq(m:supports("world", "replaceBlock"), false, "no shared block edits promised for " .. version) local value, why = m.world:party() T.eq(value, nil, "unsupported generation call refuses") T.check(why:find("unsupported:", 1, true), "unsupported generation is explicit") diff --git a/tests/engine/api3_world_edits.lua b/tests/engine/api3_world_edits.lua new file mode 100644 index 000000000..3ef071813 --- /dev/null +++ b/tests/engine/api3_world_edits.lua @@ -0,0 +1,241 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.world_edits") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +love = nil +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared object/block contracts: " .. tostring(why)) +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, key in ipairs({ "flags", "objectToggles", "g2ObjectToggles", "gen3Spawned", "gen3SessionObjects" }) do + local gen = Version.generation(version) + if + gen == 1 and (key == "objectToggles" or key == "flags") + or gen == 2 and (key == "flags" or key == "g2ObjectToggles" or key == "objectToggles") + or gen == 3 + and (key == "flags" or key == "gen3Spawned" or key == "gen3SessionObjects" or key == "objectToggles") + then + local f = fixture(version) + f.game.save[key] = "keep" + local ok, why = f.exports.toggle(f.home, "NPC", false) + T.eq(ok, nil, key .. " malformed store rejected") + T.check(type(why) == "string", "store refusal explained") + T.eq(f.game.save[key], "keep", "malformed store retained") + T.eq(#f.exports.events(), 0, "malformed store emits no event") + T.eq(f.visible.NPC, true, "malformed store leaves native object untouched") + end + end +end +for _, version in ipairs({ "red", "firered" }) do + local f = fixture(version) + local key = f.gen == 1 and "objectToggles" or "gen3Spawned" + f.game.save[key] = { [f.home] = "keep" } + T.eq(f.exports.toggle(f.home, "NPC", false), nil, "malformed nested bucket rejected") + T.eq(f.game.save[key][f.home], "keep", "nested bucket never replaced") + T.eq(#f.exports.events(), 0, "no event for malformed nested bucket") +end +for _, lock in ipairs({ + "linkSession", + "linkNet", + "gen3Locked", + "parallelRunners", + "parallelQueue", + "pendingScripts", + "_modScriptLock", +}) do + local f = fixture() + if lock == "linkSession" then + f.game.linkSession = {} + elseif lock == "linkNet" then + f.game.linkNet = { closed = false } + elseif lock == "parallelRunners" or lock == "parallelQueue" or lock == "pendingScripts" then + f.ow[lock] = { {} } + else + f.ow[lock] = true + end + T.eq(f.exports.toggle(f.home, "NPC", false), nil, lock .. " blocks objects") + T.eq(f.exports.replace(0, 0, 7), nil, lock .. " blocks blocks") + T.eq(#f.exports.events(), 0, "busy requests never emit effects") +end +for _, failure in ipairs({ + "renderer", + "coordinates", + "geometry", + "current_block", + "objects", + "object_runtime", +}) do + local f = fixture("gold") + if failure == "renderer" then + f.ow.map.renderer = nil + elseif failure == "coordinates" then + f.ow.map.def.width = 0 + elseif failure == "geometry" then + f.ow.map.blockCells, f.ow.map.blockTiles = 1, 2 + elseif failure == "current_block" then + f.ow.map.def.blocks[1] = nil + elseif failure == "objects" then + f.ow.map.def.objects = nil + else + f.ow.syncObjectVisibility = nil + end + local ok, why + if failure == "objects" or failure == "object_runtime" then + ok, why = f.exports.toggle(f.home, "NPC", false) + else + ok, why = f.exports.replace(0, 0, 7) + end + T.eq(ok, nil, failure .. " refuses safely") + T.check(type(why) == "string", "native context refusal explained") + T.eq(#f.exports.events(), 0, "invalid context emits nothing") + T.eq(f.rebuilds + f.syncs + f.reloads, 0, "invalid context performs no native work") +end +for _, operation in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + if operation == "rollback" then + f.loader:_rollback("edits") + elseif operation == "reload" then + fixture() + else + Loader.teardown() + end + local ok, why = f.exports.toggle(f.home, "NPC", false) + T.eq(ok, nil, operation .. " retires object action") + T.eq(why, "world facade is retired", "retired object diagnostic") + ok, why = f.exports.replace(0, 0, 7) + T.eq(ok, nil, operation .. " retires block action") + T.eq(why, "world facade is retired", "retired block diagnostic") + T.eq(#f.exports.events(), 0, "retired facade emits nothing") +end +do + local f = fixture() + f.exports.reenter(true) + T.check(f.exports.toggle(f.home, "NPC", false), "outer edit accepted") + T.eq(f.exports.nested()[1], nil, "event cannot reenter a partially committed edit") + T.eq(f.exports.nested()[2], "world is busy", "reentry refused explicitly") + T.eq(f.rebuilds, 0, "nested edit never redraws") + T.eq(#f.exports.events(), 1, "outer edit emits once") + f.exports.reenter(false) + T.check(f.exports.replace(0, 0, 8), "guard released after successful edit") + local rebuild = f.ow.map.renderer.rebuild + f.ow.map.renderer.rebuild = function() + error("draw probe", 0) + end + local ok, why = f.exports.replace(0, 0, 7) + T.eq(ok, nil, "native redraw failure contained") + T.check(why:find("draw probe", 1, true), "native failure explained") + f.ow.map.renderer.rebuild = rebuild + T.check(f.exports.replace(0, 0, 8), "guard released after native exception") +end +-- Packed Gen3 source reads remain observational; replacement uses instance patches. +do + local f = fixture("emerald") + f.ow.map.def.blocks = string.rep(string.char(7, 0), 6) + f.ow.map.def._blockArray = nil + f.ow.map.collisionPatch[1] = 1 + f.ow.map.shutCells = { ["0:0"] = true } + T.check(f.exports.replace(0, 0, 8), "packed metatile map writable") + T.eq(f.exports.read(f.home, 0, 0), 8, "reads see metatile patch") + T.eq(f.ow.map.def.blocks, string.rep(string.char(7, 0), 6), "generated source bytes untouched") + T.eq(f.ow.map.def._blockArray, nil, "validation does not decode/cache source") + T.eq(f.ow.map.collisionPatch[1], 0, "native default clears stale collision override") + T.eq(f.ow.map.shutCells["0:0"], nil, "native default clears stale shut cell") +end +-- Native controller methods; no actor construction or graphics are mocked. +love = require("tests.love_stub") +local OW = require("src.world.OverworldController") +local function bind(game) + for i = 1, 100 do + local name, old = debug.getupvalue(OW.syncObjectVisibility, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(OW.syncObjectVisibility, i, game) + return function() + debug.setupvalue(OW.syncObjectVisibility, i, old) + end + end + end + error("native Game upvalue missing") +end +for _, version in ipairs({ "gold", "silver", "crystal", "firered", "emerald" }) do + local f = fixture(version) + local restore = bind(f.game) + setmetatable(f.ow, { __index = OW }) + f.ow.syncObjectVisibility = OW.syncObjectVisibility + f.ow.replaceBlock = OW.replaceBlock + local function actor(def, x) + return { + id = f.home .. "_obj_" .. def.index, + def = def, + cellX = x, + cellY = 1, + px = x * 16, + py = 16, + facing = "right", + } + end + local target, sibling = actor(f.flagged, 3), actor(f.flagless, 2) + -- Distinct live actors share one persistent flag; only the named actor moves. + f.flagless.eventFlag = f.flagged.eventFlag + f.ow.npcs = { target, sibling } + f.ow.entities = { f.player, target, sibling } + f.ow.npcPool = { [target.id] = target, [sibling.id] = sibling } + T.check(f.exports.toggle(f.home, "NPC", false), version .. " actual native removal") + T.eq(#f.ow.npcs, 1, "only target removed") + T.eq(f.ow.npcs[1], sibling, "shared-flag sibling retained live") + T.eq(sibling.cellX, 2, "unrelated actor not relocated") + T.check(f.exports.toggle(f.home, "NPC", true), "actual native respawn") + T.eq(#f.ow.npcs, 2, "one actor restored") + T.eq(target.cellX, 3, "pooled position retained") + T.eq(target.facing, "right", "pooled facing retained") + f.flagless.eventFlag = nil + T.check(f.exports.toggle(f.home, "TEMP", false), "flagless object hidden for visit") + T.eq(OW.objectVisible(f.game.save, f.home, f.flagless), false, "native spawn predicate sees session hide") + f.game.save.g2ObjectToggles = nil + f.game.save.gen3SessionObjects = nil + T.eq(OW.objectVisible(f.game.save, f.home, f.flagless), true, "flagless default restored after visit reset") + T.check(f.exports.replace(1, 2, 7), "actual native block/redraw path") + T.eq(f.ow.map:blockAt(1, 2), 7, "actual Map returns patched block") + T.eq(f.ow.map.def.blocks[6], 8, "generated map record retained") + T.eq(f.ow.map.blocksDirty, nil, "native redraw consumes dirty flag") + restore() +end +for _, version in ipairs({ "firered", "emerald" }) do + local f = fixture(version) + T.check(f.exports.toggle("HOME", "NPC", false), "unprefixed Gen3 active map resolves") + T.eq(f.exports.events()[1].mapId, f.home, "event reports resolved native map ID") +end +do + local f = fixture("gold") + require("src.script.Gen2Commands") + local Commands = require("src.script.Commands") + Commands.g2ResetObjectBase() + local ctx = { game = f.game, save = f.game.save, overworld = f.ow } + Commands.g2_object(ctx, 2, true) -- Cache the native script numbering first. + f.game.data.field = { objectScriptBase = 1 } + T.check(f.exports.toggle(f.home, 1, false), "public slot resolves independently of cached script IDs") + T.eq(f.visible.NPC, false, "current native template receives visibility effect") + Commands.g2_object(ctx, 1, true) + T.eq(f.visible.NPC, false, "public adapter leaves existing script-ID cache unchanged") + Commands.g2ResetObjectBase() +end +-- Existing native/API-2 facade retains its own coercion/validation behavior. +do + local f = fixture() + local api = require("src.world.WorldAPI").new(f.game, "legacy") + T.check(api:toggleObject("UNKNOWN", "missing", "truthy"), "legacy off-map toggle unchanged") + T.eq(f.game.save.objectToggles.UNKNOWN.missing, true, "legacy boolean coercion unchanged") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 object and block edits") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 52ef00c31..b20738ec1 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From f18425648ef6bbc17b67b7ed46ad514f381a53a1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:36:15 +0200 Subject: [PATCH 018/138] Add API 3 public NPC actions with cross-engine contracts --- docs/api3-npc.md | 142 +++++ docs/api3-portability.md | 13 +- docs/api3-warp.md | 2 +- docs/api3-world-edits.md | 2 +- docs/modding.md | 3 +- src/mods/Api3.lua | 11 +- src/mods/Api3NPC.lua | 565 ++++++++++++++++++++ src/mods/Api3WorldEdits.lua | 25 +- src/mods/Loader.lua | 4 + tests/api3_contract/mods/npcs/main.lua | 28 + tests/api3_contract/mods/npcs/manifest.json | 1 + tests/api3_contract/npcs.lua | 320 +++++++++++ tests/api3_contract/worker.lua | 5 +- tests/engine/api3_compat.lua | 9 +- tests/engine/api3_npc.lua | 423 +++++++++++++++ tools/api3-contract-check/check.py | 2 +- 16 files changed, 1533 insertions(+), 22 deletions(-) create mode 100644 docs/api3-npc.md create mode 100644 src/mods/Api3NPC.lua create mode 100644 tests/api3_contract/mods/npcs/main.lua create mode 100644 tests/api3_contract/mods/npcs/manifest.json create mode 100644 tests/api3_contract/npcs.lua create mode 100644 tests/engine/api3_npc.lua diff --git a/docs/api3-npc.md b/docs/api3-npc.md new file mode 100644 index 000000000..3a38e387b --- /dev/null +++ b/docs/api3-npc.md @@ -0,0 +1,142 @@ +# API-3 public NPC actions + +Generations 1–3 support the same public creation, lookup and removal calls as +Gen1Recomp. Check availability and retain the world facade at mod entry: + +```lua +local mod = ... +local world = mod.world + +local function greet(mapId, spriteId) + if not mod:supports("world", "spawnNpc") then return end + local id, why = world:spawnNpc(mapId, { + name = "GREETER", sprite = spriteId, x = 3, y = 4, facing = "down", + }) + if not id then return nil, why end + local npc, reason = world:npc(mapId, id) + if not npc then return nil, reason end + return npc:scriptMove("down", 2, function() + npc:face("left") + end) +end +``` + +Invoke this later from an idle world. Reading the facade never bootstraps Game. +Gen4/5 report these three calls unavailable. API-1/2 retain their native behavior; +this does not add portable `queueScript` verbs. + +## Creation, lookup and ownership + +- `spawnNpc(mapId, definition)` returns the native runtime ID or `nil, reason`. + It can add a runtime definition to a known inactive map; an actor appears when + the native map lifecycle instantiates it. The return value promises a recorded + definition, not immediate live lookup or a common lifecycle event order. +- `npc(mapId, reference)` returns an opaque handle or `nil, reason`. References + are the live actor's string ID, its definition's numeric index or its name. + Only the active map and currently live neighboring map actors can be queried. +- `removeNpc(id)` returns `true` or `nil, reason`. Only runtime objects owned by + the calling mod may be removed. Imported objects and other owners are refused. + A mod may still look up and control an imported live actor. + +Map, sprite and object identifiers remain dataset-specific. Gen3 recognizes the +current version's `FR_`, `EM_`, `RU_` or `SA_` map prefix when an exact map ID is +absent. IDs/names are nonempty strings of at most 256 bytes without control +characters; numeric references are finite nonnegative integers. Use the ID +returned by `spawnNpc` for removal. + +Definitions are copied before native construction. Required `x` and `y` are +whole nonnegative walk-cell coordinates, at most 1,000,000. Optional `facing` is +`up`, `down`, `left` or `right`; optional `name` and `sprite` are identifiers. +A supplied sprite must exist in the active sprite registry. Native generation +fields remain supported as data; this API does not invent a canonical sprite, +movement or NPC definition schema. The host assigns runtime ownership and +identity, ignoring caller-supplied ownership/index markers. + +The copy accepts plain acyclic tables, strings, finite numbers and booleans. +Functions, userdata, metatables and cycles are refused before native writes. +Limits are depth 16, 4096 visited values/keys, 256 entries per table, 4096 bytes +per string and 64 KiB total string bytes. Refusal returns `nil, reason`. + +## Handles + +Handles expose `id` and these methods; they never expose the native actor: + +| Method | Result and behavior | +| --- | --- | +| `position()` | `x, y` in zero-based 16-pixel walk cells, in the actor's own map frame. | +| `isMoving()` | Boolean native movement state. | +| `canStep(direction)` | Boolean native collision permission, including the host's current seam cast for connected maps. It is a query, not a reservation. | +| `face(direction)` | Set facing. | +| `stepNow(direction)` | Begin one authored step; refuse an already moving actor. | +| `placeAt(x, y, facing?)` | Place at whole nonnegative coordinates up to 1,000,000, clear in-flight movement and synchronize pixel position. | +| `setPassable(boolean)` | Set the live actor's passability. | +| `setAppearance(spriteId)` | Refresh a live actor using an existing sprite registry entry. | +| `scriptMove(direction, tiles?, onDone?)` | Queue native movement, default one cell; `tiles` must be an integer from 1 to 1024. | +| `marchInPlace(onDone?)` | Queue one native stationary march. | + +Directions are the four lowercase names above. Mutations return `true` or +`nil, reason`; a stale handle returns `nil, "NPC handle is stale"`. Handles are +bound to the original actor, map instance, Game, save, data and version. Reused +native IDs or editing the public `id` field cannot redirect a retained handle. +The facade follows the current Game, while old actor handles do not. + +Spawn, removal and handle mutations use the shared API-3 idle-world guard: +a save and topmost overworld are required; menus, native/portable/parallel +scripts, player movement, controller locks, transitions and link sessions refuse +admission. Reads remain usable during overlays. Synchronous reentry into NPC or +object/block mutations is refused. Movement tracks require the active map; +other handle calls also work for live neighboring actors. + +`stepNow` and `placeAt` are authored operations, matching the native distinction +from `canStep`: they do not automatically perform collision validation or clamp +to map bounds. Choose coordinates and query collision as appropriate for the +scene. Appearance changes construct a renderer before committing the actor +change, retain its cell position and leave the native template sprite intact; +they are not persistent content edits. + +## Movement and mod lifetime + +Completion callbacks are optional, one-shot and attributed to the initiating +mod. Native synchronous completion is deferred until admission finishes and +the shared mutation guard is released. Native callback arguments, including +nil holes, are preserved. Callback errors are contained and reported once. + +Pending completions are permanently retired on observed Game/save/data/world, +map, stack, version, actor or runtime-bus replacement, and on native map lifecycle +events. The input-step boundary observes replacements before and after native +updates. Retiring removes only the movement entries owned by that operation; +it does not pop screens or cancel another mod's tracks. + +Rollback, reload and teardown retire the mod's facade and pending tracks and +remove its recorded runtime definitions/actors, including definitions spawned +on inactive maps and instantiated later as neighbors. Foreign runtime objects +and imported actors remain. Controls already applied to imported actors are +ordinary native scene changes, not a transactional patch with automatic undo. +Retained handles/facades return `nil, "world facade is retired"` after retirement. + +## Verification + +```sh +luajit tests/engine/api3_npc.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The clean Gen1 reference stays pinned to +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same public-only NPC mod runs +through both production loaders for Red, Blue, Yellow, Gold, Silver, Crystal, +FireRed and Emerald. Its eight cases extend the full checker to 48 comparisons +and 96 isolated runs. The shared fixture compares creation/lookup, detached +definitions, collision/passability, appearance, authored/queued movement, +callback replay/arguments, optional callbacks and ownership-aware removal. + +Local tests additionally cover bounded validation, busy worlds, reentry, stale +sessions/handles, callback errors, lifecycle cancellation and scoped cleanup. +They exercise the host's actual NPC constructor, native runtime add/remove, +scriptMove/marchInPlace/updateScriptMoves, NPC updates and seam collision casts. +Synthetic datasets and a deterministic SpriteRenderer construction seam keep +these ROM-free. The tests verify the documented shared subset, not retail-ROM +playthroughs, GPU artwork or identical native internals/event timing. Host +validation and lifecycle guards are intentionally stricter than some reference +native paths. diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 0c7b584ed..5065c4c1a 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -79,12 +79,13 @@ The common world calls currently enabled for generations 1–3 are: - `queueScript` for the existing bounded portable script language; - [`warpTo`](api3-warp.md) for validated native map transitions; - [`toggleObject` and `replaceBlock`](api3-world-edits.md) for validated native - visibility and active block/metatile edits. + visibility and active block/metatile edits; +- [`spawnNpc`, `removeNpc` and `npc`](api3-npc.md) for runtime NPC creation, + ownership-aware removal and opaque live actor controls. Check `mod:supports("world", name)` before relying on a call. The other Gen1 world method names remain callable but explicitly return -`nil, "unsupported: ..."`: runtime NPC operations, -`startWildBattle` and `invalidateMap` have not yet been verified against the +`nil, "unsupported: ..."`: `startWildBattle` and `invalidateMap` have not yet been verified against the Gen1 API-3 contract. Their native/API-2 counterparts are unaffected. Native symbolic flag mappings and `mod.datasets:open` are also explicitly unavailable. Gen4/5 do not claim the shared world surface in this first entry PR. @@ -131,8 +132,8 @@ The runner also generates an API-3 mod through the real scaffold CLI and loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp cases use the same public mod on both engines and native-shaped timing seams. -Eight object/block cases extend this to 40 comparisons and 80 isolated loader -runs. It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 48 comparisons +and 96 isolated loader runs. It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge @@ -144,6 +145,8 @@ arguments, destination events, boundary coordinates, arrival options, callback argument holes/replay, stale sessions/owners, synchronous completion and error attribution. Object/block cases compare native reference routing, visibility/flag changes, block geometry/bounds, native reads and one event per successful edit. +NPC cases compare runtime creation/lookup, movement/appearance, optional +completion callbacks and ownership-aware removal using the same public handles. Separate Gen2 tests check API-1/2 behavior, contradictory manifest fields, honest availability and malformed flag stores. CI pins the same external commit and uploads the report. diff --git a/docs/api3-warp.md b/docs/api3-warp.md index dbb19a6a8..6e4c1f055 100644 --- a/docs/api3-warp.md +++ b/docs/api3-warp.md @@ -119,7 +119,7 @@ and replaces only native transition timing seams. It compares validation reasons, dispatch arguments, public destination events and callback results. The mod has no engine imports or backend-specific branches. Together with the existing normal, rollback, generated-starter and object/block cases, CI now -performs 40 comparisons / 80 isolated loader runs. +performs 48 comparisons / 96 isolated loader runs. The local suite additionally drives Gen2Recomped's real native `startWarpTo` and `Transition` using synthetic map materialization, including diff --git a/docs/api3-world-edits.md b/docs/api3-world-edits.md index 4297c602f..1e66a6fa1 100644 --- a/docs/api3-world-edits.md +++ b/docs/api3-world-edits.md @@ -112,7 +112,7 @@ runs under both production loaders for Red, Blue, Yellow, Gold, Silver, Crystal, FireRed and Emerald. Engine-specific setup stays in the shared driver, never in the mod. Native-shaped map/render/object seams let the driver compare refusal reasons, visibility/flag effects, native block reads, geometry -and event payloads. The full checker now runs 40 comparisons / 80 isolated +and event payloads. The full checker now runs 48 comparisons / 96 isolated processes, including all previous entry, rollback, generated-starter and warp cases. diff --git a/docs/modding.md b/docs/modding.md index 153ac03e7..d6169c421 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -24,7 +24,8 @@ The [API-3 entry and cross-engine contracts](api3-portability.md) describe using the same manifest and mod source on Gen1Recomp and Gen2Recomped. API 3 selects the unified adapters automatically; API 1/2 keep their defaults. See [API-3 world warps](api3-warp.md) for validated map transitions and callback lifetime, -and [API-3 object/block edits](api3-world-edits.md) for native visibility and terrain changes. +[API-3 object/block edits](api3-world-edits.md) for native visibility and terrain changes, +and [API-3 NPC actions](api3-npc.md) for runtime actors and movement handles. ## Feature queries (`mod:supports`) diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index 29d7f0139..8e678aca5 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -4,6 +4,7 @@ local Records = require("src.mods.Api3Records") local Runtime = require("src.mods.Runtime") local Warp = require("src.mods.Api3Warp") local Edits = require("src.mods.Api3WorldEdits") +local NPC = require("src.mods.Api3NPC") local copy = require("src.mods.Merge").deepCopy local A = {} local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty @@ -14,7 +15,9 @@ local SUPPORTED = {} for name in ([[overworld current activeBlockAt party canReorderParty reorderParty availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters - setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock]]):gmatch("%S+") + setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock spawnNpc removeNpc npc]]):gmatch( + "%S+" + ) do SUPPORTED[name] = true end @@ -80,14 +83,18 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) end return nativeWorld end - local warp, licensed + local warp, licensed, npc if supportedGeneration then warp, licensed = Warp.new(loader, api.id, gen, current, native) + npc = NPC.new(loader, api.id, gen, current, native, licensed, engineRequire) end local function dispatch(name, ...) if not supportedGeneration or not SUPPORTED[name] then return nil, "unsupported: API 3 world method " .. name end + if name == "spawnNpc" or name == "removeNpc" or name == "npc" then + return npc(name, ...) + end if name == "warpTo" then return warp(...) end diff --git a/src/mods/Api3NPC.lua b/src/mods/Api3NPC.lua new file mode 100644 index 000000000..8a4091f2a --- /dev/null +++ b/src/mods/Api3NPC.lua @@ -0,0 +1,565 @@ +-- Public API-3 NPC handles. Engine objects never cross the handle boundary. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Edits = require("src.mods.Api3WorldEdits") +local Collision = require("src.world.Collision") +local copy = require("src.mods.Merge").deepCopy +local N = {} +local services = setmetatable({}, { __mode = "k" }) +local observers = setmetatable({}, { __mode = "k" }) +local moves = {} +local dirs = { up = true, down = true, left = true, right = true } +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function integer(n, minimum, maximum) + return type(n) == "number" + and n == n + and n >= (minimum or 0) + and n < math.huge + and n % 1 == 0 + and (not maximum or n <= maximum) +end +local function message(value) + local ok, text = pcall(tostring, value) + return ok and text or "unprintable error" +end +local function identifier(s) + return type(s) == "string" and #s > 0 and #s <= 256 and not s:find("%c") +end +local function reference(v) + return identifier(v) or integer(v) +end +-- Bounded data-only copy, including extra generation-native definition fields. +local function definition(value) + local visiting, nodes, bytes = {}, 0, 0 + local function clone(v, depth) + nodes = nodes + 1 + if nodes > 4096 or depth > 16 then + error("NPC definition too large", 0) + end + local t = type(v) + if t == "string" then + bytes = bytes + #v + if #v > 4096 or bytes > 65536 then + error("NPC definition too large", 0) + end + return v + elseif t == "number" then + if v ~= v or v == math.huge or v == -math.huge then + error("invalid NPC definition", 0) + end + return v + elseif t == "nil" or t == "boolean" then + return v + elseif t ~= "table" or getmetatable(v) ~= nil or visiting[v] then + error("invalid NPC definition", 0) + end + visiting[v] = true + local out, count = {}, 0 + for k, item in pairs(v) do + count = count + 1 + if count > 256 or type(k) ~= "string" and not integer(k) then + error("invalid NPC definition", 0) + end + out[clone(k, depth + 1)] = clone(item, depth + 1) + end + visiting[v] = nil + return out + end + if type(value) ~= "table" then + return nil, "invalid NPC definition" + end + local ok, result = pcall(clone, value, 0) + if not ok then + return nil, result + end + if not integer(result.x, 0, 1000000) or not integer(result.y, 0, 1000000) then + return nil, "invalid NPC coordinates" + end + for _, key in ipairs({ "name", "sprite" }) do + if result[key] ~= nil and not identifier(result[key]) then + return nil, "invalid NPC " .. key + end + end + if result.facing ~= nil and not dirs[result.facing] then + return nil, "invalid NPC facing" + end + return result +end +local function removeFrom(list, predicate) + for i = #(list or {}), 1, -1 do + if predicate(list[i]) then + table.remove(list, i) + end + end +end +local function retire(move) + move.done = true + moves[move] = nil + move.callback = nil + local removed = false + removeFrom(move.world.scriptMoves, function(entry) + if move.entries[entry] then + removed = true + return true + end + end) + if removed then + local foreign = false + for _, entry in ipairs(move.world.scriptMoves or {}) do + if entry.entity == move.actor then + foreign = true + end + end + if not foreign then + move.actor.moving, move.actor.marching = false, false + move.actor.targetX, move.actor.targetY = nil, nil + move.actor.px, move.actor.py = move.actor.cellX * 16, move.actor.cellY * 16 + end + end +end +local function removeSpawn(token) + -- Scoped by exact native record identity, even after Game/provider changes. + local rows = token.data.maps and token.data.maps[token.mapId] + local removed = false + removeFrom(rows and rows.objects, function(row) + if row == token.record then + removed = true + return true + end + end) + local function owned(actor) + return actor + and ( + actor == token.actor + or actor.def == token.record + or removed and actor.id == token.mapId .. "_obj_" .. tostring(token.record.index) + ) + end + removeFrom(token.world.npcs, owned) + removeFrom(token.world.entities, owned) + removeFrom(token.world.ghosts, function(g) + return owned(g.npc) + end) + for id, actor in pairs(token.world.npcPool or {}) do + if owned(actor) then + token.world.npcPool[id] = nil + end + end +end +function N.cancelAll(loader, owner) + for service in pairs(services) do + if (not loader or loader == service.loader) and (not owner or owner == service.owner) then + service.closed = true + for move in pairs(moves) do + if move.service == service then + retire(move) + end + end + for _, token in ipairs(service.spawned) do + removeSpawn(token) + end + service.spawned = {} + services[service] = nil + end + end +end +local function inspect(loader) + for move in pairs(moves) do + if move.service.loader == loader then + local ok, valid = pcall(move.valid) + if not ok or not valid then + retire(move) + end + end + end +end +local function observe(loader) + if observers[loader] and observers[loader][1] == loader.hooks then + return + end + observers[loader] = setmetatable({ loader.hooks }, { __mode = "v" }) + loader.hooks:wrap("input.step", function(nextFn, ...) + if next(moves) == nil then + return nextFn(...) + end + inspect(loader) + local result = pack(pcall(nextFn, ...)) + inspect(loader) + if not result[1] then + error(result[2], 0) + end + return unpack(result, 2, result.n) + end, math.huge, "engine:api3_npc") + for _, event in ipairs({ "map.exited", "map.entered" }) do + loader.events:on(event, function() + for move in pairs(moves) do + if move.service.loader == loader then + retire(move) + end + end + end, math.huge, "engine:api3_npc") + end +end +function N.new(loader, owner, gen, current, native, licensed, req) + local service = { loader = loader, owner = owner, spawned = {} } + services[service] = true + observe(loader) + local function alive() + return not service.closed and licensed() + end + local function resolveGame() + if not alive() then + return nil, nil, "world facade is retired" + end + local game = current() + if not game then + return nil, nil, "no game" + end + local ow = native(game):overworld() + if not ow then + return nil, nil, "no overworld" + end + return game, ow + end + local function mapId(game, id) + if gen == 3 and not (game.data.maps or {})[id] then + local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_" + if (game.data.maps or {})[prefix .. id] then + return prefix .. id + end + end + return id + end + local function actorAt(ow, id, ref) + local function matches(actor) + local def = actor.def or {} + return actor.id == ref or def.index == ref or def.name == ref + end + if ow.map and ow.map.id == id then + for _, actor in ipairs(ow.npcs or {}) do + if matches(actor) then + return actor, ow.map, ow.cast or ow.entities + end + end + return nil, "no such object: " .. tostring(ref) + end + for _, g in ipairs(ow.ghosts or {}) do + local actor = g.npc + local map = g.map + if map and map.id == id and actor and matches(actor) then + local peers = g.nb and g.nb.cast or g.peers + if not peers then + peers = {} + for _, sibling in ipairs(ow.ghosts) do + if sibling.map == map then + peers[#peers + 1] = sibling.npc + end + end + end + return actor, map, peers, true + end + end + return nil, "map is not live" + end + local function sprite(game, id) + return game.data and game.data.sprites and game.data.sprites[id] + end + local function queue(game, ow, actor, valid, name, a, b, callback) + local hooks, events = Runtime.hooks, Runtime.events + local move = { service = service, world = ow, actor = actor, entries = {}, callback = callback } + local save, data, stack, map, version, fastWorld = + game.save, game.data, game.stack, ow.map, Version.get(), game.overworld + function move.valid() + return alive() + and current() == game + and game.save == save + and game.data == data + and game.stack == stack + and game.overworld == fastWorld + and ow.map == map + and Version.get() == version + and Runtime.hooks == hooks + and Runtime.events == events + and valid() + end + local accepted, deferred + local function finish(...) + if move.done then + return + end + if accepted == nil then + if not deferred then + deferred = pack(...) + end + return + end + local checked, valid = pcall(move.valid) + local fn = accepted and checked and valid and move.callback + retire(move) + if not fn then + return + end + local previous = Runtime.currentMod + Runtime.currentMod = owner + local ok, why = pcall(fn, ...) + Runtime.currentMod = previous + if not ok then + Runtime.reportError(owner, "NPC movement callback failed: " .. message(why)) + end + end + local before = {} + for _, entry in ipairs(ow.scriptMoves or {}) do + before[entry] = true + end + moves[move] = true + local called, result, detail + if name == "scriptMove" then + called, result, detail = pcall(ow.scriptMove, ow, actor, a, b or 1, finish) + else + called, result, detail = pcall(ow.marchInPlace, ow, actor, finish) + end + for _, entry in ipairs(ow.scriptMoves or {}) do + if not before[entry] and entry.entity == actor then + move.entries[entry] = true + end + end + accepted = called and result ~= false and not (result == nil and detail ~= nil) + if accepted then + local checked, valid = pcall(move.valid) + accepted = checked and valid and not move.done + end + if not accepted then + retire(move) + return nil, + called and (detail or "NPC movement failed") or "world operation failed: " .. message(result) + end + local drain = deferred and function() + finish(unpack(deferred, 1, deferred.n)) + end + return true, nil, drain + end + local function handle(game, ow, id, ref) + local actor, map = actorAt(ow, id, ref) + if not actor then + return nil, map + end + local publicId = actor.id + if not identifier(publicId) then + return nil, "NPC identity unavailable" + end + local save, data, version, fastWorld = game.save, game.data, Version.get(), game.overworld + local function live() + if + not alive() + or current() ~= game + or game.save ~= save + or game.data ~= data + or game.overworld ~= fastWorld + or Version.get() ~= version + or native(game):overworld() ~= ow + then + return + end + local found, activeMap, peers, ghost = actorAt(ow, id, publicId) + if found ~= actor or activeMap ~= map then + return + end + return actor, activeMap, peers, ghost + end + local out = { id = publicId } + local function invoke(name, a, b, c) + if not alive() then + return nil, "world facade is retired" + end + local npc, onMap, peers, ghost = live() + if not npc then + return nil, "NPC handle is stale" + end + if name == "position" then + return npc.cellX, npc.cellY + end + if name == "isMoving" then + return not not npc.moving + end + if name == "canStep" or name == "stepNow" or name == "face" or name == "scriptMove" then + if not dirs[a] then + return nil, "invalid direction" + end + end + if name == "canStep" then + return not not Collision.canMove(onMap, peers or {}, npc, a) + end + if + name == "scriptMove" and (b ~= nil and not integer(b, 1, 1024) or c ~= nil and type(c) ~= "function") + then + return nil, "invalid movement" + end + if name == "marchInPlace" and a ~= nil and type(a) ~= "function" then + return nil, "invalid callback" + end + if + name == "placeAt" + and (not integer(a, 0, 1000000) or not integer(b, 0, 1000000) or c ~= nil and not dirs[c]) + then + return nil, "invalid coordinates" + end + if name == "setPassable" and type(a) ~= "boolean" then + return nil, "invalid passability" + end + if name == "setAppearance" and not identifier(a) then + return nil, "invalid sprite id" + end + local ok, why = Edits.ready(game, ow) + if not ok then + return nil, why + end + if ghost and (name == "scriptMove" or name == "marchInPlace") then + return nil, "movement tracks require the active map" + end + local drain + local value, detail = Edits.run(game, function() + if name == "scriptMove" or name == "marchInPlace" then + local value, why + local callback = a + if name == "scriptMove" then + callback = c + end + value, why, drain = queue(game, ow, npc, live, name, a, b, callback) + return value, why + elseif name == "face" then + npc.facing = a + elseif name == "stepNow" then + if npc.moving then + return nil, "already moving" + end + npc.facing = a + npc.targetX, npc.targetY = Collision.target(npc.cellX, npc.cellY, a) + npc.moving, npc.progress = true, 0 + elseif name == "placeAt" then + npc.moving, npc.marching, npc.progress = false, false, 0 + npc.targetX, npc.targetY = nil, nil + npc.cellX, npc.cellY, npc.px, npc.py = a, b, a * 16, b * 16 + if c then + npc.facing = c + end + elseif name == "setPassable" then + npc.passable = a + elseif name == "setAppearance" then + if not sprite(game, a) then + return nil, "unknown sprite: " .. a + end + local proxy = { id = npc.id, def = copy(npc.def) } + proxy.def.sprite = a + proxy.def.graphicsId, proxy.def.graphics, proxy.def.graphicsVar, proxy.def.varSprite = + nil, nil, nil, nil + req("src.world.NPC").refreshSprite(proxy, game.data) + npc.def, npc.sprite, npc.appearance = proxy.def, proxy.sprite, a + npc.big = proxy.sprite and proxy.sprite.big or false + end + return true + end) + if drain then + drain() + end + return value, detail + end + for _, name in ipairs({ + "position", + "isMoving", + "canStep", + "scriptMove", + "marchInPlace", + "face", + "stepNow", + "placeAt", + "setPassable", + "setAppearance", + }) do + local method = name + out[method] = function(_, ...) + local previous = Runtime.currentMod + Runtime.currentMod = nil + local result = pack(pcall(invoke, method, ...)) + Runtime.currentMod = previous + if not result[1] then + return nil, "world operation failed: " .. message(result[2]) + end + return unpack(result, 2, result.n) + end + end + return out + end + return function(name, a, b) + local game, ow, why = resolveGame() + if not game then + return nil, why + end + if name == "npc" then + if not identifier(a) or not reference(b) then + return nil, "invalid NPC reference" + end + return handle(game, ow, mapId(game, a), b) + end + local ok, reason = Edits.ready(game, ow) + if not ok then + return nil, reason + end + return Edits.run(game, function() + if name == "spawnNpc" then + if not identifier(a) or type(b) ~= "table" then + return nil, "invalid NPC definition" + end + local id = mapId(game, a) + local mapDef = game.data.maps and game.data.maps[id] + if not mapDef then + return nil, "unknown map: " .. a + end + local row, err = definition(b) + if not row then + return nil, err + end + if row.sprite and not sprite(game, row.sprite) then + return nil, "unknown sprite: " .. row.sprite + end + local before = {} + for _, item in ipairs(mapDef.objects or {}) do + before[item] = true + end + local called, value, detail = pcall(native(game).spawnNpc, native(game), id, row) + for _, item in ipairs(mapDef.objects or {}) do + if not before[item] and item.runtime and item.owner == owner then + local publicId = id .. "_obj_" .. tostring(item.index) + local actor = actorAt(ow, id, publicId) + local token = { data = game.data, world = ow, mapId = id, record = item, actor = actor } + if not called or not value then + removeSpawn(token) + else + service.spawned[#service.spawned + 1] = token + end + end + end + if not called then + return nil, "world operation failed: " .. message(value) + end + return value, detail + end + if not reference(a) or gen == 3 and not identifier(a) then + return nil, "invalid NPC id" + end + local value, err = native(game):removeNpc(a) + if value then + for i = #service.spawned, 1, -1 do + local token = service.spawned[i] + if token.mapId .. "_obj_" .. tostring(token.record.index) == a then + table.remove(service.spawned, i) + end + end + end + return value, err + end) + end +end +return N diff --git a/src/mods/Api3WorldEdits.lua b/src/mods/Api3WorldEdits.lua index 522adcb04..c21586e25 100644 --- a/src/mods/Api3WorldEdits.lua +++ b/src/mods/Api3WorldEdits.lua @@ -130,11 +130,8 @@ local function replace(game, ow, api, gen, req, bx, by, block) Runtime.emit("world.block_replaced", { mapId = ow.map.id, bx = bx, by = by, block = block }) return true end -function E.call(game, api, generation, req, name, ...) - local ow = api:overworld() - if not ow then - return nil, "no overworld" - end +-- Shared admission/commit guard for synchronous API-3 world mutations. +function E.ready(game, ow) local ok, reason = Actions.ready(game, ow) if not ok then return nil, reason @@ -142,13 +139,27 @@ function E.call(game, api, generation, req, name, ...) if running[game] or ow.gen3Locked or #(ow.parallelRunners or {}) > 0 or #(ow.parallelQueue or {}) > 0 then return nil, "world is busy" end + return true +end +function E.run(game, operation, ...) running[game] = true - local operation = name == "toggleObject" and toggle or replace - local called, value, why = pcall(operation, game, ow, api, generation, req, ...) + local called, value, why = pcall(operation, ...) running[game] = nil if not called then error(value, 0) end return value, why end +function E.call(game, api, generation, req, name, ...) + local ow = api:overworld() + if not ow then + return nil, "no overworld" + end + local ok, reason = E.ready(game, ow) + if not ok then + return nil, reason + end + local operation = name == "toggleObject" and toggle or replace + return E.run(game, operation, game, ow, api, generation, req, ...) +end return E diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 17ecd49e0..c24867e06 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -13,6 +13,7 @@ local Assets = require("src.render.Assets") local ModUI = require("src.ui.ModUI") local PortableScripts = require("src.mods.PortableScripts") local Api3Warp = require("src.mods.Api3Warp") +local Api3NPC = require("src.mods.Api3NPC") local AssetTransform = require("src.mods.AssetTransform") local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") @@ -1263,6 +1264,7 @@ end -- migration it took goes with them. The journal only exists around an -- entry chunk; a later failure (script validation) purges every registry. function Loader:_rollback(modId) + Api3NPC.cancelAll(self, modId) Api3Warp.cancelAll(self, modId) PortableScripts.cancelAll(self, modId, "mod rolled back") for name in pairs(self.journal or self.content) do @@ -1379,6 +1381,7 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + Api3NPC.cancelAll() Api3Warp.cancelAll() PortableScripts.cancelAll(nil, nil, "mods reloaded") -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST @@ -1724,6 +1727,7 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + Api3NPC.cancelAll() Api3Warp.cancelAll() PortableScripts.cancelAll(nil, nil, "mods unloaded") local undone = select(1, ModSeams.unwindAll()) diff --git a/tests/api3_contract/mods/npcs/main.lua b/tests/api3_contract/mods/npcs/main.lua new file mode 100644 index 000000000..696dfaafe --- /dev/null +++ b/tests/api3_contract/mods/npcs/main.lua @@ -0,0 +1,28 @@ +-- Same public-only mod on both engines. Native timing stays in the driver. +local mod = ... +local world = mod.world +for _, name in ipairs({ "spawnNpc", "removeNpc", "npc" }) do + assert(mod:supports("world", name)) +end +local ok, why = world:npc("HOME", 1) +assert(ok == nil and why == "no game") +local calls, args = 0, nil +mod.exports.spawn = function(...) + return world:spawnNpc(...) +end +mod.exports.remove = function(...) + return world:removeNpc(...) +end +mod.exports.npc = function(...) + return world:npc(...) +end +mod.exports.toggle = function(...) + return world:toggleObject(...) +end +mod.exports.done = function(...) + calls = calls + 1 + args = { n = select("#", ...), first = (...), third = select(3, ...) } +end +mod.exports.result = function() + return { calls = calls, args = args } +end diff --git a/tests/api3_contract/mods/npcs/manifest.json b/tests/api3_contract/mods/npcs/manifest.json new file mode 100644 index 000000000..16a8a3457 --- /dev/null +++ b/tests/api3_contract/mods/npcs/manifest.json @@ -0,0 +1 @@ +{"id":"npcs","name":"Portable NPC Contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/npcs.lua b/tests/api3_contract/npcs.lua new file mode 100644 index 000000000..7ee2a243f --- /dev/null +++ b/tests/api3_contract/npcs.lua @@ -0,0 +1,320 @@ +-- Real loaders/native public adapters with native actor/map/timing seams. +local M = {} +local unpack = table.unpack or unpack +function M.fixture(root, backend, version) + local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/npcs%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "npcs") + local Runtime = require("src.mods.Runtime") + love = love or require("tests.love_stub") + -- Render construction has no bearing on the public state contract. + local Renderer = require("src.render.SpriteRenderer") + local savedRenderer = Renderer.new + Renderer.new = function(def) + return { def = def, big = false } + end + f.restoreRenderer = function() + Renderer.new = savedRenderer + end + local game, ow = f.game, f.ow + game.data.sprites = { + PROBE = { image = "fixture.png", frames = 6, walker = true }, + ALT = { image = "alt.png", frames = 6, walker = true }, + } + game.data.gen2Sprites = game.data.sprites + ow.game, ow.sprites = game, game.data.sprites + local tiles = { + blocks = { [2] = {} }, + walkable = { 0 }, + collision = { [2] = { 0, 0, 0, 0 } }, + blockCells = f.gen == 3 and 1 or 2, + blockTiles = f.gen == 3 and 2 or 4, + } + for i = 1, 16 do + tiles.blocks[2][i] = 0 + end + if not (backend == "gen1" and f.gen == 2) then + tiles.collision = nil + end + for _, id in ipairs({ f.home, f.dest }) do + local def = game.data.maps[id] + def.width, def.height, def.objects, def.blocks = 8, 8, {}, {} + for i = 1, 64 do + def.blocks[i] = 1 + end + end + local base = { + index = 1, + localId = 1, + name = "IMPORTED", + sprite = "PROBE", + x = 6, + y = 6, + movement = "STAY", + range = "DOWN", + } + game.data.maps[f.home].objects = { base } + if backend == "gen1" and f.gen == 2 then + ow.map = require("src.world.gen2.Map").new(game.data.maps[f.home], tiles) + elseif not (backend == "gen1" and f.gen == 3) then + ow.map = require("src.world.Map").new(game.data.maps[f.home], tiles) + end + ow.npcs, ow.entities, ow.npcPool = {}, { f.player }, {} + f.player.cellX, f.player.cellY = 7, 7 + function f:make(id, row) + local actor + if backend == "gen1" and self.gen == 2 then + actor = require("src.world.gen2.Npc").new(id, row, game.data.sprites[row.sprite]) + elseif backend == "gen1" and self.gen == 3 then + actor = require("src.core.game3.object_prepare").instance(row, { mapId = id }) + actor.id, actor.publicId = id .. "_obj_" .. row.index, id .. "_obj_" .. row.index + else + actor = require("src.world.NPC").new(game.data, id, row) + end + function actor:queueApiMoves(_, callback) + f:begin(self, "march", nil, 1, callback) + return true + end + return actor + end + f.imported = f:make(f.home, base) + ow.npcs[1] = f.imported + ow.entities[2] = f.imported + ow.npcPool[f.imported.id] = f.imported + function f:begin(actor, kind, dir, count, callback) + self.pending = { actor = actor, kind = kind, dir = dir, count = count, callback = callback } + self.busy = true + actor.moving = true + if backend == "gen2" or self.gen == 1 then + local entry = + { entity = actor, dir = dir, remaining = count, onDone = callback, inPlace = kind == "march" } + ow.scriptMoves[#ow.scriptMoves + 1] = entry + end + if self.synchronous then + self:complete("sync", nil, "tail") + end + if self.failMovement then + return false, "movement refused" + end + return true + end + function ow:scriptMove(actor, dir, count, callback) + return f:begin(actor, "move", dir, count, callback) + end + function ow:marchInPlace(actor, callback) + return f:begin(actor, "march", nil, 1, callback) + end + function ow:beginMovement(objectId, bytes, callback) + local actor + for _, candidate in ipairs(self.npcs) do + if candidate.def.index + 1 == objectId then + actor = candidate + end + end + local dir = ({ [0x0C] = "down", [0x0D] = "up", [0x0E] = "left", [0x0F] = "right" })[bytes[1]] + return f:begin(assert(actor), "move", assert(dir), #bytes - 1, callback) + end + function ow:applySpritePalette(actor) + f.paletteActor = actor + end + function f:complete(...) + local pending = self.pending + assert(pending, "no pending move") + local actor = pending.actor + if pending.kind == "move" then + local delta = ({ up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } })[pending.dir] + actor.cellX, actor.cellY = + actor.cellX + delta[1] * pending.count, actor.cellY + delta[2] * pending.count + end + actor.moving, actor.marching = false, false + actor.px, actor.py = actor.cellX * 16, actor.cellY * 16 + self.busy = false + ow.scriptMoves = {} + if pending.callback then + pending.callback(...) + end + end + function f:spawn(id, row, owner) + local def = assert(game.data.maps[id]) + local index = 0 + for _, existing in ipairs(def.objects) do + index = math.max(index, existing.index) + end + row.index, row.localId, row.runtime, row.owner = index + 1, index + 1, true, owner + def.objects[#def.objects + 1] = row + local publicId = id .. "_obj_" .. row.index + if id == self.home then + local actor = self:make(id, row) + ow.npcs[#ow.npcs + 1] = actor + ow.entities[#ow.entities + 1] = actor + ow.npcPool[publicId] = actor + if self.byId then + self.byId[row.localId] = actor + self.order[#self.order + 1] = row.localId + end + end + return publicId + end + function ow:addRuntimeObject(id, row, owner) + return f:spawn(id, row, owner) + end + function f:remove(id, owner) + for _, def in pairs(game.data.maps) do + for i, row in ipairs(def.objects or {}) do + if row.runtime and def.id .. "_obj_" .. row.index == id then + if row.owner ~= owner then + return nil, "not owned by " .. tostring(owner) + end + table.remove(def.objects, i) + for _, list in ipairs({ ow.npcs, ow.entities }) do + for j = #list, 1, -1 do + if list[j].id == id then + table.remove(list, j) + end + end + end + ow.npcPool[id] = nil + if self.byId then + self.byId[row.localId] = nil + for j = #self.order, 1, -1 do + if self.order[j] == row.localId then + table.remove(self.order, j) + end + end + end + return true + end + end + end + return nil, "no runtime object " .. tostring(id) + end + function ow:removeRuntimeObject(id, owner) + return f:remove(id, owner) + end + if backend == "gen1" and f.gen == 3 then + f.byId, f.order = { [1] = f.imported }, { 1 } + package.loaded["src.core.game3.objects"] = { + _byId = f.byId, + _order = f.order, + addRuntimeObject = function(id, row, owner) + return f:spawn(id, row, owner) + end, + removeRuntimeObject = function(id, owner) + return f:remove(id, owner) + end, + scriptFace = function(actor, dir) + actor.facing = dir + end, + scriptStep = function(actor, dir) + local delta = ({ up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } })[dir] + actor.facing = dir + actor.targetX = actor.cellX + delta[1] + actor.targetY = actor.cellY + delta[2] + actor.moving = true + end, + startTrack = function(id, actions, callback) + local count = 0 + for _, action in ipairs(actions) do + if action.kind == "step" then + count = count + 1 + end + end + return f:begin(f.byId[id], count > 0 and "move" or "march", actions[1].dir, count, callback) + end, + } + package.loaded["src.core.game3.collision"] = { + canEnter = function() + return true + end, + isWater = function() + return false + end, + } + end + function f:update() + return self.loader.hooks:call("input.step", function() + return "updated", nil, "tail" + end, game, 0) + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local ex = f.exports + local report = { invalid = {}, actions = {} } + local function refused(value, why) + assert(value == nil and type(why) == "string" and #why > 0, tostring(why)) + end + refused(ex.spawn("UNKNOWN", { x = 0, y = 0 })) + refused(ex.spawn(f.home, { x = -1, y = 0 })) + refused(ex.spawn(f.home, { x = 0.5, y = 0 })) + refused(ex.npc(f.home, false)) + refused(ex.remove(false)) + assert(#f.game.data.maps[f.home].objects == 1 and #f.ow.npcs == 1) + local definition = { name = "RUNTIME", sprite = "PROBE", x = 0, y = 0, movement = "STAY", range = "DOWN" } + local id = assert(ex.spawn(f.home, definition)) + local handle = assert(ex.npc(f.home, id)) + assert(handle.id == id and ex.npc(f.home, "RUNTIME").id == id) + definition.x, definition.sprite = 99, "changed" + local actor = f.ow.npcPool[id] + assert(actor.cellX == 0 and actor.def.sprite == "PROBE") + for _, method in ipairs({ "scriptMove", "face", "stepNow", "canStep" }) do + refused(handle[method](handle, "north")) + end + refused(handle:scriptMove("right", 0)) + refused(handle:scriptMove("right", 1025)) + refused(handle:scriptMove("right", 1, false)) + refused(handle:marchInPlace(false)) + refused(handle:placeAt(-1, 0)) + refused(handle:setPassable("yes")) + refused(handle:setAppearance("")) + assert(handle:face("right")) + local allowed, blocked = handle:canStep("right") + assert(allowed == true, tostring(allowed) .. ": " .. tostring(blocked)) + assert(handle:setPassable(true) and actor.passable == true) + assert(handle:setAppearance("ALT")) + assert(handle:position() == 0 and handle:isMoving() == false) + assert(handle:stepNow("right")) + assert(handle:isMoving() == true and actor.targetX == 1) + refused(handle:stepNow("right")) + assert(handle:placeAt(2, 2, "up")) + assert(handle:isMoving() == false and actor.px == 32 and actor.py == 32) + local x, y = handle:position() + assert(x == 2 and y == 2) + assert(handle:scriptMove("right", 2, ex.done)) + assert(ex.result().calls == 0) + f:complete("done", nil, "tail") + x, y = handle:position() + assert(x == 4 and y == 2) + assert(ex.result().calls == 1 and ex.result().args.n == 3 and ex.result().args.third == "tail") + assert(handle:marchInPlace(ex.done)) + f:complete("march") + x, y = handle:position() + assert(x == 4 and y == 2 and ex.result().calls == 2) + assert(handle:scriptMove("left")) + f:complete() + assert(handle:position() == 3 and ex.result().calls == 2) + assert(handle:marchInPlace()) + f:complete() + assert(handle:placeAt(4, 2)) + local imported = assert(ex.npc(f.home, "IMPORTED")) + refused(ex.remove(imported.id)) + local other = f:spawn(f.home, { name = "OTHER", sprite = "PROBE", x = 5, y = 5 }, "other") + refused(ex.remove(other)) + assert(ex.remove(id)) + refused(ex.npc(f.home, id)) + assert(ex.npc(f.home, other) and ex.npc(f.home, imported.id)) + report.actions = { + x = x, + y = y, + passable = actor.passable, + callbacks = ex.result().calls, + definitionCopied = true, + ownedRemoval = true, + } + report.invalid = true + assert(#f.loader.errors == 0) + f.restoreRenderer() + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index 0fe96b9ab..b288ae8c1 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -12,11 +12,14 @@ assert( or mode == "scaffold" or mode == "warp" or mode == "world_edits" + or mode == "npcs" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "world_edits" then +if mode == "npcs" then + dofile(codeRoot .. "/npcs.lua").run(root, backend, version) +elseif mode == "world_edits" then dofile(codeRoot .. "/world_edits.lua").run(root, backend, version) elseif mode == "warp" then dofile(codeRoot .. "/warp.lua").run(root, backend, version) diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua index 2ce5e14a9..35e00aece 100644 --- a/tests/engine/api3_compat.lua +++ b/tests/engine/api3_compat.lua @@ -134,6 +134,9 @@ do T.eq(type(world.party), "function", "early world method callable") T.eq(type(battle.snapshot), "function", "early battle method callable") for _, name in ipairs({ + "spawnNpc", + "removeNpc", + "npc", "warpTo", "toggleObject", "replaceBlock", @@ -148,9 +151,6 @@ do T.eq(m:supports("world", name), true, "implemented world method " .. name) end for _, name in ipairs({ - "spawnNpc", - "removeNpc", - "npc", "startWildBattle", "invalidateMap", }) do @@ -217,6 +217,9 @@ end for _, version in ipairs({ "platinum", "black" }) do local m = boot(3, nil, false, version) T.eq(m:supports("world", "party"), false, "no shared Gen1 contract claimed for " .. version) + for _, name in ipairs({ "spawnNpc", "removeNpc", "npc" }) do + T.eq(m:supports("world", name), false, "no shared NPC API promised for " .. version) + end T.eq(m:supports("world", "warpTo"), false, "no shared warp promised for " .. version) T.eq(m:supports("world", "toggleObject"), false, "no shared object edits promised for " .. version) T.eq(m:supports("world", "replaceBlock"), false, "no shared block edits promised for " .. version) diff --git a/tests/engine/api3_npc.lua b/tests/engine/api3_npc.lua new file mode 100644 index 000000000..f3c4844c4 --- /dev/null +++ b/tests/engine/api3_npc.lua @@ -0,0 +1,423 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.npcs") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local Renderer = require("src.render.SpriteRenderer") +local originalRenderer = Renderer.new +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +love = nil +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +local function spawn(f) + local id = assert( + f.exports.spawn( + f.home, + { x = 0, y = 0, name = "OWN", sprite = "PROBE", movement = "STAY", range = "DOWN" } + ) + ) + return assert(f.exports.npc(f.home, id)), f.ow.npcPool[id] +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared NPC contract: " .. tostring(why)) +end +local cycle = { x = 0, y = 0 } +cycle.self = cycle +local meta = setmetatable({ x = 0, y = 0 }, {}) +local deep = { x = 0, y = 0 } +local child = deep +for _ = 1, 18 do + child.next = {} + child = child.next +end +local wide = { x = 0, y = 0 } +for i = 1, 260 do + wide[i] = i +end +for _, row in ipairs({ + cycle, + meta, + deep, + wide, + { x = 0, y = 0, handler = function() end }, + { x = math.huge, y = 0 }, + { x = 0 / 0, y = 0 }, + { x = 1000001, y = 0 }, + { x = 0, y = -1 }, + { x = 0, y = 0.5 }, + { x = 0, y = 0, name = "bad\nname" }, + { x = 0, y = 0, sprite = "missing" }, + { x = 0, y = 0, facing = "north" }, + { x = 0, y = 0, payload = string.rep("x", 4097) }, +}) do + local f = fixture() + local value, why = f.exports.spawn(f.home, row) + T.eq(value, nil, "invalid definition refused") + T.check(type(why) == "string", "definition failure explained") + T.eq(#f.game.data.maps[f.home].objects, 1, "invalid definition adds no template") + T.eq(#f.ow.npcs, 1, "invalid definition adds no actor") +end +for _, version in ipairs({ "red", "gold", "firered" }) do + for _, lock in ipairs({ "overlay", "player", "script", "link", "parallel", "controller" }) do + local f = fixture(version) + local handle, actor = spawn(f) + if lock == "overlay" then + f.game.stack.states[2] = {} + elseif lock == "player" then + f.player.moving = true + elseif lock == "script" then + f.ow.runner.isRunning = function() + return true + end + elseif lock == "link" then + f.game.linkSession = {} + elseif lock == "parallel" then + f.ow.parallelRunners = { {} } + else + f.ow._modScriptLock = {} + end + T.eq(handle:position(), 0, "busy world permits position read") + T.eq(handle:isMoving(), false, "busy world permits motion read") + T.eq(handle:face("left"), nil, lock .. " blocks mutation") + T.eq(f.exports.spawn(f.home, { x = 0, y = 0 }), nil, lock .. " blocks spawn") + T.eq(f.exports.remove(handle.id), nil, lock .. " blocks removal") + T.eq(actor.facing, "down", "refusal does not change actor") + end +end +for _, method in ipairs({ "face", "stepNow", "scriptMove", "canStep" }) do + for _, bad in ipairs({ "north", false, {}, 1 }) do + local f = fixture() + local handle = spawn(f) + local ok, why = handle[method](handle, bad) + T.eq(ok, nil, "bad direction refused for " .. method) + T.eq(why, "invalid direction", "bad direction diagnosed") + T.eq(#f.ow.scriptMoves, 0, "no queued movement on invalid direction") + end +end +for _, bad in ipairs({ 0, -1, 0.5, math.huge, 0 / 0, 1025, "1" }) do + local f = fixture() + local handle = spawn(f) + T.eq(handle:scriptMove("up", bad), nil, "invalid move count refused") + T.eq(#f.ow.scriptMoves, 0, "invalid count has no effect") +end +do + local f = fixture() + local handle, actor = spawn(f) + local native = f.ow.addRuntimeObject + function f.ow:addRuntimeObject(...) + local value, why = handle:face("left") + T.eq(value, nil, "native construction cannot reenter NPC mutation") + T.eq(why, "world is busy", "NPC reentry diagnosed") + value, why = f.exports.toggle(f.home, "IMPORTED", false) + T.eq(value, nil, "NPC construction cannot reenter object edit") + T.eq(why, "world is busy", "shared mutation guard diagnosed") + return native(self, ...) + end + T.check(f.exports.spawn(f.home, { x = 2, y = 2, sprite = "PROBE" }), "outer construction succeeds") + T.eq(actor.facing, "down", "reentry leaves actor untouched") + T.check(handle:face("right"), "construction releases guard") + local constructor = Renderer.new + Renderer.new = function() + error("renderer failed", 0) + end + local def, sprite = actor.def, actor.sprite + T.eq(handle:setAppearance("ALT"), nil, "failed renderer construction contained") + T.eq(actor.def, def, "failed renderer retains original definition") + T.eq(actor.sprite, sprite, "failed renderer retains original sprite") + Renderer.new = constructor + T.check(handle:face("up"), "renderer exception releases guard") +end +for _, change in ipairs({ "game", "save", "data", "stack", "world", "map", "actor", "version", "bus" }) do + local f = fixture() + local handle, actor = spawn(f) + T.check(handle:scriptMove("right", 2, f.exports.done), "movement starts before " .. change) + local callback = f.pending.callback + local restore + if change == "game" then + local old = f.live + f.live = {} + restore = function() + f.live = old + end + elseif change == "version" then + Version.set("blue") + restore = function() + Version.set("red") + end + elseif change == "world" then + local old = f.game.overworld + f.game.overworld = {} + restore = function() + f.game.overworld = old + end + elseif change == "bus" then + local old = Runtime.events + Runtime.events = require("src.mods.Events").new() + restore = function() + Runtime.events = old + end + elseif change == "actor" then + table.remove(f.ow.npcs, #f.ow.npcs) + restore = function() + f.ow.npcs[#f.ow.npcs + 1] = actor + end + else + local object = (change == "map") and f.ow or f.game + local old = object[change] + object[change] = {} + restore = function() + object[change] = old + end + end + f:update() + T.eq(#f.ow.scriptMoves, 0, "changed context retires only owned movement") + restore() + callback("stale") + callback("replayed") + T.eq(f.exports.result().calls, 0, "restored " .. change .. " cannot revive callback") +end +for _, event in ipairs({ "map.exited", "map.entered" }) do + local f = fixture() + local handle = spawn(f) + T.check(handle:scriptMove("right", 1, f.exports.done), "movement starts before map lifecycle") + local callback = f.pending.callback + Runtime.emit(event, { mapId = f.home }) + T.eq(#f.ow.scriptMoves, 0, "map lifecycle retires movement immediately") + callback() + T.eq(f.exports.result().calls, 0, "old map callback suppressed") +end +for _, action in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + local handle, actor = spawn(f) + local foreign = f:spawn(f.home, { name = "OTHER", sprite = "PROBE", x = 5, y = 5 }, "other") + T.check(handle:scriptMove("right", 1, f.exports.done), "queue before " .. action) + local callback = f.pending.callback + local foreignEntry = { + entity = f.imported, + remaining = 1, + onDone = function() + error("do not invoke") + end, + } + f.ow.scriptMoves[#f.ow.scriptMoves + 1] = foreignEntry + if action == "rollback" then + f.loader:_rollback("npcs") + elseif action == "reload" then + fixture() + else + Loader.teardown() + end + T.eq(#f.ow.scriptMoves, 1, "unload removes only owned queue entries") + T.eq(f.ow.scriptMoves[1], foreignEntry, "foreign movement retained") + T.eq(#f.game.data.maps[f.home].objects, 2, "unload removes only own runtime template") + T.eq(f.ow.npcPool[handle.id], nil, "own actor removed from native pool") + T.check(f.ow.npcPool[foreign], "foreign owner retained") + callback() + T.eq(f.exports.result().calls, 0, "unload suppresses native callback") + T.eq(handle:position(), nil, "unloaded handle retired") + T.eq(f.exports.npc(f.home, "IMPORTED"), nil, "unloaded facade retired") +end +do + local f = fixture() + local handle, actor = spawn(f) + T.check(f.exports.remove(handle.id), "own removal succeeds") + local replacement = assert(f.exports.spawn(f.home, { name = "NEW", sprite = "PROBE", x = 1, y = 1 })) + T.eq(replacement, handle.id, "native ID can be reused") + local value, why = handle:face("left") + T.eq(value, nil, "old handle cannot change replacement with same ID") + T.eq(why, "NPC handle is stale", "stale handle diagnosed") + T.eq(f.exports.npc(f.home, replacement):position(), 1, "replacement remains untouched") +end +do + local f = fixture() + local handle, actor = spawn(f) + f.synchronous = true + T.check( + handle:scriptMove("right", 1, function(...) + T.eq(Runtime.currentMod, "npcs", "movement callback attributed") + f.exports.done(...) + f.synchronous = false + T.check(handle:face("up"), "synchronous callback runs after commit guard released") + end), + "synchronous move accepted" + ) + T.eq(f.exports.result().calls, 1, "synchronous callback called once") + T.eq(f.exports.result().args.n, 3, "nil argument holes preserved") + f.pending.callback() + T.eq(f.exports.result().calls, 1, "duplicate completion ignored") + T.eq(Runtime.currentMod, nil, "callback restores runtime owner") +end +for _, err in ipairs({ + "authored failure", + setmetatable({}, { + __tostring = function() + error("hostile tostring") + end, + }), +}) do + local f = fixture() + local handle = spawn(f) + T.check( + handle:scriptMove("right", 1, function() + error(err, 0) + end), + "throwing callback admitted" + ) + T.check( + pcall(function() + f:complete() + f.pending.callback() + end), + "callback errors cannot escape native completion" + ) + T.eq(#f.loader.errors, 1, "callback error attributed once") + T.check( + f.loader.errors[1]:find("npcs: NPC movement callback failed:", 1, true), + "callback error names owner" + ) +end +do + local f = fixture() + local handle = spawn(f) + f.failMovement, f.synchronous = true, true + T.eq(handle:scriptMove("right", 1, f.exports.done), nil, "native refusal never counts as admission") + T.eq(f.exports.result().calls, 0, "callback deferred until native result") + T.eq(#f.ow.scriptMoves, 0, "native refusal leaves no owned queue entry") +end +do + local f = fixture() + local handle = spawn(f) + local native = f.ow.scriptMove + function f.ow:scriptMove(...) + Runtime.emit("map.exited", { mapId = f.home }) + return native(self, ...) + end + T.eq(handle:scriptMove("right", 1, f.exports.done), nil, "map change during admission refuses completion") + T.eq(#f.ow.scriptMoves, 0, "late-created owned entry cleaned after cancellation race") +end +do + local f = fixture() + local handle = spawn(f) + T.check(handle:scriptMove("right"), "optional count/callback accepted") + f:complete() + T.eq(handle:position(), 1, "default count is one") + T.eq(#f.loader.errors, 0, "omitted callback never treated as a callable") +end +-- Real native object creation/removal, NPC updates and movement queue. +local OW = require("src.world.OverworldController") +local function bind(game) + for i = 1, 100 do + local name, old = debug.getupvalue(OW.addRuntimeObject, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(OW.addRuntimeObject, i, game) + return function() + debug.setupvalue(OW.addRuntimeObject, i, old) + end + end + end + error("native Game upvalue missing") +end +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + local restore = bind(f.game) + f.ow.addRuntimeObject, f.ow.removeRuntimeObject = OW.addRuntimeObject, OW.removeRuntimeObject + f.ow.scriptMove, f.ow.marchInPlace = OW.scriptMove, OW.marchInPlace + local handle, actor = spawn(f) + actor.frozen = true + T.check(handle:scriptMove("right", 2, f.exports.done), version .. " native movement starts") + for _ = 1, 40 do + OW.updateScriptMoves(f.ow) + actor:update(f.ow.map, f.ow.entities) + end + T.eq(handle:position(), 2, "native queue walks exactly two cells") + T.eq(f.exports.result().calls, 1, "native completion exactly once") + T.eq(#f.ow.scriptMoves, 0, "native movement releases world input") + T.check(handle:marchInPlace(f.exports.done), "native march starts") + for _ = 1, 20 do + OW.updateScriptMoves(f.ow) + actor:update(f.ow.map, f.ow.entities) + end + T.eq(handle:position(), 2, "march does not translate") + T.eq(f.exports.result().calls, 2, "native march completes") + T.check(handle:setAppearance("ALT"), "native renderer refresh path") + T.eq(actor.def.sprite, "ALT", "actor appearance changed") + T.eq( + f.game.data.maps[f.home].objects[2].sprite, + "PROBE", + "native template data not rewritten by appearance" + ) + T.check(f.exports.remove(handle.id), "native removal succeeds") + T.eq(#f.ow.npcs, 1, "native removal retains imported actor") + restore() +end +do + local f = fixture() + local native = f.ow.addRuntimeObject + function f.ow:addRuntimeObject(...) + native(self, ...) + error("creation failure", 0) + end + T.eq(f.exports.spawn(f.home, { sprite = "PROBE", x = 0, y = 0 }), nil, "creation exception contained") + T.eq(#f.game.data.maps[f.home].objects, 1, "failed creation template rolled back") + T.eq(#f.ow.npcs, 1, "failed creation actor rolled back") + T.eq(next(f.ow.npcPool), f.imported.id, "failed creation leaves pool unchanged") +end +do + local f = fixture() + local row = + { name = "LATER", sprite = "PROBE", x = 1, y = 1, owner = "forged", runtime = false, index = 900 } + local id = assert(f.exports.spawn(f.dest, row)) + local record = f.game.data.maps[f.dest].objects[1] + T.eq(record.owner, "npcs", "caller cannot forge runtime ownership") + T.eq(record.runtime, true, "native runtime marker assigned") + T.eq(record.index, 1, "native identity replaces supplied index") + T.eq(#f.ow.npcs, 1, "inactive spawn adds no active actor") + local actor = f:make(f.dest, record) + actor.def = require("src.mods.Merge").deepCopy(record) -- Native placement/appearance clones. + local map = require("src.world.Map").new(f.game.data.maps[f.dest], f.ow.map.tileset) + f.ow.ghosts = { { npc = actor, map = map } } + f.ow.npcPool[id] = actor + local handle = assert(f.exports.npc(f.dest, id)) + T.eq(handle:position(), 1, "live neighbor uses its own coordinate frame") + T.eq(handle:canStep("right"), true, "neighbor collision uses neighbor map") + local homeHandle, homeActor = spawn(f) + homeActor.cellX, homeActor.cellY = 2, 1 + local nb = { map = map, cx = 0, cy = 0, peers = { actor } } + f.ow.neighbors = { nb } + f.ow.ghosts[1].nb = nb + require("src.world.OverworldController").updateCast(f.ow) + T.eq(handle:canStep("right"), false, "neighbor cast sees translated primary actor") + T.eq(homeHandle:canStep("left"), false, "primary cast sees translated neighbor actor") + homeActor.passable = true + actor.passable = true + require("src.world.OverworldController").updateCast(f.ow) + T.eq(handle:canStep("right"), true, "neighbor cast respects primary passability") + T.eq(homeHandle:canStep("left"), true, "primary cast respects neighbor passability") + T.check(handle:face("left"), "neighbor can face") + local ok, why = handle:scriptMove("right", 1) + T.eq(ok, nil, "neighbor track refused") + T.eq(why, "movement tracks require the active map", "neighbor restriction explicit") + Loader.teardown() + T.eq(#f.game.data.maps[f.dest].objects, 0, "inactive template cleaned on unload") + T.eq(#f.ow.ghosts, 0, "later neighbor actor clone cleaned on unload") + T.eq(f.ow.npcPool[id], nil, "neighbor pool cleaned on unload") +end +do + local f = fixture() + local handle, actor = spawn(f) + handle.id = f.imported.id + T.check(handle:face("left"), "public ID field cannot redirect private handle binding") + T.eq(actor.facing, "left", "original binding changed") + T.eq(f.imported.facing, "down", "imported actor unaffected") +end +Renderer.new = originalRenderer +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 public NPC actions") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index b20738ec1..fdaef60d3 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp", "world_edits"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From f62b3751d6417b7a287915e862a16b81889c8b81 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:36:28 +0200 Subject: [PATCH 019/138] Support bounded multipass RetroArch presets and PNG LUTs --- .github/workflows/shader-import.yml | 9 +- docs/shader-presets.md | 49 ++- src/render/ShaderGraph.lua | 372 ++++++++++++++++++ src/render/ShaderPresets.lua | 68 +++- tests/engine/shader_graph.lua | 304 ++++++++++++++ tools/shader-graph-check/conf.lua | 7 + tools/shader-graph-check/main.lua | 182 +++++++++ tools/slang-import/fixtures/graph-first.slang | 14 + tools/slang-import/fixtures/graph-last.slang | 23 ++ .../slang-import/fixtures/graph-middle.slang | 12 + tools/slang-import/fixtures/graph.slangp | 21 + tools/slang-import/fixtures/tint.png | Bin 0 -> 74 bytes tools/slang-import/src/lib.rs | 351 ++++++++++++----- tools/slang-import/src/validate.lua | 66 +--- tools/slang-import/tests/import.rs | 94 ++++- 15 files changed, 1415 insertions(+), 157 deletions(-) create mode 100644 src/render/ShaderGraph.lua create mode 100644 tests/engine/shader_graph.lua create mode 100644 tools/shader-graph-check/conf.lua create mode 100644 tools/shader-graph-check/main.lua create mode 100644 tools/slang-import/fixtures/graph-first.slang create mode 100644 tools/slang-import/fixtures/graph-last.slang create mode 100644 tools/slang-import/fixtures/graph-middle.slang create mode 100644 tools/slang-import/fixtures/graph.slangp create mode 100644 tools/slang-import/fixtures/tint.png diff --git a/.github/workflows/shader-import.yml b/.github/workflows/shader-import.yml index 2fdb12b88..ac28e8721 100644 --- a/.github/workflows/shader-import.yml +++ b/.github/workflows/shader-import.yml @@ -6,6 +6,9 @@ on: - 'tools/slang-import/**' - 'tools/slang-import-check/**' - 'src/render/ShaderPresets.lua' + - 'src/render/ShaderGraph.lua' + - 'tools/shader-graph-check/**' + - 'tests/engine/shader_graph.lua' - 'tests/engine/slang_presets.lua' push: branches: [main, dev] @@ -14,6 +17,9 @@ on: - 'tools/slang-import/**' - 'tools/slang-import-check/**' - 'src/render/ShaderPresets.lua' + - 'src/render/ShaderGraph.lua' + - 'tools/shader-graph-check/**' + - 'tests/engine/shader_graph.lua' - 'tests/engine/slang_presets.lua' permissions: contents: read @@ -48,8 +54,9 @@ jobs: run: | destination="$RUNNER_TEMP/slang-presets" mkdir -p "$destination" - for name in probe wrap; do + for name in probe wrap graph; do xvfb-run -a tools/slang-import/target/release/gen2-slang-import \ "tools/slang-import/fixtures/$name.slangp" "$destination" --name "${name^^}" done SLANG_IMPORT_CHECK_DIR="$destination" xvfb-run -a love tools/slang-import-check + SHADER_GRAPH_CHECK_DIR="$destination" xvfb-run -a love tools/shader-graph-check diff --git a/docs/shader-presets.md b/docs/shader-presets.md index 9af0eeebc..3948af316 100644 --- a/docs/shader-presets.md +++ b/docs/shader-presets.md @@ -149,7 +149,7 @@ librashader. Before writing any preset files it validates both GL 1.20 and GLES 1.00 shader pairs with LÖVE, compiles the current GPU's pair, checks parameter/size/sampler uniforms and NPOT wrapping, and renders/reads back a small test target. A failed conversion or preflight leaves the destination -untouched. Success writes four GLSL files and one JSON descriptor, with +untouched. Success writes four GLSL files per pass, optional PNG LUTs and one JSON descriptor, with content-derived names and exclusive file creation. Existing files are never overwritten; a duplicate import reports an error. Sources are synced before the descriptor is written; a handled write failure removes only that import's @@ -159,17 +159,17 @@ Keep the original shader pack and its licence notices; converted shaders remain subject to their original licences. No third-party shader pack is bundled here. -This first importer intentionally supports a bounded subset: +The importer supports a bounded subset: -- One pass with default output scaling or explicit viewport scaling of 1x. -- `Source`/`Original` samplers, `SourceSize`/`OriginalSize`, `OutputSize`, +- One to eight ordered passes and up to eight PNG LUTs. Intermediate passes default to source 1x; the final pass defaults to viewport 1x. Explicit source, original, viewport (1/64x to 8x) and absolute (1 to 8192) scaling are supported per axis. +- Separate `Source`/`Original`, strictly earlier `PassOutputN` and named LUT samplers and their size uniforms, `OutputSize`, `FinalViewportSize`, the vertex MVP and up to eight scalar parameters, including preset overrides. Optimized-out parameters remain editable but have no GPU uniform to send. - Nearest/linear sampling and clamp-to-border, clamp-to-edge, repeat or mirrored-repeat addressing when supported by the target GPU. -- Normal RGBA8 output. LUTs, multipass graphs, history/feedback textures, - mipmaps, float/sRGB targets, other scaling, temporal/sensor/HDR uniforms, +- Normal RGBA8 output. History/feedback textures, + mipmaps, float/sRGB targets, temporal/sensor/HDR uniforms, unsigned arithmetic and other unsupported GLSL interfaces are rejected. LCD3x and zfast LCD are examples of this subset. Import success is not a @@ -208,3 +208,40 @@ source/output sizes, wrapping/filtering, alpha, resize/DPI 1/2/2.75 and failed selection rollback. If the directory also contains imports named `LCD3X` and `ZFAST LCD`, it compares their real output with independent CPU equations. `SLANG_IMPORT_CHECK_SHOT=/absolute/path.png` exports a comparison sheet. + +### Format-3 pass graphs + +Multipass, LUT or nonlegacy scaling imports use `format: 3` with `passes` +and `textures` arrays. Each pass holds the four stage filenames, filter, +wrap and `scaleX`/`scaleY` objects (`type`, `value`). LUT records hold `id`, +`image`, `filter` and `wrap`. Filenames are closed basenames; no executable +metadata is loaded. Shared parameters keep their existing IDs and save keys; +conflicting definitions across passes are rejected. + +The runtime compiles both GL/GLES dialects and draws/reads back the complete +graph before replacing a working effect. All canvases and LUT images are +private. Pass sampling settings affect only private rendered inputs; LUTs +retain their own filter/wrap. Original always means the reconstructed native +primary view; Source is the previous pass. The final result is copied to the +primary view's logical size/DPI. Borrowed inputs and the caller's graphics +state are preserved. Resize, OFF, replacement and render failure release +owned resources; render failure returns the unfiltered input. + +Before allocation, each frame checks GPU dimension limits and a 32-million +aggregate pixel budget (RGBA8 targets plus LUT pixels). LUTs must be 8-bit +PNG, at most 8 MiB compressed each, 2048 per axis and eight million pixels +combined; header limits precede native decoding. These bounds refuse expensive +presets rather than silently downscaling them. This is a spatial RGBA8 subset, +not full RetroArch compatibility. + +```sh +# Import graph.slangp as GRAPH, then: +luajit tests/engine/shader_graph.lua +SHADER_GRAPH_CHECK_DIR=/absolute/path/to/test-presets love tools/shader-graph-check +``` + +The graph GPU probe checks separate Original/Source/earlier-pass/LUT values, +per-pass sizes, live parameters, alpha, resize/DPI 1/2/2.75, state restoration +and rejected-candidate rollback against an independent pixel equation. +Pixels exactly on a nearest-sampling boundary are excluded from that equation +because GPU floating-point rounding can legitimately choose either neighbor. diff --git a/src/render/ShaderGraph.lua b/src/render/ShaderGraph.lua new file mode 100644 index 000000000..45b181af1 --- /dev/null +++ b/src/render/ShaderGraph.lua @@ -0,0 +1,372 @@ +-- Bounded, data-only RGBA8 pass graphs for converted RetroArch presets. +local Graph = + { MAX_PASSES = 8, MAX_TEXTURES = 8, MAX_PIXELS = 32 * 1024 * 1024, MAX_LUT_PIXELS = 8 * 1024 * 1024 } +Graph.__index = Graph +local function finite(v) + return type(v) == "number" and v == v and math.abs(v) < math.huge +end +local function filename(v, ext) + return type(v) == "string" and #v <= 64 and v:match("^[%w_-]+%." .. ext .. "$") ~= nil +end +local filters = { nearest = true, linear = true } +local wraps = { clampzero = true, clamp = true, ["repeat"] = true, mirroredrepeat = true } +local function array(t, max) + if type(t) ~= "table" or getmetatable(t) ~= nil or #t > max then + return false + end + local n = 0 + for k in pairs(t) do + if type(k) ~= "number" or k < 1 or k > #t or k % 1 ~= 0 then + return false + end + n = n + 1 + end + return n == #t +end +local function scale(s) + if type(s) ~= "table" or getmetatable(s) ~= nil then + return nil + end + for k in pairs(s) do + if k ~= "type" and k ~= "value" then + return nil + end + end + if not finite(s.value) then + return nil + end + if s.type == "absolute" then + if s.value % 1 ~= 0 or s.value < 1 or s.value > 8192 then + return nil + end + elseif + not ({ source = true, original = true, viewport = true })[s.type] + or s.value < 1 / 64 + or s.value > 8 + then + return nil + end + return { type = s.type, value = s.value } +end +function Graph.validate(raw) + if + type(raw) ~= "table" + or raw.format ~= 3 + or not array(raw.passes, Graph.MAX_PASSES) + or #raw.passes < 1 + or not array(raw.textures or {}, Graph.MAX_TEXTURES) + then + return nil, "invalid shader graph" + end + local out = { passes = {}, textures = {} } + for _, p in ipairs(raw.passes) do + if type(p) ~= "table" or getmetatable(p) ~= nil then + return nil, "invalid shader pass" + end + for key in pairs(p) do + if + not ({ + shader = true, + vertex = true, + esShader = true, + esVertex = true, + filter = true, + wrap = true, + scaleX = true, + scaleY = true, + })[key] + then + return nil, "unknown shader pass field" + end + end + local x, y = scale(p.scaleX), scale(p.scaleY) + if not x or not y or not filters[p.filter] or not wraps[p.wrap] then + return nil, "invalid shader pass options" + end + local copy = { filter = p.filter, wrap = p.wrap, scaleX = x, scaleY = y } + for _, k in ipairs({ "shader", "vertex", "esShader", "esVertex" }) do + if not filename(p[k], "glsl") then + return nil, "invalid shader graph filename" + end + copy[k] = p[k] + end + out.passes[#out.passes + 1] = copy + end + for _, t in ipairs(raw.textures or {}) do + if type(t) ~= "table" or getmetatable(t) ~= nil then + return nil, "invalid LUT" + end + for key in pairs(t) do + if not ({ id = true, image = true, filter = true, wrap = true })[key] then + return nil, "unknown LUT field" + end + end + if + not filename(t.image, "png") + or not filters[t.filter] + or not wraps[t.wrap] + or type(t.id) ~= "string" + or #t.id > 32 + or not t.id:match("^[%a_][%w_]*$") + then + return nil, "invalid LUT options" + end + out.textures[#out.textures + 1] = { id = t.id, image = t.image, filter = t.filter, wrap = t.wrap } + end + return out +end +local function png(bytes) + if + type(bytes) ~= "string" + or #bytes < 33 + or #bytes > 8 * 1024 * 1024 + or bytes:sub(1, 8) ~= "\137PNG\13\10\26\10" + or bytes:sub(9, 16) ~= "\0\0\0\13IHDR" + then + error("LUT must be a bounded PNG", 0) + end + local function word(p) + local a, b, c, d = bytes:byte(p, p + 3) + return ((a * 256 + b) * 256 + c) * 256 + d + end + local w, h = word(17), word(21) + if + w < 1 + or h < 1 + or w > 2048 + or h > 2048 + or bytes:byte(25) ~= 8 + or not ({ [0] = true, [2] = true, [3] = true, [4] = true, [6] = true })[bytes:byte(26)] + then + error("unsupported LUT dimensions/bit depth", 0) + end + return w, h +end +local function release(v) + if v and v.release then + pcall(v.release, v) + end +end +function Graph:own(v) + self.resources[v] = true + return v +end +function Graph:drop(v) + if v then + self.resources[v] = nil + release(v) + end +end +function Graph:release() + for v in pairs(self.resources) do + release(v) + end + self.resources = {} + self.targets = {} + self.original = nil + self.output = nil +end +local function send(shader, name, value) + if shader:hasUniform(name) then + shader:send(name, value) + end +end +-- LÖVE 11 returns a boolean; LÖVE 12 returns no value. Observe the actual mode. +local function setWrap(image, mode) + local result = image:setWrap(mode, mode) + if result == false then + error("unsupported graph wrapping", 0) + end + if image.getWrap then + local x, y = image:getWrap() + assert(x == mode and y == mode, "unsupported graph wrapping") + else + assert(result == true, "cannot verify graph wrapping") + end +end +Graph.setWrap = setWrap +local function sizes(w, h) + return { w, h, 1 / w, 1 / h } +end +local function dimensions(s, input, original, viewport) + local n = s.type == "absolute" and s.value + or s.value * ({ source = input, original = original, viewport = viewport })[s.type] + return math.max(1, math.floor(n + 0.5)) +end +function Graph:canvas(old, w, h, dpi) + local g = self.g + if + old + and old:getWidth() == w + and old:getHeight() == h + and (old.getDPIScale and old:getDPIScale() or 1) == dpi + then + return old + end + self:drop(old) + local c = self:own(g.newCanvas(w, h, { dpiscale = dpi, format = "rgba8" })) + c:setFilter("nearest", "nearest") + return c +end +function Graph:owns(image) + return self.resources[image] == true +end +function Graph:run(input, pixelScale) + local g = self.g + local pw, ph = input:getPixelDimensions() + local w, h = input:getDimensions() + local dpi = input.getDPIScale and input:getDPIScale() or 1 + local ratio = finite(pixelScale) and math.max(1, pixelScale) or 1 + local nw, nh = math.max(1, math.floor(pw / ratio + 0.5)), math.max(1, math.floor(ph / ratio + 0.5)) + local limit = g.getSystemLimits and g.getSystemLimits().texturesize or 4096 + limit = math.min(8192, limit) + local total = nw * nh + pw * ph + self.lutPixels + local resolved = {} + local iw, ih = nw, nh + for i, p in ipairs(self.spec.passes) do + local ow = dimensions(p.scaleX, iw, nw, pw) + local oh = dimensions(p.scaleY, ih, nh, ph) + total = total + ow * oh + if ow > limit or oh > limit or total > Graph.MAX_PIXELS then + error("shader graph exceeds GPU size/pixel budget", 0) + end + resolved[i] = { ow, oh } + iw, ih = ow, oh + end + if nw > limit or nh > limit or pw > limit or ph > limit or total > Graph.MAX_PIXELS then + error("shader graph exceeds GPU size/pixel budget", 0) + end + self.original = self:canvas(self.original, nw, nh, 1) + self.output = self:canvas(self.output, w, h, dpi) + for i, size in ipairs(resolved) do + self.targets[i] = self:canvas(self.targets[i], size[1], size[2], 1) + end + local saved = g.getCanvas() + local pushed, why = pcall(g.push, "all") + if not pushed then + error(why, 0) + end + local ok, err = pcall(function() + g.origin() + g.setScissor() + g.setStencilTest() + g.setDepthMode() + g.setColorMask(true, true, true, true) + g.setColor(1, 1, 1, 1) + g.setBlendMode("replace", "premultiplied") + g.setCanvas(self.original) + g.setShader() + g.clear(0, 0, 0, 0) + g.draw(input, 0, 0, 0, nw / w, nh / h) + for i, p in ipairs(self.spec.passes) do + local shader = self.shaders[i] + local source = i == 1 and self.original or self.targets[i - 1] + local sw, sh = source:getDimensions() + local ow, oh = resolved[i][1], resolved[i][2] + -- All rendered inputs are private. LUT samplers retain their own settings. + for j = 0, i - 1 do + local img = j == 0 and self.original or self.targets[j] + img:setFilter(p.filter, p.filter) + setWrap(img, p.wrap) + end + send(shader, "g2_Source", source) + send(shader, "g2_Original", self.original) + send(shader, "g2_SourceSize", sizes(sw, sh)) + send(shader, "g2_OriginalSize", sizes(nw, nh)) + send(shader, "g2_OutputSize", sizes(ow, oh)) + send(shader, "g2_FinalViewportSize", sizes(pw, ph)) + for j = 1, i - 1 do + local image = self.targets[j] + send(shader, "g2_Pass" .. (j - 1), image) + send(shader, "g2_Pass" .. (j - 1) .. "Size", sizes(image:getDimensions())) + end + for j, img in ipairs(self.images) do + send(shader, "g2_Lut" .. (j - 1), img) + send(shader, "g2_Lut" .. (j - 1) .. "Size", sizes(img:getDimensions())) + end + for id, n in pairs(self.values) do + send(shader, "g2_p_" .. id, n) + end + g.setCanvas(self.targets[i]) + g.clear(0, 0, 0, 0) + g.setShader(shader) + g.draw(source, 0, 0, 0, ow / sw, oh / sh) + end + local last = self.targets[#self.targets] + last:setFilter("nearest", "nearest") + g.setCanvas(self.output) + g.setShader() + g.clear(0, 0, 0, 0) + g.draw(last, 0, 0, 0, w / last:getWidth(), h / last:getHeight()) + end) + g.pop() + g.setCanvas(saved) + if not ok then + error(err, 0) + end + return self.output +end +function Graph.new(spec, read, g, values, fileData) + local self = setmetatable( + { + spec = spec, + g = g, + values = values, + resources = {}, + targets = {}, + shaders = {}, + images = {}, + lutPixels = 0, + }, + Graph + ) + local probe + local ok, why = pcall(function() + local renderer, version = g.getRendererInfo() + local es = (tostring(renderer) .. tostring(version)):find("OpenGL ES", 1, true) ~= nil + for i, p in ipairs(spec.passes) do + local source = {} + for _, key in ipairs({ "shader", "vertex", "esShader", "esVertex" }) do + source[key] = assert(read(p[key], 65536)) + end + for _, mobile in ipairs({ false, true }) do + local valid, message = g.validateShader( + mobile, + source[mobile and "esShader" or "shader"], + source[mobile and "esVertex" or "vertex"] + ) + assert(valid, tostring(message)) + end + self.shaders[i] = + self:own(g.newShader(source[es and "esShader" or "shader"], source[es and "esVertex" or "vertex"])) + end + for i, t in ipairs(spec.textures) do + local bytes = assert(read(t.image, 8 * 1024 * 1024)) + local width, height = png(bytes) + self.lutPixels = self.lutPixels + width * height + assert(self.lutPixels <= Graph.MAX_LUT_PIXELS, "LUT pixel budget") + local data = fileData(bytes, t.image) + local loaded, img = pcall(g.newImage, data, { mipmaps = false, linear = true }) + release(data) + if not loaded then + error(img, 0) + end + self.images[i] = self:own(img) + assert(img:getWidth() == width and img:getHeight() == height, "LUT decoder dimensions differ") + img:setFilter(t.filter, t.filter) + setWrap(img, t.wrap) + end + probe = g.newCanvas(3, 5, { dpiscale = 1, format = "rgba8" }) + local output = self:run(probe, 1) + local pixels = output:newImageData() + assert(pixels, "graph readback unavailable") + release(pixels) + end) + release(probe) + if not ok then + self:release() + return nil, why + end + return self +end +return Graph diff --git a/src/render/ShaderPresets.lua b/src/render/ShaderPresets.lua index c9651581a..0677e6e7b 100644 --- a/src/render/ShaderPresets.lua +++ b/src/render/ShaderPresets.lua @@ -1,6 +1,7 @@ --- Optional single-pass LÖVE shaders over the completed primary game view. +-- Optional bounded LÖVE shaders over the completed primary game view. -- Metadata is JSON, never executable Lua. OFF does no discovery or GPU work. local Json = require("src.link.Json") +local Graph = require("src.render.ShaderGraph") local Logger = require("src.core.Logger") local Presets = {} Presets.__index = Presets @@ -27,14 +28,24 @@ local function value(p, n) return math.max(p.min, math.min(p.max, p.min + steps * p.step)) end local function validate(raw, id) - if type(raw) ~= "table" or (raw.format ~= 1 and raw.format ~= 2) or not label(raw.name) then + if + type(raw) ~= "table" + or (raw.format ~= 1 and raw.format ~= 2 and raw.format ~= 3) + or not label(raw.name) + then return nil, "invalid preset metadata" end - if not filename(raw.shader, "glsl") and not raw.builtinSource then + if raw.format ~= 3 and not filename(raw.shader, "glsl") and not raw.builtinSource then return nil, "invalid shader filename" end local out = { id = id, name = raw.name, shader = raw.shader, source = raw.builtinSource, parameters = {} } - if raw.format == 2 then + if raw.format == 3 then + local graph, why = Graph.validate(raw) + if not graph or raw.builtinSource then + return nil, why or "invalid graph" + end + out.graph, out.imported = graph, true + elseif raw.format == 2 then if raw.builtinSource or not filename(raw.vertex, "glsl") @@ -122,7 +133,10 @@ vec4 effect(vec4 color, Image tex, vec2 uv, vec2 pixel) { } function Presets.new(opts) opts = opts or {} - return setmetatable({ fs = opts.fs, graphics = opts.graphics, entries = {}, byId = {} }, Presets) + return setmetatable( + { fs = opts.fs, graphics = opts.graphics, entries = {}, byId = {}, fileData = opts.fileData }, + Presets + ) end function Presets:_fs() return self.fs or (love and love.filesystem and require("src.import.CacheFs").dataFs()) @@ -194,6 +208,10 @@ function Presets:_fail(reason) return nil, self.error end function Presets:off() + if self.graph then + self.graph:release() + self.graph = nil + end release(self.shader) release(self.output) release(self.importInput) @@ -202,7 +220,7 @@ function Presets:off() self.error = nil end function Presets:active() - return self.shader ~= nil + return self.shader ~= nil or self.graph ~= nil end function Presets:activate(id, params) if id == nil or id == "off" then @@ -218,6 +236,24 @@ function Presets:activate(id, params) return self:_fail(entry.error) end local g = self:_graphics() + if entry.graph then + if not (g and g.newShader and g.newCanvas and g.validateShader) then + return self:_fail("shader graph graphics unavailable") + end + local values = {} + for _, p in ipairs(entry.parameters) do + values[p.id] = value(p, type(params) == "table" and params[p.id] or nil) + end + local graph, why = Graph.new(entry.graph, function(name, limit) + return read(self:_fs(), ROOT .. "/" .. name, limit) + end, g, values, self.fileData or (love and love.filesystem and love.filesystem.newFileData)) + if not graph then + return self:_fail(why) + end + self:off() + self.graph, self.entry, self.values = graph, entry, values + return true + end local es = false if entry.imported and g and g.getRendererInfo then local name, version = g.getRendererInfo() @@ -248,7 +284,7 @@ function Presets:activate(id, params) -- Check NPOT wrapping and standardized uniform types before retiring the old effect. importInput = g.newCanvas(3, 5, { dpiscale = 1 }) importInput:setFilter(entry.filter, entry.filter) - assert(importInput:setWrap(entry.wrap, entry.wrap), "unsupported preset texture wrapping") + Graph.setWrap(importInput, entry.wrap) for _, name in ipairs({ "SourceSize", "OriginalSize", "OutputSize", "FinalViewportSize" }) do if shader:hasUniform("g2_" .. name) then shader:send("g2_" .. name, { 3, 5, 1 / 3, 1 / 5 }) @@ -317,6 +353,19 @@ function Presets:setParameter(opts, id, n) return nil, "unknown parameter" end function Presets:apply(input, pixelScale) + if self.graph then + if self.graph:owns(input) then + return input + end + local ok, output = pcall(self.graph.run, self.graph, input, pixelScale) + if ok then + return output + end + self:off() + self:_fail(output) + Logger.warn("shader graph disabled: %s", self.error) + return input + end if not self:active() or (input == self.output or input == self.importInput) then return input end @@ -366,10 +415,7 @@ function Presets:apply(input, pixelScale) self.importInput = nil self.importInput = g.newCanvas(nw, nh, { dpiscale = 1 }) self.importInput:setFilter(self.entry.filter, self.entry.filter) - assert( - self.importInput:setWrap(self.entry.wrap, self.entry.wrap), - "unsupported preset texture wrapping" - ) + Graph.setWrap(self.importInput, self.entry.wrap) end -- Reconstruct the primary view's native-pixel grid in a private texture. -- Its filter/wrap never change a mod's or the renderer's input texture. diff --git a/tests/engine/shader_graph.lua b/tests/engine/shader_graph.lua new file mode 100644 index 000000000..768e4af06 --- /dev/null +++ b/tests/engine/shader_graph.lua @@ -0,0 +1,304 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Graph = require("src.render.ShaderGraph") +local Presets = require("src.render.ShaderPresets") +local Json = require("src.link.Json") +local function fixture() + local spec = { + format = 3, + name = "GRAPH", + parameters = { { id = "gain", name = "GAIN", min = 0, max = 1, step = 0.1, default = 0.5 } }, + passes = {}, + textures = { { id = "Tint", image = "t.png", filter = "linear", wrap = "repeat" } }, + } + for i = 1, 3 do + spec.passes[i] = { + shader = "f.glsl", + vertex = "v.glsl", + esShader = "ef.glsl", + esVertex = "ev.glsl", + filter = "nearest", + wrap = "clamp", + scaleX = { type = i == 3 and "viewport" or "source", value = i == 2 and 2 or 1 }, + scaleY = { type = i == 3 and "viewport" or "original", value = 1 }, + } + end + local files = { + ["f.glsl"] = "f", + ["v.glsl"] = "v", + ["ef.glsl"] = "ef", + ["ev.glsl"] = "ev", + ["t.png"] = "\137PNG\13\10\26\10\0\0\0\13IHDR\0\0\0\2\0\0\0\1\8\6" .. string.rep("\0", 7), + } + local objects, faults, uniforms, compiled = {}, {}, {}, {} + local current, stack, draws = nil, 0, 0 + local function resource(w, h, dpi) + local o = { w = w, h = h, dpi = dpi or 1 } + objects[#objects + 1] = o + function o:getWidth() + return self.w + end + function o:getHeight() + return self.h + end + function o:getDimensions() + return self.w, self.h + end + function o:getPixelDimensions() + return self.w * self.dpi, self.h * self.dpi + end + function o:getDPIScale() + return self.dpi + end + function o:setFilter(...) + self.filter = { ... } + end + function o:setWrap(...) + self.wrap = { ... } + return not faults.wrap + end + function o:release() + T.check(not self.released, "release once") + self.released = true + end + function o:newImageData() + if faults.readback then + error("readback") + end + return resource(0, 0) + end + function o:hasUniform() + return true + end + function o:send(k, v) + if faults.send then + error("uniform") + end + self.uniforms = self.uniforms or {} + self.uniforms[k] = v + uniforms[k] = v + end + return o + end + local g = { + getRendererInfo = function() + return faults.es and "OpenGL ES" or "OpenGL", "2.0" + end, + getSystemLimits = function() + return { texturesize = 8192 } + end, + validateShader = function(es, f, v) + compiled[#compiled + 1] = { es, f, v } + return not faults.validate, "bad dialect" + end, + newShader = function(f, v) + if faults.compile then + error("compile") + end + local o = resource(0, 0) + o.pair = { f, v } + return o + end, + newCanvas = function(w, h, opts) + if faults.allocate then + error("allocation") + end + return resource(w, h, opts.dpiscale) + end, + newImage = function() + if faults.decode then + error("decode") + end + return resource(2, 1) + end, + getCanvas = function() + return current + end, + setCanvas = function(c) + current = c + end, + push = function() + stack = stack + 1 + end, + pop = function() + stack = stack - 1 + end, + draw = function(image) + if faults.draw then + error("draw") + end + T.neq(image, current, "no render feedback") + draws = draws + 1 + end, + } + for _, k in ipairs({ + "origin", + "setScissor", + "setStencilTest", + "setDepthMode", + "setColorMask", + "setColor", + "setBlendMode", + "setShader", + "clear", + }) do + g[k] = function() end + end + local fs = { + getDirectoryItems = function() + return { "graph.json" } + end, + getInfo = function(path) + local name = path:match("[^/]+$") + local text = name == "graph.json" and Json.encode(spec) or files[name] + return text and { type = "file", size = #text } + end, + read = function(path) + local name = path:match("[^/]+$") + return name == "graph.json" and Json.encode(spec) or files[name] + end, + } + return Presets.new({ + graphics = g, + fs = fs, + fileData = function() + return resource(0, 0) + end, + }), + spec, + files, + faults, + uniforms, + objects, + resource, + g, + function() + return stack, current, draws + end, + compiled +end +for i, mutate in ipairs({ + function(s) + s.passes = {} + end, + function(s) + for n = 4, 9 do + s.passes[n] = s.passes[1] + end + end, + function(s) + s.passes[1].shader = "../bad.glsl" + end, + function(s) + s.passes[1].filter = "cubic" + end, + function(s) + s.passes[1].wrap = "bad" + end, + function(s) + s.passes[1].scaleX.value = 9 + end, + function(s) + s.passes[1].scaleY = { type = "absolute", value = 1.5 } + end, + function(s) + s.passes[1].scaleX = { type = "bad", value = 1 } + end, + function(s) + s.passes[1].scaleX.extra = true + end, + function(s) + s.passes[1].extra = true + end, + function(s) + s.textures[1].image = "../t.png" + end, + function(s) + s.textures[1].filter = "cubic" + end, + function(s) + s.textures[1].id = "bad id" + end, + function(s) + s.textures[1].extra = true + end, + function(s) + s.parameters[1].id = "g2_Lut0" + end, +}) do + local p, s = fixture() + mutate(s) + T.check(not p:activate("local:graph"), "invalid graph refused " .. i) +end +for _, fault in ipairs({ "validate", "compile", "allocate", "wrap", "decode", "send", "readback", "draw" }) do + local p, s, files, faults, u, objects = fixture() + T.check(p:activate("builtin:warm"), "working fallback") + local old = p.shader + faults[fault] = true + T.check(not p:activate("local:graph"), fault .. " preflight refuses") + T.eq(p.shader, old, "old preserved") + T.check(p:active(), "still active") + for _, o in ipairs(objects) do + if o ~= old then + T.check(o.released, "failed candidate releases partial resources") + end + end + p:off() +end +do + local p, s, files, f, u, objects, resource, g, state, compiled = fixture() + f.es = true + T.check(p:activate("local:graph"), "activate graph") + T.eq(#compiled, 6, "both dialects every pass") + T.same(p.graph.shaders[1].pair, { "ef", "ev" }, "current dialect pair") + local input = resource(40, 30, 2) + local sentinel = resource(1, 1) + g.setCanvas(sentinel) + local output = p:apply(input, 5) + T.neq(output, input, "graph output") + T.eq(output:getDPIScale(), 2, "DPI retained") + T.same(u.g2_OriginalSize, { 16, 12, 1 / 16, 1 / 12 }, "original native grid") + T.same(u.g2_SourceSize, { 32, 12, 1 / 32, 1 / 12 }, "source prior pass grid") + T.same(u.g2_Pass0Size, { 16, 12, 1 / 16, 1 / 12 }, "earlier grid") + T.same(u.g2_Lut0Size, { 2, 1, 0.5, 1 }, "LUT dimensions") + T.same(u.g2_OutputSize, { 80, 60, 1 / 80, 1 / 60 }, "viewport physical grid") + T.eq(u.g2_Original, p.graph.original, "original sampler") + T.eq(u.g2_Pass0, p.graph.targets[1], "earlier sampler") + T.same(p.graph.images[1].filter, { "linear", "linear" }, "LUT filter retained") + T.same(p.graph.images[1].wrap, { "repeat", "repeat" }, "LUT wrap retained") + T.eq(input.filter, nil, "borrowed filter untouched") + local stack, current = state() + T.eq(stack, 0, "balanced state") + T.eq(current, sentinel, "canvas restored") + local n = #objects + p:apply(input, 5) + T.eq(#objects, n, "stable graph reuses resources") + T.eq(p:apply(output, 5), output, "output feedback guard") + local old = p.graph.original + p:apply(input, 2.5) + T.check(old.released, "scale retires grid") + local opts = {} + p:setParameter(opts, "gain", 0.8) + p:apply(input, 2.5) + T.eq(u.g2_p_gain, 0.8, "live parameter shared across passes") + f.draw = true + T.eq(p:apply(input, 2.5), input, "render failure identity") + T.check(not p:active(), "render failure disables graph") + T.check(not input.released, "input owned by caller") + stack, current = state() + T.eq(stack, 0, "failure balances state") + T.eq(current, sentinel, "failure restores canvas") +end +do + local p, s, files, f, u, objects, resource = fixture() + T.check(p:activate("local:graph"), "budget candidate") + local input = resource(8192, 8192) + T.eq(p:apply(input, 1), input, "pixel budget identity") + T.check(not p:active(), "over budget retired") +end +for _, bad in ipairs({ "not png", "\137PNG\13\10\26\10" .. string.rep("\0", 30) }) do + local p, s, files = fixture() + files["t.png"] = bad + T.check(not p:activate("local:graph"), "PNG header checked before decoder") +end +T.finish("shader graph") diff --git a/tools/shader-graph-check/conf.lua b/tools/shader-graph-check/conf.lua new file mode 100644 index 000000000..18f7e58cd --- /dev/null +++ b/tools/shader-graph-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + t.identity = "gen2-slang-import-check" + t.window.title = "Gen2Recomped slang import checks" + t.window.width, t.window.height = 640, 480 + t.window.visible = false + t.modules.audio = false +end diff --git a/tools/shader-graph-check/main.lua b/tools/shader-graph-check/main.lua new file mode 100644 index 000000000..34ee7f6b1 --- /dev/null +++ b/tools/shader-graph-check/main.lua @@ -0,0 +1,182 @@ +function love.errorhandler(message) + io.stderr:write(tostring(message) .. "\n") + return function() + return 1 + end +end +local root = love.filesystem.getSource():match("^(.*)/tools/shader%-graph%-check$") + or love.filesystem.getWorkingDirectory() +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +local Presets = require("src.render.ShaderPresets") +local g = love.graphics +local checks = 0 +local function check(v, why) + checks = checks + 1 + assert(v, why) +end +local function near(a, b, why) + check(math.abs(a - b) < 2 / 255, why .. " " .. a .. "/" .. b) +end +local function pattern(x, y) + return (11 * x + 7) / 255, (17 * y + 13) / 255, 0.3, y % 2 == 0 and 1 or 0.5 +end +local function run() + local fs = { + getDirectoryItems = function() + return love.filesystem.getDirectoryItems("imports") + end, + getInfo = function(p) + return love.filesystem.getInfo((p:gsub("^shader%-presets", "imports"))) + end, + read = function(p) + return love.filesystem.read((p:gsub("^shader%-presets", "imports"))) + end, + } + local p = Presets.new({ fs = fs }) + local entry + for _, e in ipairs(p:list()) do + if e.name == "GRAPH" then + entry = e + end + end + check(entry ~= nil, "GRAPH import required") + check(p:activate(entry.id), "graph activated: " .. tostring(p.error)) + local data = love.image.newImageData(16, 12) + for y = 0, 11 do + for x = 0, 15 do + data:setPixel(x, y, pattern(x, y)) + end + end + local image = g.newImage(data) + image:setFilter("nearest", "nearest") + local sentinel = g.newCanvas(10, 10) + local sentinelShader = g.newShader("vec4 effect(vec4 c, Image t, vec2 uv,vec2 px){return Texel(t,uv)*c;}") + for _, dpi in ipairs({ 1, 2, 2.75 }) do + for _, factor in ipairs({ 1, 2 }) do + local scale = 5 * factor * dpi + local w, h = 80 * factor, 60 * factor + local input = g.newCanvas(w, h, { dpiscale = dpi }) + input:setFilter("nearest", "nearest") + input:setWrap("repeat", "repeat") + g.push("all") + g.setCanvas(input) + g.origin() + g.setShader() + g.setBlendMode("replace", "premultiplied") + g.setColor(1, 1, 1, 1) + g.draw(image, 0, 0, 0, w / 16, h / 12) + g.pop() + g.setCanvas() + for _, gain in ipairs({ 0.5, 0.8 }) do + check(p:setParameter({}, "gain", gain), "parameter") + g.setCanvas(sentinel) + g.setShader(sentinelShader) + g.setColor(0.4, 0.5, 0.6, 0.7) + g.setScissor(1, 2, 3, 4) + g.translate(2, 3) + local before = g.getStackDepth() + local output = p:apply(input, scale) + check(output ~= input and p:active(), "graph survives") + check(g.getCanvas() == sentinel, "canvas restored") + check(g.getShader() == sentinelShader, "shader restored") + check(g.getStackDepth() == before, "stack restored") + local r, gg, b, a = g.getColor() + near(r, 0.4, "color") + local x, y = g.transformPoint(0, 0) + near(x, 2, "transform x") + near(y, 3, "transform y") + local sx, sy, sw, sh = g.getScissor() + check(sx == 1 and sy == 2 and sw == 3 and sh == 4, "scissor") + g.setCanvas() + g.setShader() + g.setScissor() + g.origin() + g.setColor(1, 1, 1, 1) + local pixels = output:newImageData() + local pw, ph = pixels:getDimensions() + check(pw == 16 * scale and ph == 12 * scale, "physical output") + near(output:getDPIScale(), dpi, "DPI") + check(p.graph.targets[1]:getWidth() == 16 and p.graph.targets[2]:getWidth() == 32, "per pass scale") + check(p.graph.targets[3]:getWidth() == pw, "viewport scale") + for py = 0, ph - 1 do + for px = 0, pw - 1 do + local u, v = (px + 0.5) / scale, (py + 0.5) / scale + -- A pixel exactly on a nearest-sampling boundary can select either texel after GPU rounding. + if + math.abs(u - math.floor(u + 0.5)) > 0.00001 and math.abs(v - math.floor(v + 0.5)) > 0.00001 + then + local pr, pg, pb, pa = pattern(math.floor((px + 0.5) / scale), math.floor((py + 0.5) / scale)) + local ar, ag, ab, aa = pixels:getPixel(px, py) + -- RGBA8 intermediate passes quantize the first multiplication. + near( + ar, + math.floor(pr * gain * 255 + 0.5) / 255 + pr * 0.25, + ("source plus original dpi=%s factor=%s gain=%s px=%s py=%s"):format( + dpi, + factor, + gain, + px, + py + ) + ) + near(ag, pg, "earlier pass, not source") + near(ab, 128 / 255 / 2, "LUT and size semantics") + near(aa, pa, "alpha") + end + end + end + pixels:release() + check(p:apply(output, scale) == output, "feedback prevented") + check(input:getFilter() == "nearest", "borrowed filter") + check(input:getWrap() == "repeat", "borrowed wrap") + end + local targets = p.graph.targets + p:apply(input, scale) + check(p.graph.targets == targets, "reuse") + input:release() + end + end + -- Rejected GPU/decoder candidates preserve the current effect. + local old = p.graph + local original = fs.read + fs.read = function(path) + if path:match("%.glsl$") then + return "invalid glsl" + end + return original(path) + end + check(not p:activate(entry.id), "bad candidate rejected") + check(p.graph == old and p:active(), "working graph preserved") + fs.read = original + p:off() + check(not p:active(), "OFF") + image:release() + data:release() + sentinel:release() + sentinelShader:release() + print( + "shader graph GPU PASS " + .. checks + .. " pixel/state checks | " + .. table.concat({ g.getRendererInfo() }, " | ") + ) +end +function love.load() + local ok, why = pcall(function() + assert( + require("src.import.CacheFs").withMounted( + assert(os.getenv("SHADER_GRAPH_CHECK_DIR")), + "imports", + function() + run() + return true + end + ), + "mount unavailable" + ) + end) + if not ok then + io.stderr:write(tostring(why) .. "\n") + end + love.event.quit(ok and 0 or 1) +end diff --git a/tools/slang-import/fixtures/graph-first.slang b/tools/slang-import/fixtures/graph-first.slang new file mode 100644 index 000000000..a5421ceaa --- /dev/null +++ b/tools/slang-import/fixtures/graph-first.slang @@ -0,0 +1,14 @@ +#version 450 +#pragma parameter gain "GAIN" 0.5 0.0 1.0 0.1 +layout(push_constant) uniform Push { float gain; } params; +layout(std140,set=0,binding=0) uniform UBO { mat4 MVP; } global; +#pragma stage vertex +layout(location=0) in vec4 Position; +layout(location=1) in vec2 TexCoord; +layout(location=0) out vec2 uv; +void main(){gl_Position=global.MVP*Position;uv=TexCoord;} +#pragma stage fragment +layout(location=0) in vec2 uv; +layout(location=0) out vec4 FragColor; +layout(set=0,binding=2) uniform sampler2D Source; +void main(){vec4 c=texture(Source,uv);FragColor=vec4(c.r*params.gain,c.g,c.b,c.a);} diff --git a/tools/slang-import/fixtures/graph-last.slang b/tools/slang-import/fixtures/graph-last.slang new file mode 100644 index 000000000..a88d535ad --- /dev/null +++ b/tools/slang-import/fixtures/graph-last.slang @@ -0,0 +1,23 @@ +#version 450 +layout(push_constant) uniform Push { + vec4 SourceSize;vec4 OriginalSize;vec4 PassOutputSize0;vec4 TintSize;vec4 OutputSize;vec4 FinalViewportSize; +} params; +layout(std140,set=0,binding=0) uniform UBO { mat4 MVP; } global; +#pragma stage vertex +layout(location=0) in vec4 Position; +layout(location=1) in vec2 TexCoord; +layout(location=0) out vec2 uv; +void main(){gl_Position=global.MVP*Position;uv=TexCoord;} +#pragma stage fragment +layout(location=0) in vec2 uv; +layout(location=0) out vec4 FragColor; +layout(set=0,binding=2) uniform sampler2D Source; +layout(set=0,binding=3) uniform sampler2D Original; +layout(set=0,binding=4) uniform sampler2D PassOutput0; +layout(set=0,binding=5) uniform sampler2D Tint; +void main(){ + vec4 c=texture(Source,uv);vec4 original=texture(Original,uv);vec4 first=texture(PassOutput0,uv); + float sizes=params.SourceSize.x/params.OriginalSize.x+params.PassOutputSize0.x/params.OriginalSize.x; + float outputRatio=params.OutputSize.y/params.FinalViewportSize.y; + FragColor=vec4(c.r+original.r*0.25,first.g,texture(Tint,vec2(0.75,0.5)).b*sizes*outputRatio/6.0,original.a); +} diff --git a/tools/slang-import/fixtures/graph-middle.slang b/tools/slang-import/fixtures/graph-middle.slang new file mode 100644 index 000000000..2266b944c --- /dev/null +++ b/tools/slang-import/fixtures/graph-middle.slang @@ -0,0 +1,12 @@ +#version 450 +layout(std140,set=0,binding=0) uniform UBO { mat4 MVP; } global; +#pragma stage vertex +layout(location=0) in vec4 Position; +layout(location=1) in vec2 TexCoord; +layout(location=0) out vec2 uv; +void main(){gl_Position=global.MVP*Position;uv=TexCoord;} +#pragma stage fragment +layout(location=0) in vec2 uv; +layout(location=0) out vec4 FragColor; +layout(set=0,binding=2) uniform sampler2D Source; +void main(){vec4 c=texture(Source,uv);FragColor=vec4(c.r,c.g*0.5,c.b,c.a);} diff --git a/tools/slang-import/fixtures/graph.slangp b/tools/slang-import/fixtures/graph.slangp new file mode 100644 index 000000000..7c61ee9e5 --- /dev/null +++ b/tools/slang-import/fixtures/graph.slangp @@ -0,0 +1,21 @@ +shaders=3 +shader0=graph-first.slang +shader1=graph-middle.slang +shader2=graph-last.slang +filter_linear0=false +filter_linear1=false +filter_linear2=false +wrap_mode0=clamp_to_edge +wrap_mode1=clamp_to_edge +wrap_mode2=clamp_to_edge +scale_type0=original +scale0=1.0 +scale_type1=source +scale1=2.0 +scale_type2=viewport +scale2=1.0 +textures=Tint +Tint=tint.png +Tint_linear=false +Tint_wrap_mode=repeat +Tint_mipmap=false diff --git a/tools/slang-import/fixtures/tint.png b/tools/slang-import/fixtures/tint.png new file mode 100644 index 0000000000000000000000000000000000000000..8afb523abb9a57bb161785983627c1341f7b1f9f GIT binary patch literal 74 zcmeAS@N?(olHy`uVBq!ia0vp^OhC-Y!3HG1DAjiXDM3#c$B>FS$q5MwKh7Vx@Ijt| XotdfrMbz5@pd^E*tDnm{r-UW|MCKD( literal 0 HcmV?d00001 diff --git a/tools/slang-import/src/lib.rs b/tools/slang-import/src/lib.rs index e0cfb91ae..67dca3736 100644 --- a/tools/slang-import/src/lib.rs +++ b/tools/slang-import/src/lib.rs @@ -1,7 +1,7 @@ -//! Offline, single-pass slang -> LÖVE importer. No compiler is loaded by the game. +//! Offline, bounded RGBA8 slang -> LÖVE importer. No compiler is loaded by the game. use librashader_common::{FilterMode, ImageFormat, WrapMode}; use librashader_preprocess::ShaderSource; -use librashader_presets::{ScaleFactor, ScaleType, ShaderFeatures, ShaderPreset}; +use librashader_presets::{ScaleFactor, ScaleType, Scaling, ShaderFeatures, ShaderPreset}; use librashader_reflect::{ back::{CompileShader, FromCompilation, glsl::GlslVersion, targets::GLSL}, front::{Glslang, ShaderInputCompiler}, @@ -25,9 +25,37 @@ use std::{ }; pub type Result = std::result::Result>; +pub struct ConvertedPass { + pub metadata: Value, + pub sources: [String; 4], +} +pub struct ConvertedTexture { + pub metadata: Value, + pub bytes: Vec, +} pub struct Converted { pub metadata: Value, - pub sources: [String; 4], // desktop fragment/vertex, GLES fragment/vertex + pub passes: Vec, + pub textures: Vec, +} +fn binding( + semantic: TextureSemantics, + index: usize, + pass: usize, + textures: usize, + legacy: bool, +) -> Result { + Ok(match (semantic, index) { + (TextureSemantics::Source, 0) => "g2_Source".into(), + (TextureSemantics::Original, 0) => if legacy { "g2_Source" } else { "g2_Original" }.into(), + (TextureSemantics::PassOutput, n) if n < pass => format!("g2_Pass{n}"), + (TextureSemantics::User, n) if n < textures => format!("g2_Lut{n}"), + _ => { + return Err( + "history, feedback, future or unknown texture binding is unsupported".into(), + ); + } + }) } fn rx(pattern: &str) -> Regex { Regex::new(pattern).expect("internal regex") @@ -55,6 +83,9 @@ fn adapt( vertex: bool, semantics: &ShaderSemantics, params: &[Value], + pass: usize, + textures: usize, + legacy: bool, ) -> Result { if input.contains("g2_") { return Err("reserved g2_ identifier in shader".into()); @@ -100,11 +131,11 @@ fn adapt( _ => return Err(format!("unsupported uniform: {name}").into()), } } else if let Some(v) = semantics.uniform_semantics.texture_semantic(name) { - match (v.semantics, v.index, *ty) { - (TextureSemantics::Source, 0, "vec4") => "g2_SourceSize".into(), - (TextureSemantics::Original, 0, "vec4") => "g2_OriginalSize".into(), - _ => return Err(format!("unsupported texture-size uniform: {name}").into()), + if *ty != "vec4" { + return Err("texture size must be vec4".into()); } + let name = binding(v.semantics, v.index, pass, textures, false)?; + format!("{name}Size") } else { return Err(format!("unknown uniform: {name}").into()); }; @@ -135,18 +166,17 @@ fn adapt( .texture_semantics .texture_semantic(logical) .ok_or("unknown sampler semantic")?; - if !matches!( - (semantic.semantics, semantic.index), - (TextureSemantics::Source | TextureSemantics::Original, 0) - ) { - return Err(format!("unsupported sampler: {logical}").into()); - } - s = replace_word(&s, name, "g2_Source"); + let target = binding(semantic.semantics, semantic.index, pass, textures, legacy)?; + s = replace_word(&s, name, &target); } - // Repeated Source/Original aliases refer to the same input in a single pass. s = samplers.replace_all(&s, "").into_owned(); - if s.contains("g2_Source") { - declarations.push_str("uniform Image g2_Source;\n"); + let mut bindings: Vec = vec!["g2_Source".into(), "g2_Original".into()]; + bindings.extend((0..pass).map(|i| format!("g2_Pass{i}"))); + bindings.extend((0..textures).map(|i| format!("g2_Lut{i}"))); + for name in bindings { + if rx(&format!(r"\b{name}\b")).is_match(&s) { + declarations.push_str(&format!("uniform Image {name};\n")); + } } if vertex { s = rx(r"attribute vec4 Position;\s*|attribute vec2 TexCoord;\s*") @@ -190,27 +220,38 @@ pub fn convert(path: &Path, name: &str) -> Result { return Err("expected a .slangp preset".into()); } let p = ShaderPreset::try_parse(path, ShaderFeatures::empty())?; - if p.pass_count != 1 || p.passes.len() != 1 { - return Err("only single-pass presets are supported".into()); - } - if !p.textures.is_empty() { - return Err("LUT textures are not supported".into()); - } - let pass = &p.passes[0]; - if pass.meta.srgb_framebuffer || pass.meta.float_framebuffer || pass.meta.mipmap_input { - return Err("special framebuffer/mipmap formats are not supported".into()); + if p.passes.is_empty() || p.passes.len() > 8 || p.pass_count as usize != p.passes.len() { + return Err("one to eight shader passes are supported".into()); } - if pass.meta.scaling.valid && [&pass.meta.scaling.x, &pass.meta.scaling.y].iter().any(|s| !matches!((&s.scale_type, &s.factor), (ScaleType::Viewport, ScaleFactor::Float(f)) if *f == 1.0)) { - return Err("only default or viewport 1x output scaling is supported".into()); + if p.textures.len() > 8 { + return Err("at most eight LUT textures are supported".into()); } - let source = ShaderSource::load(&pass.path, p.features)?; - if !matches!( - source.format, - ImageFormat::Unknown | ImageFormat::R8G8B8A8Unorm - ) { - return Err("shader requires an unsupported output format".into()); + let legacy = p.passes.len()==1 && p.textures.is_empty() && (!p.passes[0].meta.scaling.valid || [&p.passes[0].meta.scaling.x,&p.passes[0].meta.scaling.y].iter().all(|s| matches!((&s.scale_type,&s.factor),(ScaleType::Viewport,ScaleFactor::Float(f)) if *f==1.0))); + let mut loaded = Vec::new(); + let mut parameters: Vec = Vec::new(); + for pass in &p.passes { + if pass.meta.srgb_framebuffer || pass.meta.float_framebuffer || pass.meta.mipmap_input { + return Err("special framebuffer/mipmap formats are not supported".into()); + } + let source = ShaderSource::load(&pass.path, p.features)?; + if !matches!( + source.format, + ImageFormat::Unknown | ImageFormat::R8G8B8A8Unorm + ) { + return Err("shader requires an unsupported output format".into()); + } + for v in source.parameters.values() { + let param = json!({"id":v.id.to_string(),"name":v.description,"default":v.initial,"min":v.minimum,"max":v.maximum,"step":v.step}); + if let Some(old) = parameters.iter().find(|old| old["id"] == param["id"]) { + if *old != param { + return Err("conflicting parameter definitions across passes".into()); + } + } else { + parameters.push(param); + } + } + loaded.push(source); } - let mut parameters: Vec = source.parameters.values().map(|v| json!({"id":v.id.to_string(),"name":v.description,"default":v.initial,"min":v.minimum,"max":v.maximum,"step":v.step})).collect(); parameters.sort_by_key(|v| v["id"].as_str().unwrap_or_default().to_string()); if parameters.len() > 8 { return Err("at most eight parameters are supported".into()); @@ -244,29 +285,174 @@ pub fn convert(path: &Path, name: &str) -> Result { return Err("invalid parameter range/default".into()); } } - let semantics = ShaderSemantics::create_pass_semantics::>(&p, 0)?; - let mut sources: Vec = Vec::new(); - for version in [GlslVersion::Glsl120, GlslVersion::Glsl100Es] { - let compiled = Glslang::compile(&source)?; - let mut target = GLSL::from_compilation(compiled)?; - target.reflect(0, &semantics)?; - let out = target.compile(version)?; - sources.push(adapt(&out.fragment, false, &semantics, ¶meters)?); - sources.push(adapt(&out.vertex, true, &semantics, ¶meters)?); + let mut passes = Vec::new(); + for (i, pass) in p.passes.iter().enumerate() { + let semantics = ShaderSemantics::create_pass_semantics::>(&p, i)?; + let mut sources = Vec::new(); + for version in [GlslVersion::Glsl120, GlslVersion::Glsl100Es] { + let compiled = Glslang::compile(&loaded[i])?; + let mut target = GLSL::from_compilation(compiled)?; + target.reflect(i, &semantics)?; + let out = target.compile(version)?; + sources.push(adapt( + &out.fragment, + false, + &semantics, + ¶meters, + i, + p.textures.len(), + legacy, + )?); + sources.push(adapt( + &out.vertex, + true, + &semantics, + ¶meters, + i, + p.textures.len(), + legacy, + )?); + } + if sources.iter().any(|s| s.len() > 65536) { + return Err("converted shader exceeds 64 KiB".into()); + } + let (x, y) = if pass.meta.scaling.valid { + ( + scaling(&pass.meta.scaling.x)?, + scaling(&pass.meta.scaling.y)?, + ) + } else { + let kind = if i + 1 == p.passes.len() { + "viewport" + } else { + "source" + }; + ( + json!({"type":kind,"value":1}), + json!({"type":kind,"value":1}), + ) + }; + passes.push(ConvertedPass {metadata:json!({"filter":filter(pass.meta.filter),"wrap":wrap(pass.meta.wrap_mode),"scaleX":x,"scaleY":y}),sources:sources.try_into().unwrap()}); + } + let mut textures = Vec::new(); + let mut pixels = 0u64; + for t in &p.textures { + if t.meta.mipmap || !identifier(t.meta.name.as_str()) { + return Err("unsupported LUT identifier/mipmap".into()); + } + // Bound compressed data and decoded allocation before invoking the image decoder. + if fs::metadata(&t.path)?.len() > 8 * 1024 * 1024 { + return Err("LUT file too large".into()); + } + let bytes = fs::read(&t.path)?; + pixels += png_pixels(&bytes)?; + if pixels > 8 * 1024 * 1024 { + return Err("LUT pixel budget exceeded".into()); + } + textures.push(ConvertedTexture { metadata:json!({"id":t.meta.name.as_str(),"filter":filter(t.meta.filter_mode),"wrap":wrap(t.meta.wrap_mode)}),bytes}); } - if sources.iter().any(|s| s.len() > 65536) { - return Err("converted shader exceeds 64 KiB".into()); + let mut metadata = json!({"format":if legacy {2}else{3},"name":name,"parameters":parameters}); + if legacy { + for key in ["filter", "wrap"] { + metadata[key] = passes[0].metadata[key].clone(); + } } - let wrap = match pass.meta.wrap_mode { + Ok(Converted { + metadata, + passes, + textures, + }) +} +fn filter(v: FilterMode) -> &'static str { + match v { + FilterMode::Linear => "linear", + FilterMode::Nearest => "nearest", + } +} +fn wrap(v: WrapMode) -> &'static str { + match v { WrapMode::ClampToBorder => "clampzero", WrapMode::ClampToEdge => "clamp", WrapMode::Repeat => "repeat", WrapMode::MirroredRepeat => "mirroredrepeat", + } +} +fn scaling(s: &Scaling) -> Result { + let value: f32 = s.factor.into(); + let kind = match s.scale_type { + ScaleType::Input => "source", + ScaleType::Original => "original", + ScaleType::Viewport => "viewport", + ScaleType::Absolute => "absolute", }; - Ok(Converted { - metadata: json!({"format":2,"name":name,"parameters":parameters,"filter": match pass.meta.filter { FilterMode::Linear => "linear", FilterMode::Nearest => "nearest" },"wrap":wrap}), - sources: sources.try_into().unwrap(), - }) + if !value.is_finite() + || (kind == "absolute" && (value < 1.0 || value > 8192.0 || value.fract() != 0.0)) + || (kind != "absolute" && !(1.0 / 64.0..=8.0).contains(&value)) + { + return Err("unsupported scaling range".into()); + } + Ok(json!({"type":kind,"value":value})) +} +fn png_pixels(bytes: &[u8]) -> Result { + if bytes.len() < 33 + || bytes.len() > 8 * 1024 * 1024 + || &bytes[..16] != b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR" + { + return Err("LUT must be a bounded PNG".into()); + } + let w = u32::from_be_bytes(bytes[16..20].try_into()?); + let h = u32::from_be_bytes(bytes[20..24].try_into()?); + if !(1..=2048).contains(&w) + || !(1..=2048).contains(&h) + || bytes[24] != 8 + || ![0, 2, 3, 4, 6].contains(&bytes[25]) + { + return Err("unsupported LUT dimensions/bit depth".into()); + } + Ok(w as u64 * h as u64) +} + +/// Descriptor and all referenced resources, with a deterministic closed basename namespace. +type Bundle = (Value, Vec<(String, Vec)>); +fn bundle(c: &Converted, stem: &str, force_graph: bool) -> Result { + let mut meta = c.metadata.clone(); + let mut files = Vec::new(); + let mut passes = Vec::new(); + let mut textures = Vec::new(); + for (i, p) in c.passes.iter().enumerate() { + let mut pass = p.metadata.clone(); + for ((key, suffix), source) in ["shader", "vertex", "esShader", "esVertex"] + .iter() + .zip(["f", "v", "ef", "ev"]) + .zip(&p.sources) + { + let name = if c.metadata["format"] == 2 && !force_graph { + format!("{stem}-{suffix}.glsl") + } else { + format!("{stem}-p{i}-{suffix}.glsl") + }; + pass[*key] = json!(name); + files.push((name, source.as_bytes().to_vec())); + } + passes.push(pass); + } + for (i, t) in c.textures.iter().enumerate() { + let mut tex = t.metadata.clone(); + let name = format!("{stem}-t{i}.png"); + tex["image"] = json!(name); + files.push((name, t.bytes.clone())); + textures.push(tex); + } + if force_graph || c.metadata["format"] == 3 { + meta["format"] = json!(3); + meta["passes"] = json!(passes); + meta["textures"] = json!(textures); + } else { + for key in ["shader", "vertex", "esShader", "esVertex"] { + meta[key] = passes[0][key].clone(); + } + } + Ok((meta, files)) } /// Validate both dialects and compile the current GPU's pair BEFORE publication. @@ -286,27 +472,19 @@ pub fn preflight(converted: &Converted, love: &Path) -> Result<()> { "function love.conf(t) t.identity='gen2-slang-preflight'; t.window.visible=false; t.window.width=32; t.window.height=32; t.modules.audio=false; t.modules.joystick=false end", )?; fs::write(dir.join("main.lua"), include_str!("validate.lua"))?; - for (i, source) in converted.sources.iter().enumerate() { - fs::write(dir.join(format!("{i}.glsl")), source)?; - } fs::write( - dir.join("settings.txt"), - format!( - "{} {}", - converted.metadata["filter"].as_str().unwrap(), - converted.metadata["wrap"].as_str().unwrap() - ), + dir.join("ShaderGraph.lua"), + include_str!("../../../src/render/ShaderGraph.lua"), )?; - // JSON parameters never become executable Lua. fs::write( - dir.join("params.txt"), - converted.metadata["parameters"] - .as_array() - .unwrap() - .iter() - .map(|p| format!("g2_p_{} {}\n", p["id"].as_str().unwrap(), p["default"])) - .collect::(), + dir.join("Json.lua"), + include_str!("../../../src/link/Json.lua"), )?; + let (meta, files) = bundle(converted, "probe", true)?; + for (name, bytes) in files { + fs::write(dir.join(name), bytes)?; + } + fs::write(dir.join("graph.json"), serde_json::to_vec(&meta)?)?; let output = Command::new(love).arg(&dir).output()?; if !output.status.success() { return Err(format!( @@ -327,40 +505,35 @@ pub fn preflight(converted: &Converted, love: &Path) -> Result<()> { pub fn publish(converted: &Converted, destination: &Path) -> Result { let mut hash = Sha256::new(); hash.update(converted.metadata.to_string()); - for s in &converted.sources { - hash.update((s.len() as u64).to_le_bytes()); - hash.update(s); + for p in &converted.passes { + hash.update(p.metadata.to_string()); + for source in &p.sources { + hash.update((source.len() as u64).to_le_bytes()); + hash.update(source); + } + } + for t in &converted.textures { + hash.update(t.metadata.to_string()); + hash.update((t.bytes.len() as u64).to_le_bytes()); + hash.update(&t.bytes); } let stem = format!("slang-{:x}", hash.finalize()); let stem = &stem[..38]; - let names: Vec<_> = ["-f.glsl", "-v.glsl", "-ef.glsl", "-ev.glsl"] - .iter() - .map(|s| format!("{stem}{s}")) - .collect(); - let mut meta = converted.metadata.clone(); - for (key, name) in ["shader", "vertex", "esShader", "esVertex"] - .iter() - .zip(&names) - { - meta[*key] = json!(name); - } - let descriptor = serde_json::to_string_pretty(&meta)?; + let (meta, mut files) = bundle(converted, stem, false)?; + let descriptor = serde_json::to_vec_pretty(&meta)?; if descriptor.len() > 16384 { return Err("descriptor exceeds 16 KiB".into()); } + files.push((format!("{stem}.json"), descriptor)); fs::create_dir_all(destination)?; let path = destination.join(format!("{stem}.json")); let mut created = Vec::new(); let result = (|| -> Result { - for (name, contents) in names - .iter() - .zip(&converted.sources) - .chain(std::iter::once((&format!("{stem}.json"), &descriptor))) - { + for (name, contents) in &files { let p = destination.join(name); let mut f = OpenOptions::new().write(true).create_new(true).open(&p)?; created.push(p); - f.write_all(contents.as_bytes())?; + f.write_all(contents)?; f.sync_all()?; } Ok(path) diff --git a/tools/slang-import/src/validate.lua b/tools/slang-import/src/validate.lua index 698200b1f..9597a92e7 100644 --- a/tools/slang-import/src/validate.lua +++ b/tools/slang-import/src/validate.lua @@ -1,55 +1,29 @@ +function love.errorhandler(message) + io.stderr:write(tostring(message) .. "\n") + return function() + return 1 + end +end function love.load() local ok, why = pcall(function() - local s = {} - for i = 0, 3 do - s[i + 1] = assert(love.filesystem.read(i .. ".glsl")) - end - for _, es in ipairs({ false, true }) do - local i = es and 3 or 1 - local valid, message = love.graphics.validateShader(es, s[i], s[i + 1]) - assert(valid, (es and "GLES: " or "GL: ") .. tostring(message)) - end - local renderer, version = love.graphics.getRendererInfo() - local es = (renderer .. version):find("OpenGL ES", 1, true) - local i = es and 3 or 1 - local shader = love.graphics.newShader(s[i], s[i + 1]) - local input = love.graphics.newCanvas(3, 5) - local filter, wrap = love.filesystem.read("settings.txt"):match("(%S+) (%S+)") - input:setFilter(filter, filter) - assert(input:setWrap(wrap, wrap), "unsupported preset texture wrapping") - local sizes = { 3, 5, 1 / 3, 1 / 5 } - for _, name in ipairs({ "SourceSize", "OriginalSize", "OutputSize", "FinalViewportSize" }) do - if shader:hasUniform("g2_" .. name) then - shader:send("g2_" .. name, sizes) - end + local Graph, Json = require("ShaderGraph"), require("Json") + local raw = Json.decode(assert(love.filesystem.read("graph.json"))) + local spec = assert(Graph.validate(raw)) + local values = {} + for _, p in ipairs(raw.parameters) do + values[p.id] = p.default end - if shader:hasUniform("g2_Source") then - shader:send("g2_Source", input) - end - for id, value in love.filesystem.read("params.txt"):gmatch("(%S+) (%S+)") do - if shader:hasUniform(id) then - shader:send(id, assert(tonumber(value))) - end - end - local output = love.graphics.newCanvas(4, 4) - love.graphics.setCanvas(output) - love.graphics.setShader(shader) - love.graphics.draw(input) - love.graphics.setShader() - love.graphics.setCanvas() - assert(output:newImageData()) -- force completion/readback - shader:release() - input:release() - output:release() + local graph, err = Graph.new(spec, function(name, limit) + local info = assert(love.filesystem.getInfo(name)) + assert(info.size <= limit, "oversized graph resource") + return assert(love.filesystem.read(name)) + end, love.graphics, values, love.filesystem.newFileData) + assert(graph, err) + graph:release() + print("GL+GLES graph validation and native GPU readback PASS") end) if not ok then io.stderr:write(tostring(why) .. "\n") end love.event.quit(ok and 0 or 1) end -function love.errorhandler(message) - io.stderr:write(tostring(message) .. "\n") - return function() - return 1 - end -end diff --git a/tools/slang-import/tests/import.rs b/tools/slang-import/tests/import.rs index 53f6dab50..deb586a9b 100644 --- a/tools/slang-import/tests/import.rs +++ b/tools/slang-import/tests/import.rs @@ -55,7 +55,7 @@ fn compiles_both_stages_and_dialects_with_includes_and_overrides() { assert_eq!(c.metadata["parameters"][1]["id"], "unused"); assert_eq!(c.metadata["filter"], "nearest"); assert_eq!(c.metadata["wrap"], "clamp"); - for pair in c.sources.as_chunks::<2>().0 { + for pair in c.passes[0].sources.as_chunks::<2>().0 { assert!(pair[0].contains("vec4 effect(")); assert!(pair[1].contains("vec4 position(")); assert!(pair[0].contains("g2_p_gain")); @@ -74,13 +74,13 @@ fn rejects_unsupported_pass_graph_formats_scaling_and_bad_metadata() { let t = Scratch::new(); fs::write(t.0.join("probe.png"), "").unwrap(); for (extra, error) in [ - ("shaders=2", "single-pass"), + ("shaders=9", "eight"), ("textures=lut\nlut=probe.png", "LUT"), ("float_framebuffer0=true", "framebuffer"), ("srgb_framebuffer0=true", "framebuffer"), ("mipmap_input0=true", "mipmap"), - ("scale_type0=source\nscale0=2.0", "scaling"), - ("scale_type0=absolute\nscale0=64", "scaling"), + ("scale_type0=source\nscale0=9.0", "scaling"), + ("scale_type0=absolute\nscale0=9000", "scaling"), ("parameters=gain\ngain=2.0", "parameter range"), ] { assert!( @@ -163,3 +163,89 @@ fn failed_cli_preflight_preserves_existing_destination() { "existing preset" ); } + +#[test] +fn graph_converts_source_original_earlier_pass_and_lut_in_both_dialects() { + let c = convert(&fixture("graph.slangp"), "GRAPH").unwrap(); + assert_eq!(c.metadata["format"], 3); + assert_eq!(c.passes.len(), 3); + assert_eq!(c.textures.len(), 1); + for i in [0, 2] { + let s = &c.passes[2].sources[i]; + for name in [ + "g2_Source", + "g2_Original", + "g2_Pass0", + "g2_Lut0", + "g2_SourceSize", + "g2_OriginalSize", + "g2_Pass0Size", + "g2_OutputSize", + "g2_FinalViewportSize", + ] { + assert!(s.contains(name), "{name}"); + } + } + assert_eq!(c.passes[1].metadata["scaleX"]["type"], "source"); + assert_eq!(c.passes[1].metadata["scaleX"]["value"], 2.0); + let t = Scratch::new(); + let path = publish(&c, &t.0).unwrap(); + let meta: serde_json::Value = serde_json::from_slice(&fs::read(path).unwrap()).unwrap(); + assert_eq!(fs::read_dir(&t.0).unwrap().count(), 14); + for p in meta["passes"].as_array().unwrap() { + for key in ["shader", "vertex", "esShader", "esVertex"] { + assert!(t.0.join(p[key].as_str().unwrap()).is_file()); + } + } + assert_eq!( + fs::read(t.0.join(meta["textures"][0]["image"].as_str().unwrap())).unwrap(), + c.textures[0].bytes + ); +} +#[test] +fn bounded_scaling_and_unsupported_lut_and_parameter_conflicts() { + let t = Scratch::new(); + for extra in [ + "scale_type0=source\nscale0=2", + "scale_type0=original\nscale0=0.5", + "scale_type0=absolute\nscale0=64", + ] { + let c = convert(&t.preset(extra), "SCALE").unwrap(); + assert_eq!(c.metadata["format"], 3); + } + let text = fs::read_to_string(fixture("graph.slangp")).unwrap(); + for name in [ + "graph-first.slang", + "graph-middle.slang", + "graph-last.slang", + "tint.png", + ] { + fs::copy(fixture(name), t.0.join(name)).unwrap(); + } + for bad in [ + text.replace("Tint_mipmap=false", "Tint_mipmap=true"), + text.replace("shader2=graph-last.slang", "shader2=bad.slang"), + ] { + let shader = fs::read_to_string(fixture("graph-last.slang")) + .unwrap() + .replace("PassOutput0", "PassOutput2"); + fs::write(t.0.join("bad.slang"), shader).unwrap(); + fs::write(t.0.join("bad.slangp"), bad).unwrap(); + assert!(convert(&t.0.join("bad.slangp"), "BAD").is_err()); + } + fs::write( + t.0.join("graph-middle.slang"), + fs::read_to_string(fixture("graph-first.slang")) + .unwrap() + .replace("0.5 0.0", "0.8 0.0"), + ) + .unwrap(); + fs::write(t.0.join("bad.slangp"), text).unwrap(); + assert!( + convert(&t.0.join("bad.slangp"), "BAD") + .err() + .unwrap() + .to_string() + .contains("conflicting") + ); +} From 5324b5c22c541454162dd284e283976c5ae8658c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:20:08 +0200 Subject: [PATCH 020/138] Add portable keyboard, gamepad and wheel input hooks --- .github/workflows/input-hook-contract.yml | 43 +++++++ docs/portable-input-hooks.md | 81 +++++++++++++ examples/portable_input/README.md | 17 +++ examples/portable_input/main.lua | 24 ++++ examples/portable_input/manifest.json | 18 +++ src/core/Game.lua | 81 +++++++++++-- src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 2 +- tests/engine/portable_input_hooks.lua | 32 ++++++ tests/engine/portable_input_routing.lua | 125 +++++++++++++++++++++ tests/input_hooks/cases.lua | 131 ++++++++++++++++++++++ tests/input_hooks/fixture.lua | 74 ++++++++++++ tests/input_hooks/worker.lua | 7 ++ tools/input-hook-contract-check/README.md | 16 +++ tools/input-hook-contract-check/check.py | 58 ++++++++++ 15 files changed, 702 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/input-hook-contract.yml create mode 100644 docs/portable-input-hooks.md create mode 100644 examples/portable_input/README.md create mode 100644 examples/portable_input/main.lua create mode 100644 examples/portable_input/manifest.json create mode 100644 tests/engine/portable_input_hooks.lua create mode 100644 tests/engine/portable_input_routing.lua create mode 100644 tests/input_hooks/cases.lua create mode 100644 tests/input_hooks/fixture.lua create mode 100644 tests/input_hooks/worker.lua create mode 100644 tools/input-hook-contract-check/README.md create mode 100644 tools/input-hook-contract-check/check.py diff --git a/.github/workflows/input-hook-contract.yml b/.github/workflows/input-hook-contract.yml new file mode 100644 index 000000000..fa6ad76cf --- /dev/null +++ b/.github/workflows/input-hook-contract.yml @@ -0,0 +1,43 @@ +name: portable input hook contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: input-hook-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native input and host routing + run: | + luajit tests/engine/portable_input_hooks.lua + luajit tests/engine/portable_input_routing.lua + - name: Compare both native engines through the same mod + run: | + mkdir -p /tmp/input-hook-contract + python3 tools/input-hook-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/input-hook-contract/api2-native.json + - name: Check unified callback and API-3 integration + run: | + python3 tools/input-hook-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/input-hook-contract/api2-unified.json + python3 tools/input-hook-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/input-hook-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: input-hook-contract + path: /tmp/input-hook-contract + if-no-files-found: ignore diff --git a/docs/portable-input-hooks.md b/docs/portable-input-hooks.md new file mode 100644 index 000000000..25ebda673 --- /dev/null +++ b/docs/portable-input-hooks.md @@ -0,0 +1,81 @@ +# Portable raw input hooks + +Gen2Recomped dispatches `input.key`, `input.gamepad`, and `input.wheel` +through `mod.hooks:wrap`, matching the native Gen1Recomp callbacks at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. A mod overlay can observe or +capture input without replacing engine methods or importing private modules. + +| Hook | Callback arguments after `next` | Payload | +| --- | --- | --- | +| `input.key` | `game, event` | `{ phase = "pressed" or "released", key = key }` | +| `input.gamepad` | `game, event` | Button: `{ phase = "pressed" or "released", joystick = joystick, button = button }` | +| `input.gamepad` | `game, event` | Axis: `{ phase = "axis", joystick = joystick, axis = axis, value = value }` | +| `input.wheel` | `game, dy` | Original numeric vertical delta, including fractions and zero | + +`game` and `joystick` retain their native identities. The joystick is an +opaque host handle, not a portable serializable record. Button events do not +include axis fields, and axis events do not include `button`. Keyboard +scancodes/repeat flags and horizontal wheel deltas are not part of this +contract. + +```lua +local mod = ... +local captured = false +mod.hooks:wrap("input.key", function(next, game, event) + if captured then return end + return next(game, event) +end) +``` + +Omitting `next` suppresses the entire normal Game callback, including its +hotkeys, top-state capture, Input updates and axis transitions. It also +suppresses wheel zoom. A returned boolean has no independent consume meaning: +calling `next` and then returning `true` or `false` has already forwarded the +event. `next()` forwards the current hook arguments. As in Gen1Recomp, +`next(game, replacement)` changes what downstream wrappers see; the native +body still acts on the original callback arguments. This is not a synthetic +input API. + +Releases can be captured too. If a mod forwards a press, it must also let the +matching release through or deliberately manage its capture lifecycle; +otherwise the native held state remains set. Prefer capturing complete +press/release pairs. Activating an overlay between a forwarded press and its +release needs particular care. Unsubscribing removes callbacks, not held state. + +Hooks run at the live Game boundary. Launcher/importer/editor routing and +accelerometer filtering outside that boundary retain their existing behavior. +Within Gen2Recomped, existing native routing is preserved: emergency hotkeys +precede top-state key capture, releases update Input before capture observers, +and the free camera keeps its existing wheel behavior (including zero delta). +Gen1-specific gamepad polling/watchdog bookkeeping is still owned by Gen1; +this contract does not promise identical internal input algorithms. + +When no hook chain is installed, calls bypass the hook bus and allocate no +hook payload or continuation closure. Existing bus priority, error handling, unsubscribe and +owner cleanup apply. Wrappers that fail before `next` are skipped; a failure +after forwarding does not replay native side effects; native errors propagate. + +The hooks work with native API 1/2, unified API 2 and API 3. The central +callback and capability catalogs include them, so +`mod:supports("hooks", name)` reports availability before the mod entry runs. +This change builds on the common API-3 entry feature (PR #102). + +## Verification + +```sh +luajit tests/engine/portable_input_hooks.lua +luajit tests/engine/portable_input_routing.lua +python3 tools/input-hook-contract-check/check.py --gen1 /path/to/gen1recomp +python3 tools/input-hook-contract-check/check.py --gen1 /path/to/gen1recomp --format unified +python3 tools/input-hook-contract-check/check.py --gen1 /path/to/gen1recomp --api 3 --format unified +``` + +The ROM-free checker loads the same committed example entry chunk through +each engine's real mod loader and invokes its native Game callbacks with the +real Input singleton. It covers Red, Gold, FireRed and Emerald, checking raw +payloads/identity, forwarded and suppressed input, held releases, fractional +and zero wheel values, continuation rewrites, callback errors and unsubscribe. +It compares input-state journals across engines and refuses a dirty or +unexpected Gen1 reference. It does not replace Gen1 callback implementations. +The Gen2 routing suite additionally covers every supported host version, +menu capture, engine hotkeys, touch bookkeeping and free-camera zoom. diff --git a/examples/portable_input/README.md b/examples/portable_input/README.md new file mode 100644 index 000000000..06a051d02 --- /dev/null +++ b/examples/portable_input/README.md @@ -0,0 +1,17 @@ +# Portable input overlay example + +Copy this directory into `mods/`. The same `main.lua` runs on Gen1Recomp and +Gen2Recomped. `exports.events` journals the three raw input hooks; +`exports.capture(true)` captures complete input events and `capture(false)` +forwards them. `exports.stop()` removes the subscriptions. The exported `api` +lets the headless contract runner add further public wrappers for error and +priority checks. + +Keep capture paired across presses and releases. Forwarding a press and +swallowing its release leaves that native input held. The example starts +with capture disabled and leaves native input active. + +The manifest uses API 2 for compatibility with current standalone hosts. The +contract checker also loads this exact entry chunk with unified API-2 and +API-3 manifests when those host features are installed. See +[the hook contract](../../docs/portable-input-hooks.md). diff --git a/examples/portable_input/main.lua b/examples/portable_input/main.lua new file mode 100644 index 000000000..de8a7d2ca --- /dev/null +++ b/examples/portable_input/main.lua @@ -0,0 +1,24 @@ +-- The same entry chunk runs on both engines; no private engine imports. +local mod = ... +local events, captured = {}, false +mod.exports.events = events +mod.exports.api = mod +function mod.exports.capture(value) + captured = value +end +local off = {} +for _, name in ipairs({ "input.key", "input.gamepad", "input.wheel" }) do + off[#off + 1] = mod.hooks:wrap(name, function(next, game, event) + events[#events + 1] = { name = name, game = game, event = event } + -- An overlay owns both press and release while capture is active. + if captured then + return "captured" + end + return next(game, event) + end) +end +function mod.exports.stop() + for _, unsubscribe in ipairs(off) do + unsubscribe() + end +end diff --git a/examples/portable_input/manifest.json b/examples/portable_input/manifest.json new file mode 100644 index 000000000..914b02d7c --- /dev/null +++ b/examples/portable_input/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "portable_input", + "name": "Portable Input Hooks", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "games": [ + "all" + ], + "generations": [ + 1, + 2, + 3, + 4, + 5 + ], + "affects_link": false +} diff --git a/src/core/Game.lua b/src/core/Game.lua index 9dc07ce9d..54c8ed21c 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1192,7 +1192,7 @@ function Game:zoomStep(delta) end end -function Game:wheelmoved(_, dy) +local function wheelmovedBody(self, dy) -- THE WHEEL ZOOMS THE FREE CAMERA when one owns the frame, and the -- window otherwise. -- @@ -1213,6 +1213,17 @@ function Game:wheelmoved(_, dy) end end +function Game:wheelmoved(_, dy) + if not ModRuntime.wantsHook("input.wheel") then + return wheelmovedBody(self, dy) + end + -- Keep the native callback arguments even if a wrapper rewrites next(...), + -- matching Gen1. Omitting next suppresses the complete native body. + return ModRuntime.call("input.wheel", function() + return wheelmovedBody(self, dy) + end, self, dy) +end + -- WHEN THE STACK IS EMPTIED AND NOBODY PUSHES ANYTHING BACK. -- -- Traced from play, straight out of the log: @@ -1344,7 +1355,7 @@ function Game:cycleGen4CameraTilt() return true end -function Game:keypressed(key) +local function keypressedBody(self, key) -- ...ahead of the delegation below, which is unconditional. if key == "f9" then return self:unstick() end if key == "f11" then return self:toggleFrameProfile() end @@ -1477,6 +1488,17 @@ function Game:keypressed(key) Input:keypressed(key) end +function Game:keypressed(key) + if not ModRuntime.wantsHook("input.key") then + return keypressedBody(self, key) + end + -- Keep the native callback arguments even if a wrapper rewrites next(...), + -- matching Gen1. Omitting next suppresses the complete native body. + return ModRuntime.call("input.key", function() + return keypressedBody(self, key) + end, self, { phase = "pressed", key = key }) +end + function Game:textinput(text) local top = self.stack and self.stack:top() if top and top.onTextInput then @@ -1504,19 +1526,31 @@ function Game:returnToLauncher() require("src.core.HostShell").toLauncher() end --- Releases reach Input even while a top state captures raw input: a +-- Unless an input.key wrapper swallows the event, releases reach Input +-- even while a top state captures raw input: a -- swallowed key-up would strand a held-state flag for a key Input saw go -- down before the capture armed (the stuck-flag hazard Input:reset -- exists for). The top state only OBSERVES the release afterwards, -- unlike onKeyPressed above which owns the press, so BindingsMenu can -- commit a capture on the key-up (#589). -function Game:keyreleased(key) +local function keyreleasedBody(self, key) Input:keyreleased(key) local top = self.stack and self.stack:top() if top and top.onKeyReleased then top:onKeyReleased(key) end end -function Game:gamepadpressed(joystick, button) +function Game:keyreleased(key) + if not ModRuntime.wantsHook("input.key") then + return keyreleasedBody(self, key) + end + -- Keep the native callback arguments even if a wrapper rewrites next(...), + -- matching Gen1. Omitting next suppresses the complete native body. + return ModRuntime.call("input.key", function() + return keyreleasedBody(self, key) + end, self, { phase = "released", key = key }) +end + +local function gamepadpressedBody(self, joystick, button) -- a controller is being used: the touch overlay steps aside until the -- next screen touch (mobile only; a no-op elsewhere) TouchControls:noteGamepad() @@ -1529,14 +1563,36 @@ function Game:gamepadpressed(joystick, button) Input:gamepadpressed(joystick, button) end -function Game:gamepadreleased(joystick, button) +function Game:gamepadpressed(joystick, button) + if not ModRuntime.wantsHook("input.gamepad") then + return gamepadpressedBody(self, joystick, button) + end + -- Keep the native callback arguments even if a wrapper rewrites next(...), + -- matching Gen1. Omitting next suppresses the complete native body. + return ModRuntime.call("input.gamepad", function() + return gamepadpressedBody(self, joystick, button) + end, self, { phase = "pressed", joystick = joystick, button = button }) +end + +local function gamepadreleasedBody(self, joystick, button) -- same observe-after-Input contract as Game:keyreleased (#589) Input:gamepadreleased(joystick, button) local top = self.stack and self.stack:top() if top and top.onGamepadReleased then top:onGamepadReleased(button) end end -function Game:gamepadaxis(joystick, axis, value) +function Game:gamepadreleased(joystick, button) + if not ModRuntime.wantsHook("input.gamepad") then + return gamepadreleasedBody(self, joystick, button) + end + -- Keep the native callback arguments even if a wrapper rewrites next(...), + -- matching Gen1. Omitting next suppresses the complete native body. + return ModRuntime.call("input.gamepad", function() + return gamepadreleasedBody(self, joystick, button) + end, self, { phase = "released", joystick = joystick, button = button }) +end + +local function gamepadaxisBody(self, joystick, axis, value) -- past-deadzone only, so resting-stick drift can't hide the overlay if math.abs(value) > 0.5 then TouchControls:noteGamepad() end -- the triggers and the right stick as bindable pseudo-buttons (Input @@ -1559,6 +1615,17 @@ function Game:gamepadaxis(joystick, axis, value) Input:gamepadaxis(joystick, axis, value) end +function Game:gamepadaxis(joystick, axis, value) + if not ModRuntime.wantsHook("input.gamepad") then + return gamepadaxisBody(self, joystick, axis, value) + end + -- Keep the native callback arguments even if a wrapper rewrites next(...), + -- matching Gen1. Omitting next suppresses the complete native body. + return ModRuntime.call("input.gamepad", function() + return gamepadaxisBody(self, joystick, axis, value) + end, self, { phase = "axis", joystick = joystick, axis = axis, value = value }) +end + -- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the -- generic joystick path anyway: any sensor-style device that still reaches us -- has gravity pinning an axis past the deadzone, which would hide the touch diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 32da142d7..cdadbbde8 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -18,7 +18,7 @@ add("hooks", "12345", [[ battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award battle.low_health_alarm battle.run battle.turn_order catch.rate encounter.fishing encounter.roll encounter.species evolution.check exp.gain - fieldmove.eligibility input.step link.fingerprint map.palette movement.collision + fieldmove.eligibility input.key input.gamepad input.wheel input.step link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite render.compose render.hud render.letterbox render.zones save.new_game save.write script.command trainer.party transition.style ui.list_menu ui.options.rows diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 5e6a6195d..5f54085eb 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -19,7 +19,7 @@ C.hooks = names([[ battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility - input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed + input.key input.gamepad input.wheel input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite render.compose render.hud render.letterbox render.zones save.new_game save.write script.command trainer.party transition.style ui.list_menu ui.naming.grid ui.options.rows ui.party.submenu ui.pc.items ui.start_menu.items diff --git a/tests/engine/portable_input_hooks.lua b/tests/engine/portable_input_hooks.lua new file mode 100644 index 000000000..31c6113cb --- /dev/null +++ b/tests/engine/portable_input_hooks.lua @@ -0,0 +1,32 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/input_hooks/fixture.lua") +local f = Fixture.new(".", "gen2", "red", 2, "native") +dofile("tests/input_hooks/cases.lua").run(f) +f.close() +-- The no-mod path bypasses Runtime.call altogether for all three hooks. +local Runtime = require("src.mods.Runtime") +local none = Fixture.new(".", "gen2", "red", 2, "native", true) +for _, name in ipairs({ "input.key", "input.gamepad", "input.wheel" }) do + T.eq(Runtime.wantsHook(name), false, "no subscriber: " .. name) +end +local call = Runtime.call +Runtime.call = function() + error("unsubscribed input entered hook bus") +end +none.game:keypressed("up") +T.eq(none.Input:isDown("up"), true, "no-mod native key press") +none.game:keyreleased("up") +T.eq(none.Input:isDown("up"), false, "no-mod native key release") +none.game:gamepadpressed(none.pad, "a") +T.eq(none.Input:isDown("a"), true, "no-mod native pad press") +none.game:gamepadreleased(none.pad, "a") +T.eq(none.Input:isDown("a"), false, "no-mod native pad release") +none.game:gamepadaxis(none.pad, "leftx", 0.9) +T.eq(none.Input:isDown("right"), true, "no-mod native axis") +none.game:gamepadaxis(none.pad, "leftx", 0) +none.game:wheelmoved(0, 1) +T.same(none.zoom, { 1 }, "no-mod native wheel") +Runtime.call = call +none.close() +T.finish("portable input hooks") diff --git a/tests/engine/portable_input_routing.lua b/tests/engine/portable_input_routing.lua new file mode 100644 index 000000000..2db2b0183 --- /dev/null +++ b/tests/engine/portable_input_routing.lua @@ -0,0 +1,125 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/input_hooks/fixture.lua") +local Touch = require("src.core.TouchControls") +local Version = require("src.core.GameVersion") +local oldVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + local f = Fixture.new(".", "gen2", version, 2, "native") + local g, I, api, e = f.game, f.Input, f.public, f.exports + local calls, top = {}, {} + g.stack.top = function() + return top + end + top.onKeyPressed = function(_, key) + calls[#calls + 1] = "key:" .. key + end + top.onKeyReleased = function(_, key) + T.eq(I:isDown("up"), false, "release reaches Input before capture observer: " .. version) + calls[#calls + 1] = "release:" .. key + end + local noted, note = 0, Touch.noteGamepad + Touch.noteGamepad = function() + noted = noted + 1 + end + top.onGamepadPressed = function(_, button) + calls[#calls + 1] = "pad:" .. button + end + top.onGamepadReleased = function(_, button) + if button == "a" then + T.eq(I:isDown("a"), false, "pad release before observer") + end + calls[#calls + 1] = "pad-release:" .. button + end + local unstick = 0 + g.unstick = function() + unstick = unstick + 1 + return "unstuck" + end + local held = {} + for key, value in pairs(I.axisHeld or {}) do + held[key] = value + end + e.capture(true) + g:keypressed("f9") + g:keypressed("up") + g:keyreleased("up") + g:gamepadpressed(f.pad, "a") + g:gamepadreleased(f.pad, "a") + g:gamepadaxis(f.pad, "triggerleft", 1) + T.eq(unstick, 0, "hook precedes engine emergency hotkey: " .. version) + T.eq(#calls, 0, "hook precedes every capture callback") + T.eq(noted, 0, "suppression precedes touch overlay bookkeeping") + T.same(I.axisHeld or {}, held, "suppression does not create pseudo-button state") + T.eq(#I.pressQueue, 0, "suppressed input creates no queued press") + e.capture(false) + T.eq(g:keypressed("f9"), "unstuck", "native hotkey result survives hooks") + T.eq(unstick, 1, "hotkey runs exactly once") + T.eq(#calls, 0, "native hotkey retains priority over top capture") + g:keypressed("up") + T.eq(I:isDown("up"), false, "top key capture keeps native priority over Input") + T.same(calls, { "key:up" }, "exactly one captured key") + I:keypressed("up") + g:keyreleased("up") + g:gamepadpressed(f.pad, "a") + T.eq(I:isDown("a"), false, "top pad capture does not fall through") + I:gamepadpressed(f.pad, "a") + g:gamepadreleased(f.pad, "a") + g:gamepadaxis(f.pad, "triggerleft", 1) + T.eq(calls[#calls], "pad:triggerleft", "swallowed axis did not consume later capture edge") + g:gamepadaxis(f.pad, "triggerleft", 0) + T.eq(calls[#calls], "pad-release:triggerleft", "axis release still reaches capture observer") + Touch.noteGamepad = note + -- Standard hook ordering, next() argument forwarding, and return values. + local order = {} + local a = api.hooks:wrap("input.wheel", function(next, game, dy) + order[#order + 1] = "outer" + T.eq(game, g, "original Game in outer wrapper") + T.eq(dy, 2.5, "fractional raw wheel delta") + next(game, -100) + order[#order + 1] = "after" + return false + end, 30) + local b = api.hooks:wrap("input.wheel", function(next, game, dy) + order[#order + 1] = "inner" + T.eq(dy, -100, "rewritten delta visible downstream") + return next() + end, 20) + local n = #f.zoom + T.eq(g:wheelmoved(777, 2.5), false, "outer hook return value preserved") + T.same(order, { "outer", "inner", "after" }, "descending priority and continuation") + T.eq(f.zoom[n + 1], 1, "native wheel still uses original positive delta") + a() + b() + -- Free-camera behavior (including its existing zero-delta step) is native. + local free = {} + g.zoomView = function() + return { + zoomBy = function(_, delta) + free[#free + 1] = delta + end, + } + end + for _, dy in ipairs({ 2.5, -1, 0 }) do + e.capture(true) + g:wheelmoved(0, dy) + e.capture(false) + g:wheelmoved(0, dy) + end + T.same(free, { -1, 1, 1 }, "free camera preserves native sign and zero behavior") + -- Nil and false are ordinary wrapper results; only omitting next suppresses. + local off = api.hooks:wrap("input.key", function() + return nil + end, 100) + n = #calls + T.eq(g:keypressed("up"), nil, "nil-result suppression") + T.eq(#calls, n, "nil-result wrapper prevents native callback") + off() + f.result.loader.hooks:removeOwner("portable_input") + n = #e.events + g:keypressed("up") + T.eq(#e.events, n, "owner cleanup removes public registrations") + f.close() +end +Version.set(oldVersion) +T.finish("portable input routing") diff --git a/tests/input_hooks/cases.lua b/tests/input_hooks/cases.lua new file mode 100644 index 000000000..006b8694c --- /dev/null +++ b/tests/input_hooks/cases.lua @@ -0,0 +1,131 @@ +local M = {} +function M.run(f) + local T, g, I, e = f.T, f.game, f.Input, f.exports + local rows = {} + local function state(label) + rows[#rows + 1] = { + label = label, + up = I:isDown("up"), + a = I:isDown("a"), + right = I:isDown("right"), + zoom = #f.zoom, + events = #e.events, + } + end + local function last(name, expected) + local seen = e.events[#e.events] + T.eq(seen.name, name, "hook name") + T.eq(seen.game, g, "original Game identity") + T.same(seen.event, expected, "exact raw payload") + end + g:keypressed("up") + last("input.key", { phase = "pressed", key = "up" }) + T.eq(I:isDown("up"), true, "forwarded keyboard press") + state("key-down") + e.capture(true) + T.eq(g:keyreleased("up"), "captured", "release may be swallowed") + last("input.key", { phase = "released", key = "up" }) + T.eq(I:isDown("up"), true, "swallowed release does not clear held input") + state("key-release-captured") + e.capture(false) + g:keyreleased("up") + T.eq(I:isDown("up"), false, "forwarded release clears held input") + e.capture(true) + g:keypressed("up") + T.eq(I:isDown("up"), false, "swallowed press never reaches Input") + state("key-press-captured") + e.capture(false) + g:gamepadpressed(f.pad, "a") + last("input.gamepad", { phase = "pressed", joystick = f.pad, button = "a" }) + T.eq(e.events[#e.events].event.joystick, f.pad, "opaque joystick identity") + T.eq(I:isDown("a"), true, "forwarded pad press") + e.capture(true) + g:gamepadreleased(f.pad, "a") + last("input.gamepad", { phase = "released", joystick = f.pad, button = "a" }) + T.eq(I:isDown("a"), true, "swallowed pad release retains held input") + state("pad-release-captured") + e.capture(false) + g:gamepadreleased(f.pad, "a") + T.eq(I:isDown("a"), false, "forwarded pad release clears held input") + e.capture(true) + g:gamepadpressed(f.pad, "a") + T.eq(I:isDown("a"), false, "swallowed pad press") + g:gamepadaxis(f.pad, "leftx", 0.875) + last("input.gamepad", { phase = "axis", joystick = f.pad, axis = "leftx", value = 0.875 }) + T.eq(I:isDown("right"), false, "swallowed axis does not create direction") + state("axis-captured") + e.capture(false) + g:gamepadaxis(f.pad, "leftx", 0.875) + T.eq(I:isDown("right"), true, "native axis produces direction") + state("axis-forwarded") + g:gamepadaxis(f.pad, "leftx", 0) + last("input.gamepad", { phase = "axis", joystick = f.pad, axis = "leftx", value = 0 }) + T.eq(I:isDown("right"), false, "zero axis releases direction") + for _, dy in ipairs({ -1, 2.5, 0 }) do + local n = #f.zoom + e.capture(true) + T.eq(g:wheelmoved(99, dy), "captured", "wheel suppression result") + last("input.wheel", dy) + T.eq(#f.zoom, n, "captured wheel does not zoom") + e.capture(false) + g:wheelmoved(99, dy) + last("input.wheel", dy) + T.eq(#f.zoom, n + (dy == 0 and 0 or 1), "native wheel delta") + end + state("wheel-forwarded") + local api = f.public + if api.supports then + for _, name in ipairs({ "input.key", "input.gamepad", "input.wheel" }) do + T.eq(api:supports("hooks", name), true, "public capability " .. name) + end + T.eq(api:supports("hooks", "input.not_a_hook"), false, "unknown hook still declines") + end + -- Rewriting next's arguments changes downstream observations, not the + -- original event captured by the native Game body (the Gen1 contract). + local off = api.hooks:wrap("input.key", function(next, game, ev) + return next(game, { phase = ev.phase, key = "down" }) + end, 100) + g:keypressed("up") + T.eq(e.events[#e.events].event.key, "down", "downstream sees replacement payload") + T.eq(I:isDown("up"), true, "native callback retains original key") + T.eq(I:isDown("down"), false, "replacement does not synthesize another key") + off() + g:keyreleased("up") + -- Errors before/after next must not lose or replay a native event. + for _, after in ipairs({ false, true }) do + local offError = api.hooks:wrap("input.wheel", function(next) + if after then + next() + end + error("intentional input contract failure") + end, 100) + local n = #f.zoom + g:wheelmoved(0, 1) + T.eq(#f.zoom, n + 1, "wrapper error preserves exactly one native call") + offError() + end + local original = g.zoomStep + local calls = 0 + g.zoomStep = function() + calls = calls + 1 + error("native zoom failure") + end + T.raises(function() + g:wheelmoved(0, 1) + end, "native zoom failure", "native failure propagates") + T.eq(calls, 1, "native failure is never retried") + g.zoomStep = original + local n = #e.events + e.stop() + g:keypressed("up") + g:keyreleased("up") + g:gamepadpressed(f.pad, "a") + g:gamepadreleased(f.pad, "a") + g:gamepadaxis(f.pad, "leftx", 0) + g:wheelmoved(0, 1) + T.eq(#e.events, n, "unsubscribe removes every fixture callback") + T.eq(I:isDown("up"), false, "empty chains preserve native release") + T.eq(I:isDown("a"), false, "empty chains preserve native pad release") + return rows +end +return M diff --git a/tests/input_hooks/fixture.lua b/tests/input_hooks/fixture.lua new file mode 100644 index 000000000..4f4f9a133 --- /dev/null +++ b/tests/input_hooks/fixture.lua @@ -0,0 +1,74 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod) + local T = require("tests.modkit") + require("src.core.GameVersion").set(version) + local Input = require("src.core.Input") + local name = backend == "gen1" + and (version == "gold" and "Game2" or (version == "firered" or version == "emerald") and "Game3" or "Game") + or "Game" + local Game = require("src.core." .. name) + local files = {} + for _, path in ipairs({ "main.lua", "manifest.json" }) do + local f = assert(io.open(root .. "/examples/portable_input/" .. path, "rb")) + files["mods/portable_input/" .. path] = f:read("*a") + f:close() + end + local Json = require("src.link.Json") + local manifest = assert(Json.decode(files["mods/portable_input/manifest.json"])) + manifest.api, manifest.callback_format = api, format + files["mods/portable_input/manifest.json"] = Json.encode(manifest) + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_input" }, { + data = T.fixtures.fresh(), + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + Input:init() + local f = { + T = T, + result = result, + Input = Input, + Game = Game, + version = version, + api = api, + public = result.loader.exports.portable_input and result.loader.exports.portable_input.api, + exports = result.loader.exports.portable_input, + } + local zoom = {} + local game = setmetatable({ + input = Input, + mods = result.loader, + data = result.data, + save = { options = {} }, + phase = "world", + stack = { + top = function() + return nil + end, + }, + zoomStep = function(_, delta) + zoom[#zoom + 1] = delta + end, + zoomView = function() + return nil + end, + }, { __index = Game }) + f.game, f.zoom = game, zoom + result.loader.game = game + f.pad = { + getName = function() + return "Contract Gamepad" + end, + isGamepadDown = function() + return false + end, + getGamepadAxis = function() + return 0 + end, + } + function f.close() + result.release() + end + return f +end +return M diff --git a/tests/input_hooks/worker.lua b/tests/input_hooks/worker.lua new file mode 100644 index 000000000..a892d9679 --- /dev/null +++ b/tests/input_hooks/worker.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local f = dofile(root .. "/tests/input_hooks/fixture.lua").new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/input_hooks/cases.lua").run(f) +f.close() +print("INPUT_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable input contract") diff --git a/tools/input-hook-contract-check/README.md b/tools/input-hook-contract-check/README.md new file mode 100644 index 000000000..c8e0c2195 --- /dev/null +++ b/tools/input-hook-contract-check/README.md @@ -0,0 +1,16 @@ +# Cross-engine input hook contract + +Run `python3 tools/input-hook-contract-check/check.py --gen1 /path/to/gen1recomp +--lua luajit --report /tmp/input-contract.json` from Gen2Recomped. + +The Gen1 checkout must be clean at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Both native loaders execute +`examples/portable_input/main.lua` unchanged, against the real Input singleton +and native Game/Game2/Game3 callbacks. Four host versions run in separate +processes. Each run asserts the contract and the checker compares resulting +held-state journals. No ROM or window is needed, and Gen1 is never patched. + +Use `--format unified` for API-2 callback-format integration and +`--api 3 --format unified` for the common API-3 entry. These modes require the +corresponding host features; unsupported manifests fail rather than being +silently downgraded. `--gen2` selects a separate integration checkout. diff --git a/tools/input-hook-contract-check/check.py b/tools/input-hook-contract-check/check.py new file mode 100644 index 000000000..e5a6fa67c --- /dev/null +++ b/tools/input-hook-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged input mod against both engines' native Game callbacks.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/input_hooks/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("INPUT_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("INPUT_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"input state mismatch: {version}" + print(f"PASS {version}: matching native input state across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_input/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From bbf00f2df2657da2d2b554a2b6cc40a5bc166339 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:47:07 +0200 Subject: [PATCH 021/138] Add portable mod.input tap, press and release controls --- .github/workflows/mod-input-contract.yml | 44 +++++ docs/portable-mod-input.md | 78 +++++++++ examples/portable_mod_input/README.md | 15 ++ examples/portable_mod_input/main.lua | 16 ++ examples/portable_mod_input/manifest.json | 10 ++ src/core/Input.lua | 12 ++ src/dev/HotReload.lua | 1 + src/mods/InputControl.lua | 140 ++++++++++++++++ src/mods/Loader.lua | 17 ++ tests/engine/mod_input_headless.lua | 64 ++++++++ tests/engine/mod_input_lifecycle.lua | 190 ++++++++++++++++++++++ tests/engine/portable_mod_input.lua | 6 + tests/mod_input/cases.lua | 130 +++++++++++++++ tests/mod_input/fixture.lua | 60 +++++++ tests/mod_input/worker.lua | 7 + tools/mod-input-contract-check/README.md | 19 +++ tools/mod-input-contract-check/check.py | 58 +++++++ 17 files changed, 867 insertions(+) create mode 100644 .github/workflows/mod-input-contract.yml create mode 100644 docs/portable-mod-input.md create mode 100644 examples/portable_mod_input/README.md create mode 100644 examples/portable_mod_input/main.lua create mode 100644 examples/portable_mod_input/manifest.json create mode 100644 src/mods/InputControl.lua create mode 100644 tests/engine/mod_input_headless.lua create mode 100644 tests/engine/mod_input_lifecycle.lua create mode 100644 tests/engine/portable_mod_input.lua create mode 100644 tests/mod_input/cases.lua create mode 100644 tests/mod_input/fixture.lua create mode 100644 tests/mod_input/worker.lua create mode 100644 tools/mod-input-contract-check/README.md create mode 100644 tools/mod-input-contract-check/check.py diff --git a/.github/workflows/mod-input-contract.yml b/.github/workflows/mod-input-contract.yml new file mode 100644 index 000000000..acbbad865 --- /dev/null +++ b/.github/workflows/mod-input-contract.yml @@ -0,0 +1,44 @@ +name: portable logical mod input contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: mod-input-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check logical input, headless loading and lifecycle + run: | + luajit tests/engine/portable_mod_input.lua + luajit tests/engine/mod_input_lifecycle.lua + luajit tests/engine/mod_input_headless.lua + - name: Compare both native engines through the same mod + run: | + mkdir -p /tmp/mod-input-contract + python3 tools/mod-input-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/mod-input-contract/api2-native.json + - name: Check unified callback and API-3 integration + run: | + python3 tools/mod-input-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/mod-input-contract/api2-unified.json + python3 tools/mod-input-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/mod-input-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: mod-input-contract + path: /tmp/mod-input-contract + if-no-files-found: ignore diff --git a/docs/portable-mod-input.md b/docs/portable-mod-input.md new file mode 100644 index 000000000..51c705fff --- /dev/null +++ b/docs/portable-mod-input.md @@ -0,0 +1,78 @@ +# Portable logical mod input + +`mod.input` implements Gen1Recomp's source-owned logical input contract in +Gen2Recomped, for native API 1/2, unified API 2 and API 3. The signatures are: + +| Method | Result | Effect | +| --- | --- | --- | +| `mod.input:tap(game, button)` | `nil` | Queue an edge for the next fixed step and immediately release this call's source | +| `mod.input:press(game, button)` | opaque token | Queue an edge and hold this call's source until released | +| `mod.input:release(token)` | boolean | Release this mod's token once; return `false` for missing, foreign, forged, retired or already released tokens | + +Buttons are exactly `up`, `down`, `left`, `right`, `a`, `b`, `start`, and +`select`, in lowercase. They are logical actions, independent of keyboard +aliases or user rebindings. Unknown buttons raise an error before input is +changed. The common contract does not include shoulder or DS face buttons. + +Wait for `game.ready` to obtain the live game; both `tap` and `press` take +that explicit handle. Constructing or reading `mod.input` does not create a +Game/Input instance, register callbacks, or require graphics. + +```lua +local mod = ... +mod.events:on("game.ready", function(event) + local game = event.game + mod.input:tap(game, "a") + local token = mod.input:press(game, "right") + -- Later, when this mod's action ends: + mod.input:release(token) +end) +``` + +Keep tokens in memory; they carry no editable fields and cannot be copied or +serialized. Each press owns a separate source. Releasing one token preserves +other tokens for the same button, other mods, keyboard/gamepad input and +virtual controls. Multiple taps before the same fixed step coalesce into that +step's single `wasPressed` edge. A tap's own source never remains held, but +another source can still hold the button. + +The normal Input queue determines edge lifetime. Releasing a token or +cleaning up a mod releases its held source; it does not undo an edge already +queued. A press followed by a release before the next step still yields one +edge and no held state. An Input reset also clears queued edges. + +Gen2Recomped retires tokens whenever their Input instance resets, including +focus/visibility changes, controller removal and input initialization. The +handle remains usable after recovery. Rollback retires the failing mod's +handle; hot reload, a new loader session and launcher teardown retire old +handles. A retired handle refuses new taps/presses. Tokens cannot release +sources belonging to a replacement session, even for the same mod ID. +Outstanding holds remain registered for teardown even if the caller loses +its token or loader references. + +Logical input goes directly to Input's per-source bookkeeping. It does not +synthesize OS events or dispatch `input.key`, `input.gamepad`, `input.wheel` +or engine display hotkeys. A raw-input hook can therefore request a logical +button without recursively entering itself. + +## Verification + +The reference is unmodified Gen1Recomp +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The checker loads the same example +entry chunk through both native mod loaders, emits `game.ready`, and checks +the real Input singleton's held/edge state on Red, Gold, FireRed and Emerald. +It tests the shared contract; the Gen2 lifecycle suite separately verifies +Gen2's reset and retired-handle behavior. + +```sh +luajit tests/engine/portable_mod_input.lua +luajit tests/engine/mod_input_lifecycle.lua +luajit tests/engine/mod_input_headless.lua +python3 tools/mod-input-contract-check/check.py --gen1 /path/to/gen1recomp +python3 tools/mod-input-contract-check/check.py --gen1 /path/to/gen1recomp --format unified +python3 tools/mod-input-contract-check/check.py --gen1 /path/to/gen1recomp --api 3 --format unified +``` + +The lifecycle tests cover all supported Gen2Recomped host versions under +API 1, native/unified API 2 and API 3. The checks are ROM-free and headless; +they do not measure OS event delivery or physical controller hardware. diff --git a/examples/portable_mod_input/README.md b/examples/portable_mod_input/README.md new file mode 100644 index 000000000..fe3001d71 --- /dev/null +++ b/examples/portable_mod_input/README.md @@ -0,0 +1,15 @@ +# Portable logical input example + +Copy this directory into `mods/`. The unchanged entry chunk runs on both +Gen1Recomp and Gen2Recomped. It obtains the Game through `game.ready` and +exports `tap(button)`, `press(button)` and `release(token)` helpers, together +with the underlying public `input` handle for contract tests. It does not +send input until a helper is called. + +`press` returns an opaque token which must be released by the same mod. +Available buttons are `up`, `down`, `left`, `right`, `a`, `b`, `start`, and +`select`. Do not store tokens in saves. See +[the contract and lifecycle rules](../../docs/portable-mod-input.md). + +The manifest uses API 2. The contract runner also exercises the exact same +entry chunk with unified API-2 and API-3 manifests. diff --git a/examples/portable_mod_input/main.lua b/examples/portable_mod_input/main.lua new file mode 100644 index 000000000..44119e6cb --- /dev/null +++ b/examples/portable_mod_input/main.lua @@ -0,0 +1,16 @@ +-- Logical buttons, not keyboard aliases; unchanged across both engines. +local mod = ... +local game +mod.exports.input = mod.input +mod.events:on("game.ready", function(event) + game = event.game +end) +function mod.exports.tap(button) + return mod.input:tap(game, button) +end +function mod.exports.press(button) + return mod.input:press(game, button) +end +function mod.exports.release(token) + return mod.input:release(token) +end diff --git a/examples/portable_mod_input/manifest.json b/examples/portable_mod_input/manifest.json new file mode 100644 index 000000000..69112779f --- /dev/null +++ b/examples/portable_mod_input/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_mod_input", + "name": "Portable Logical Input", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3, 4, 5], + "affects_link": false +} diff --git a/src/core/Input.lua b/src/core/Input.lua index e55bf0cbf..f808bbdd8 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -8,6 +8,7 @@ -- table at call time keeps that entirely inside GamepadMap. local GamepadMap = require("src.core.GamepadMap") local GameVersion = require("src.core.GameVersion") +local InputControl = require("src.mods.InputControl") local Input = {} @@ -144,6 +145,7 @@ end -- that would have cleared a held direction. Called from Game on those -- transitions so a stuck flag can't outlive them. function Input:reset() + InputControl.forget(self) self.state = {} self.pressQueue = {} self.pressed = {} @@ -237,6 +239,16 @@ function Input:overlayReleased(btn) release(self, btn, "touch:" .. btn) end +-- Internal source-aware entry points for mod.input. Each token owns a +-- separate source, so releasing it cannot release physical/other mod input. +function Input:sourcePress(button, source) + press(self, button, source) +end + +function Input:sourceRelease(button, source) + release(self, button, source) +end + function Input:gamepadpressed(joystick, button) local btn = self.padBindings[button] if btn then diff --git a/src/dev/HotReload.lua b/src/dev/HotReload.lua index 0a36309fb..37aee8ea7 100644 --- a/src/dev/HotReload.lua +++ b/src/dev/HotReload.lua @@ -29,6 +29,7 @@ function HotReload.run(game, opts) local Assets = require("src.render.Assets") local Runtime = require("src.mods.Runtime") local Logger = require("src.core.Logger") + if game.mods and game.mods._closeModInput then game.mods:_closeModInput() end local data = game.data if data and data.reloadGenerated then data:reloadGenerated() end local loader = Loader.new(opts and { fs = opts.fs, dev = opts.dev } or nil) diff --git a/src/mods/InputControl.lua b/src/mods/InputControl.lua new file mode 100644 index 000000000..03eea1a79 --- /dev/null +++ b/src/mods/InputControl.lua @@ -0,0 +1,140 @@ +-- Per-loader, per-mod ownership for logical GB input. No Game/Input import: +-- handles may be constructed at the title screen or in a headless loader. +local Control = {} +Control.__index = Control +local sessions = setmetatable({}, { __mode = "k" }) +-- Keep sessions with outstanding holds alive until release/reset/teardown. +local holding = {} +local serial = 0 +local BUTTONS = { + up = true, + down = true, + left = true, + right = true, + a = true, + b = true, + start = true, + select = true, +} + +local function idle(session) + for _, bucket in pairs(session.owners) do + if next(bucket.tokens) then + return + end + end + holding[session] = nil +end + +function Control.new() + serial = serial + 1 + local self = setmetatable({ serial = serial, owners = {} }, Control) + sessions[self] = true + return self +end + +function Control:release(owner) + if owner == nil then + for id in pairs(self.owners) do + self:release(id) + end + return + end + local bucket = self.owners[owner] + if not bucket then + return + end + for token, record in pairs(bucket.tokens) do + bucket.tokens[token] = nil + record.input:sourceRelease(record.button, record.source) + end + idle(self) +end + +function Control:retire(owner) + local bucket = self.owners[owner] + if not bucket then + return + end + self:release(owner) + bucket.retired = true + self.owners[owner] = nil +end + +function Control:close() + for owner in pairs(self.owners) do + self:retire(owner) + end + self.closed = true + sessions[self] = nil + holding[self] = nil +end + +function Control:api(owner) + assert(not self.closed, "mod.input is no longer active") + self:retire(owner) + local bucket = { tokens = {}, seq = 0 } + self.owners[owner] = bucket + local function sourceFor(game, button) + assert(not self.closed and not bucket.retired, "mod.input is no longer active") + local input = type(game) == "table" and game.input + assert( + type(input) == "table" + and type(input.sourcePress) == "function" + and type(input.sourceRelease) == "function", + "mod.input needs the live game (see game.ready)" + ) + assert(BUTTONS[button], "unknown GB button: " .. tostring(button)) + bucket.seq = bucket.seq + 1 + -- A retired loader's sources cannot alias those of its replacement. + return input, ("mod:%s:%d:%d"):format(owner, self.serial, bucket.seq) + end + return { + tap = function(_, game, button) + local input, source = sourceFor(game, button) + input:sourcePress(button, source) + input:sourceRelease(button, source) + end, + press = function(_, game, button) + local input, source = sourceFor(game, button) + input:sourcePress(button, source) + local token = {} + bucket.tokens[token] = { input = input, button = button, source = source } + holding[self] = true + return token + end, + release = function(_, token) + local record = bucket.tokens[token] + if not record then + return false + end + bucket.tokens[token] = nil + idle(self) + record.input:sourceRelease(record.button, record.source) + return true + end, + } +end + +-- Input:reset already discards every physical and synthetic source. Retire +-- only tokens bound to that Input, without touching another game's holds. +function Control.forget(input) + for session in pairs(sessions) do + for _, bucket in pairs(session.owners) do + for token, record in pairs(bucket.tokens) do + if record.input == input then + bucket.tokens[token] = nil + end + end + end + idle(session) + end +end + +function Control.closeAll() + for session in pairs(sessions) do + session:close() + end +end + +return Control diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index ba46218d0..4802d84aa 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -24,6 +24,7 @@ local Hooks = require("src.mods.Hooks") local ModStorage = require("src.mods.Storage") local WorldReads = require("src.mods.WorldReads") local Runtime = require("src.mods.Runtime") +local InputControl = require("src.mods.InputControl") local BattleSnapshots = require("src.mods.BattleSnapshots") local UIActions = require("src.mods.UIActions") local ModGens = require("src.mods.ModGens") @@ -158,6 +159,7 @@ function Loader.new(opts) mods = {}, loaded = {}, errors = {}, initialized = false, events = Events.new(), hooks = Hooks.new(), content = {}, assets = {}, exports = {}, apis = {}, migrations = {}, order = {}, + inputControl = InputControl.new(), modSave = {}, modOptions = {}, optionSchemas = {}, optionStatus = {}, imageCache = {}, fs = (opts and opts.fs) or (love and love.filesystem), @@ -810,6 +812,16 @@ function Loader.freshness(fs, root) return (" (newest file %s, %s)"):format(tostring(newestName), when) end +-- Recovery releases holds while keeping the current API usable. Retirement +-- (rollback/reload/teardown) additionally disables the old API's presses. +function Loader:releaseModInput(owner) + self.inputControl:release(owner) +end + +function Loader:_closeModInput() + self.inputControl:close() +end + function Loader:_api(mod) local loader = self local modId = mod.manifest.id @@ -922,6 +934,7 @@ function Loader:_api(mod) return loader.events:emit(name, payload) end, }, + input = loader.inputControl:api(modId), hooks = { wrap = function(_, name, callback, priority) return loader.hooks:wrap(name, callback, priority, modId) end }, @@ -1262,6 +1275,7 @@ end -- migration it took goes with them. The journal only exists around an -- entry chunk; a later failure (script validation) purges every registry. function Loader:_rollback(modId) + self.inputControl:retire(modId) PortableScripts.cancelAll(self, modId, "mod rolled back") for name in pairs(self.journal or self.content) do self.content[name]:rollback(modId) @@ -1377,6 +1391,8 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + InputControl.closeAll() + self.inputControl = InputControl.new() PortableScripts.cancelAll(nil, nil, "mods reloaded") -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST -- LOAD'S MODS. EXIT GAME -> launcher -> Play runs every mod's main.lua @@ -1721,6 +1737,7 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + InputControl.closeAll() PortableScripts.cancelAll(nil, nil, "mods unloaded") local undone = select(1, ModSeams.unwindAll()) pcall(function() diff --git a/tests/engine/mod_input_headless.lua b/tests/engine/mod_input_headless.lua new file mode 100644 index 000000000..15f816759 --- /dev/null +++ b/tests/engine/mod_input_headless.lua @@ -0,0 +1,64 @@ +-- Construct/query the input facade without a live Game, Input or LOVE runtime. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +love = nil +local Loader = require("src.mods.Loader") +local Json = require("src.link.Json") +local manifest = Json.encode({ + id = "headless_input", + name = "Headless Input", + version = "1.0.0", + api = 2, + entry = "main.lua", +}) +local files = { + ["mods/headless_input/manifest.json"] = manifest, + ["mods/headless_input/main.lua"] = [[ + local mod = ... + mod.exports.input = mod.input + mod.exports.hasHandle = type(mod.input.tap) == "function" + and type(mod.input.press) == "function" and type(mod.input.release) == "function" + ]], +} +local fs = { + read = function(path) + return files[path] + end, + load = function(path) + return loadstring(files[path], path) + end, + getInfo = function(path) + if path == "mods" or path == "mods/headless_input" then + return { type = "directory" } + end + if files[path] then + return { type = "file" } + end + end, + getDirectoryItems = function(path) + return path == "mods" and { "headless_input" } or {} + end, +} +local oldGame, oldInput = package.loaded["src.core.Game"], package.loaded["src.core.Input"] +local loader = Loader.new({ fs = fs, dev = true }) +loader._game = function() + error("mod.input must not bootstrap Game") +end +T.check(loader:load({ pokemon = {} }), "headless native API loads") +T.eq(#loader.errors, 0, "no load-time error") +local e = assert(loader.exports.headless_input) +T.eq(e.hasHandle, true, "complete facade available inside entry chunk") +for _, method in ipairs({ "tap", "press" }) do + for _, game in ipairs({ false, 1, "game", {}, { input = {} } }) do + T.raises(function() + e.input[method](e.input, game, "a") + end, "mod.input needs the live game", "invalid game rejected before Input work") + end +end +T.eq(e.input:release({}), false, "unknown token is safe before Game exists") +T.eq(e.input:release(nil), false, "missing token is safe before Game exists") +T.eq(package.loaded["src.core.Game"], oldGame, "does not import Game") +T.eq(package.loaded["src.core.Input"], oldInput, "does not import Input") +T.eq(love, nil, "does not install LOVE globals") +loader:_closeModInput() +T.finish("headless mod.input") diff --git a/tests/engine/mod_input_lifecycle.lua b/tests/engine/mod_input_lifecycle.lua new file mode 100644 index 000000000..32267d04f --- /dev/null +++ b/tests/engine/mod_input_lifecycle.lua @@ -0,0 +1,190 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/mod_input/fixture.lua") +local Control = require("src.mods.InputControl") +local Input = require("src.core.Input") +local Loader = require("src.mods.Loader") +local Version = require("src.core.GameVersion") +local Game = require("src.core.Game") +local Touch = require("src.core.TouchControls") +local savedVersion = Version.get() +Touch:init() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local label = version .. "/" .. mode[1] .. "/" .. mode[2] .. ": " + f.ready() + local own, peer, g = f.primary, f.peer, f.game + setmetatable(g, { __index = Game }) + for _, recover in ipairs({ + function() + g:focus(false) + end, + function() + g:focus(true) + end, + function() + g:visible(false) + end, + function() + g:visible(true) + end, + function() + g:joystickremoved(nil) + end, + function() + Input:init() + end, + }) do + local token = own.press("up") + Input:step() + recover() + T.eq(Input:isDown("up"), false, label .. "native recovery clears hold") + T.eq(own.release(token), false, label .. "native recovery retires token") + local fresh = own.press("up") + T.eq(own.release(token), false, label .. "old token cannot affect resumed input") + T.eq(Input:isDown("up"), true, label .. "new input remains held") + T.eq(own.release(fresh), true, label .. "API remains usable after recovery") + end + local api = f.result.loader.apis.portable_mod_input + local seen = 0 + for _, name in ipairs({ "input.key", "input.gamepad", "input.wheel" }) do + api.hooks:wrap(name, function(next, ...) + seen = seen + 1 + return next(...) + end) + end + own.tap("a") + local token = own.press("b") + own.release(token) + T.eq(seen, 0, label .. "logical input bypasses raw hooks/hotkeys") + g:keypressed("up") + g:keyreleased("up") + T.eq(seen, 2, label .. "physical input still dispatches raw hooks") + -- Logical input can be used within a raw hook without recursion. + local held + local off = api.hooks:wrap("input.key", function(_, game, event) + if event.phase == "pressed" then + held = own.input:press(game, "a") + end + end, 100) + g:keypressed("up") + T.eq(Input:isDown("up"), false, label .. "raw hook can suppress original action") + T.eq(Input:isDown("a"), true, label .. "raw hook can request logical action") + T.eq(own.release(held), true, label .. "logical hold from hook releases") + off() + -- Replacing a mod's handle retires the old one without touching peers. + token = own.press("select") + local peerToken = peer.press("select") + local fresh = f.result.loader.inputControl:api("portable_mod_input") + T.eq(own.release(token), false, label .. "replaced API retires old token") + T.eq(Input:isDown("select"), true, label .. "replacement preserves peer") + T.raises(function() + own.press("a") + end, "no longer active", label .. "old API cannot press again") + token = fresh:press(g, "select") + peer.release(peerToken) + T.eq(Input:isDown("select"), true, label .. "new handle owns independent source") + T.eq(fresh:release(token), true, label .. "new handle releases normally") + f.close() + f.result.loader:_closeModInput() + end +end +-- A reset retires tokens for that Input instance only. +Input:init() +local other = setmetatable({}, { __index = Input }) +other:init() +local control = Control.new() +local api = control:api("isolation") +local a = api:press({ input = Input }, "a") +local b = api:press({ input = other }, "a") +Input:reset() +T.eq(api:release(a), false, "reset retires matching Input token") +T.eq(other:isDown("a"), true, "another Input instance remains held") +T.eq(api:release(b), true, "another Input instance's token remains valid") +control:close() +-- Two sessions with the same mod ID still own different sources. +local one, two = Control.new(), Control.new() +local x, y = one:api("same_id"), two:api("same_id") +a = x:press({ input = Input }, "b") +b = y:press({ input = Input }, "b") +one:close() +T.eq(x:release(a), false, "closed session token retired") +T.eq(Input:isDown("b"), true, "closing old session preserves replacement source") +T.eq(y:release(b), true, "replacement session releases normally") +two:close() +-- A failed entry is cleaned through the real loader's rollback path. +Input:init() +local capture = { game = { input = Input } } +_G.MOD_INPUT_ROLLBACK_FIXTURE = capture +local files = {} +local Json = require("src.link.Json") +for _, id in ipairs({ "aaa_input_peer", "broken_input" }) do + files["mods/" .. id .. "/manifest.json"] = Json.encode({ + id = id, + name = id, + version = "1.0.0", + api = 3, + games = { "all" }, + generations = { 1, 2, 3, 4, 5 }, + entry = "main.lua", + }) +end +files["mods/aaa_input_peer/main.lua"] = [[ + local mod = ... + local f = MOD_INPUT_ROLLBACK_FIXTURE + f.peer, f.peerToken = mod.input, mod.input:press(f.game, "b") +]] +files["mods/broken_input/main.lua"] = [[ + local mod = ... + local f = MOD_INPUT_ROLLBACK_FIXTURE + f.broken, f.brokenToken = mod.input, mod.input:press(f.game, "b") + f.brokenSelect = mod.input:press(f.game, "select") + error("intentional mod.input rollback") +]] +local failed = T.sdk.loadMods({ "mods/aaa_input_peer", "mods/broken_input" }, { + data = T.fixtures.fresh(), + fs = T.sdk.memfs(files), + dev = false, +}) +_G.MOD_INPUT_ROLLBACK_FIXTURE = nil +T.check(#failed.errors > 0, "failed input entry reported") +T.eq(Input:isDown("select"), false, "failed entry releases its exclusive hold") +T.eq(Input:isDown("b"), true, "failed entry preserves successful peer hold") +T.eq(capture.broken:release(capture.brokenToken), false, "rollback retires failed token") +T.eq(capture.peer:release(capture.peerToken), true, "peer still owns its token") +T.raises(function() + capture.broken:press(capture.game, "a") +end, "no longer active", "rolled-back API retired") +failed.loader:_closeModInput() +failed.release() +-- Native hot reload closes the old service before building replacement mods. +local f = Fixture.new(".", "gen2", "red", 3, "unified") +f.ready() +a = f.primary.press("a") +f.game.data = T.fixtures.fresh() +rawset(f.game.data, "reloadGenerated", function() end) +local replacement = require("src.dev.HotReload").run(f.game, { fs = T.sdk.memfs({}), dev = false }) +T.eq(#replacement.errors, 0, "ROM-free hot reload succeeds") +T.eq(Input:isDown("a"), false, "hot reload releases old holds") +T.eq(f.primary.release(a), false, "hot reload retires old token") +T.raises(function() + f.primary.press("a") +end, "no longer active", "old service cannot press after reload") +f.close() +-- Native teardown retires all handles, including holds whose caller lost +-- its token/loader references. Outstanding sources must survive GC to cleanup. +Input:init() +Input:keypressed("z") +do + local orphan = Control.new():api("orphan") + orphan:press({ input = Input }, "b") +end +collectgarbage("collect") +Loader.teardown() +T.eq(Input:isDown("b"), false, "teardown releases orphaned mod hold after GC") +T.eq(Input:isDown("a"), true, "teardown preserves keyboard source") +Input:keyreleased("z") +Input:reset() +Version.set(savedVersion) +T.finish("mod.input lifecycle") diff --git a/tests/engine/portable_mod_input.lua b/tests/engine/portable_mod_input.lua new file mode 100644 index 000000000..2d1b7f3f4 --- /dev/null +++ b/tests/engine/portable_mod_input.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local Fixture = dofile("tests/mod_input/fixture.lua") +local f = Fixture.new(".", "gen2", "red", 3, "unified") +dofile("tests/mod_input/cases.lua").run(f) +f.close() +f.T.finish("portable mod.input") diff --git a/tests/mod_input/cases.lua b/tests/mod_input/cases.lua new file mode 100644 index 000000000..ae4944eb0 --- /dev/null +++ b/tests/mod_input/cases.lua @@ -0,0 +1,130 @@ +local M = {} +function M.run(f) + local T, I, own, peer = f.T, f.Input, f.primary, f.peer + local buttons = { "up", "down", "left", "right", "a", "b", "start", "select" } + local rows = {} + local function state(label) + local held, edge = {}, {} + for _, button in ipairs(buttons) do + held[button], edge[button] = I:isDown(button), I:wasPressed(button) + end + rows[#rows + 1] = { label = label, held = held, edge = edge } + end + T.raises(function() + own.tap("a") + end, "mod.input needs the live game", "requires game.ready") + T.eq(#I.pressQueue, 0, "early call has no queued input") + f.ready() + for _, button in ipairs(buttons) do + I:reset() + T.eq(own.tap(button), nil, "tap has no success return") + T.eq(I:isDown(button), false, "tap has no held state before step") + T.eq(I:wasPressed(button), false, "tap edge waits for fixed step") + I:step() + T.eq(I:wasPressed(button), true, "tap produces one edge") + T.eq(I:isDown(button), false, "tap does not resurrect held state") + state(button .. "-tap") + I:step() + T.eq(I:wasPressed(button), false, "tap edge expires after one step") + local a, b = own.press(button), own.press(button) + T.eq(type(a), "table", "opaque press token") + T.neq(a, b, "independent tokens for same button") + T.eq(next(a), nil, "token contains no mutable input details") + I:step() + T.eq(I:wasPressed(button), true, "press queues native edge") + T.eq(I:isDown(button), true, "press holds immediately and across steps") + I:step() + T.eq(I:wasPressed(button), false, "holding does not repeat edges") + T.eq(I:isDown(button), true, "holding survives further steps") + T.eq(peer.release(a), false, "other mod cannot release token") + T.eq(own.release({}), false, "copied/forged token is refused") + T.eq(own.release(a), true, "own token released") + T.eq(I:isDown(button), true, "second token still holds") + T.eq(own.release(a), false, "release is idempotent") + T.eq(own.release(b), true, "second own token released") + T.eq(I:isDown(button), false, "last source releases held state") + state(button .. "-released") + end + I:reset() + own.tap("a") + own.tap("a") + I:step() + T.eq(I:wasPressed("a"), true, "multiple taps before step coalesce into one edge") + I:step() + T.eq(I:wasPressed("a"), false, "coalesced taps do not spill into another step") + -- Physical sources and other mods keep independent ownership. + local a, b = own.press("b"), peer.press("b") + I:keypressed("x") + I:gamepadpressed(nil, "b") + I:overlayPressed("b") + I:step() + T.eq(own.release(a), true, "primary release") + T.eq(I:isDown("b"), true, "other sources keep button down") + T.eq(peer.release(b), true, "peer releases only its own source") + I:keyreleased("x") + T.eq(I:isDown("b"), true, "pad and overlay still hold") + I:gamepadreleased(nil, "b") + T.eq(I:isDown("b"), true, "overlay still holds") + I:overlayReleased("b") + T.eq(I:isDown("b"), false, "all physical sources released") + I:keypressed("z") + own.tap("a") + I:step() + T.eq(I:wasPressed("a"), true, "tap coexists with physical hold") + T.eq(I:isDown("a"), true, "tap release preserves physical hold") + I:keyreleased("z") + -- Logical input ignores user keyboard remapping. + I:applyBindings({ a = { key = "v" } }) + own.tap("a") + I:step() + T.eq(I:wasPressed("a"), true, "tap uses logical action after rebind") + T.eq(I:isDown("a"), false, "rebound tap has no held state") + I:applyBindings(nil) + state("sources-and-rebind") + I:reset() + a = own.press("select") + T.eq(own.release(a), true, "release before first step") + I:step() + T.eq(I:wasPressed("select"), true, "press-release still has its queued edge") + T.eq(I:isDown("select"), false, "press-release does not become a hold") + I:step() + state("released-before-step") + for _, value in ipairs({ "", "A", "quit", "l", "r", "x", "y", false, 1, {}, function() end }) do + for _, method in ipairs({ "tap", "press" }) do + T.raises(function() + own[method](value) + end, "unknown GB button", "reject invalid logical button") + end + end + T.raises(function() + own.tap(nil) + end, "unknown GB button", "missing logical button") + T.eq(#I.pressQueue, 0, "validation does not queue input") + for _, value in ipairs({ "token", false, 1, {}, function() end }) do + T.eq(own.release(value), false, "malformed token declines") + end + T.eq(own.release(nil), false, "missing token declines") + -- Recovery invalidates only this loader's tokens and preserves keyboard. + a, b = own.press("a"), peer.press("a") + I:keypressed("z") + f.result.loader:releaseModInput("portable_mod_input") + T.eq(own.release(a), false, "owner recovery retires token") + T.eq(I:isDown("a"), true, "owner recovery preserves peer/keyboard") + T.eq(peer.release(b), true, "peer token still valid") + T.eq(I:isDown("a"), true, "keyboard remains after both mods release") + f.result.loader:releaseModInput() + T.eq(I:isDown("a"), true, "global mod recovery leaves keyboard alone") + I:keyreleased("z") + -- Recovery permits new presses, without reviving old tokens. + a = own.press("a") + f.result.loader:releaseModInput() + b = own.press("a") + T.eq(own.release(a), false, "stale token cannot release new hold") + T.eq(I:isDown("a"), true, "new hold is live") + T.eq(own.release(b), true, "replacement hold releases normally") + I:step() + I:step() + state("recovery") + return rows +end +return M diff --git a/tests/mod_input/fixture.lua b/tests/mod_input/fixture.lua new file mode 100644 index 000000000..9c4fe5b0f --- /dev/null +++ b/tests/mod_input/fixture.lua @@ -0,0 +1,60 @@ +local M = {} +function M.new(root, backend, version, api, format) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local generation = Version.generation() + local Input = require("src.core.Input") + local files, paths = {}, {} + local Json = require("src.link.Json") + local function read(path) + local f = assert(io.open(root .. "/examples/portable_mod_input/" .. path, "rb")) + local bytes = f:read("*a") + f:close() + return bytes + end + local manifest = assert(Json.decode(read("manifest.json"))) + for _, id in ipairs({ "portable_mod_input", "portable_mod_input_peer" }) do + paths[#paths + 1] = "mods/" .. id + manifest.id, manifest.api, manifest.callback_format = id, api, format + files["mods/" .. id .. "/manifest.json"] = Json.encode(manifest) + files["mods/" .. id .. "/main.lua"] = read("main.lua") + end + local data = backend == "gen1" and generation == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local result = + T.sdk.loadMods(paths, { generation = generation, data = data, fs = T.sdk.memfs(files), dev = false }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + Input:init() + local game = { + input = Input, + mods = result.loader, + data = data, + save = { options = {} }, + stack = { + states = {}, + top = function() + return nil + end, + }, + } + result.loader.game = game + local f = { + T = T, + result = result, + Input = Input, + game = game, + primary = assert(result.loader.exports.portable_mod_input), + peer = assert(result.loader.exports.portable_mod_input_peer), + files = files, + paths = paths, + } + function f.ready() + result.loader.events:emit("game.ready", { game = game }) + end + function f.close() + result.loader:releaseModInput() + result.release() + end + return f +end +return M diff --git a/tests/mod_input/worker.lua b/tests/mod_input/worker.lua new file mode 100644 index 000000000..bbabd4b0a --- /dev/null +++ b/tests/mod_input/worker.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local f = dofile(root .. "/tests/mod_input/fixture.lua").new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/mod_input/cases.lua").run(f) +f.close() +print("MOD_INPUT_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable mod.input contract") diff --git a/tools/mod-input-contract-check/README.md b/tools/mod-input-contract-check/README.md new file mode 100644 index 000000000..b6ac022a6 --- /dev/null +++ b/tools/mod-input-contract-check/README.md @@ -0,0 +1,19 @@ +# Cross-engine mod.input contract + +Run from Gen2Recomped: + +```sh +python3 tools/mod-input-contract-check/check.py --gen1 /path/to/gen1recomp --lua luajit --report /tmp/mod-input.json +``` + +The Gen1 checkout must be clean at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. No source is patched. Each native +loader executes `examples/portable_mod_input/main.lua` unchanged for two +independent mods, on Red, Gold, FireRed and Emerald. The checker verifies +the eight logical buttons, fixed-step edges/holds, independent sources, +foreign/idempotent token release, rebinding, invalid arguments and recovery, +then compares held/edge journals across the engines. + +Use `--format unified` for unified API 2, `--api 3 --format unified` for API 3, +and `--gen2` to select an integration checkout. Unsupported manifests fail; +there is no silent downgrade. No ROM or native window is required. diff --git a/tools/mod-input-contract-check/check.py b/tools/mod-input-contract-check/check.py new file mode 100644 index 000000000..7981cdd22 --- /dev/null +++ b/tools/mod-input-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged logical input mod against both engines' native Input state.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/mod_input/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("MOD_INPUT_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("MOD_INPUT_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"logical input state mismatch: {version}" + print(f"PASS {version}: matching native input state across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_mod_input/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 34d5c10ff5cf5c8c65170a339a0ba9504972bf40 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:16:08 +0200 Subject: [PATCH 022/138] Add portable core.update dispatch and native cross-engine contracts --- .github/workflows/core-update-contract.yml | 43 +++++ docs/portable-core-update.md | 84 +++++++++ examples/portable_core_update/README.md | 13 ++ examples/portable_core_update/main.lua | 18 ++ examples/portable_core_update/manifest.json | 10 ++ main.lua | 5 +- src/core/PlatformHooks.lua | 16 ++ src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 2 +- tests/core_update/cases.lua | 180 ++++++++++++++++++++ tests/core_update/fixture.lua | 104 +++++++++++ tests/core_update/worker.lua | 7 + tests/engine/core_update.lua | 6 + tests/engine/core_update_routing.lua | 102 +++++++++++ tools/core-update-contract-check/README.md | 20 +++ tools/core-update-contract-check/check.py | 58 +++++++ 16 files changed, 667 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/core-update-contract.yml create mode 100644 docs/portable-core-update.md create mode 100644 examples/portable_core_update/README.md create mode 100644 examples/portable_core_update/main.lua create mode 100644 examples/portable_core_update/manifest.json create mode 100644 src/core/PlatformHooks.lua create mode 100644 tests/core_update/cases.lua create mode 100644 tests/core_update/fixture.lua create mode 100644 tests/core_update/worker.lua create mode 100644 tests/engine/core_update.lua create mode 100644 tests/engine/core_update_routing.lua create mode 100644 tools/core-update-contract-check/README.md create mode 100644 tools/core-update-contract-check/check.py diff --git a/.github/workflows/core-update-contract.yml b/.github/workflows/core-update-contract.yml new file mode 100644 index 000000000..3f5fd0b26 --- /dev/null +++ b/.github/workflows/core-update-contract.yml @@ -0,0 +1,43 @@ +name: portable core.update contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: core-update-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check frame dispatch and native host routing + run: | + luajit tests/engine/core_update.lua + luajit tests/engine/core_update_routing.lua + - name: Compare both native engines through the same mod + run: | + mkdir -p /tmp/core-update-contract + python3 tools/core-update-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/core-update-contract/api2-native.json + - name: Check unified callback and API-3 integration + run: | + python3 tools/core-update-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/core-update-contract/api2-unified.json + python3 tools/core-update-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/core-update-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: core-update-contract + path: /tmp/core-update-contract + if-no-files-found: ignore diff --git a/docs/portable-core-update.md b/docs/portable-core-update.md new file mode 100644 index 000000000..74ed54262 --- /dev/null +++ b/docs/portable-core-update.md @@ -0,0 +1,84 @@ +# Portable interactive frame hook + +Gen2Recomped dispatches `core.update` at the same host boundary as Gen1Recomp: +`main.lua` calls `PlatformHooks.update(game, dt)` for each normal interactive +Game frame. Native API 1/2, unified API 2 and API 3 use the same signature: + +```lua +local mod = ... +local paused = false +mod.hooks:wrap("core.update", function(next, game, dt) + -- Poll/update the mod's own overlay even while Game simulation is paused. + if paused then return end + return next(game, dt) +end) +``` + +The arguments are the original opaque Game handle and frame delta. The hook +runs once at the interactive frame boundary, outside the fixed-step loop; +fast-forward does not multiply callback frequency. Vanilla calls +`game:update(dt)` and discards its return values, matching Gen1's helper. + +Omitting `next` pauses that frame's Game update. The pausing wrapper still +runs on subsequent frames, so its overlay can observe its own state and resume. Calling +`next()` forwards the current arguments; explicit `next(otherGame, otherDt)` +changes both downstream wrapper arguments and the actual native update target. +This differs from raw input hooks, whose native bodies retain the original +input event. Returned booleans have no independent consume meaning: whether +the callback calls `next` determines whether Game updates. + +The helper returns hook-chain results, including nil holes. LÖVE's interactive +host callback ignores those results, just as Gen1 does. Existing priority, +unsubscribe, owner cleanup and error rules apply. A wrapper that fails before +forwarding is skipped; a failure after forwarding does not replay the native +update. Native update errors propagate without being retried. Unified +callbacks retain their existing cached-continuation behavior; this feature +does not change the hook bus's rules for repeated explicit `next` calls. + +`mod:supports("hooks", "core.update")` reports availability before the mod entry +runs, and the unified callback catalog accepts the hook without record +conversion. The Game identity remains unchanged unless a wrapper deliberately +passes a replacement to `next`. + +## Host routing + +The hook pauses Game simulation only. Work outside Game:update, including +normal host housekeeping and drawing, retains its native routing. A boot +report, launcher/importer, editor or a frame with no Game does not dispatch +the hook. + +Autopilot and coroutine-driver runs keep their existing direct deterministic +`Game:update(1/60)` calls and bypass `core.update`, matching Gen1. Each scripted +observation still has its own step, including multiple iterations per rendered +frame. A failed or completed driver returns through its existing exit path. +Do not install an additional wrapper on Game:update to implement this seam; +that would also affect scripted loops and could double dispatch it. + +With no subscriber, the existing Runtime/bus fallback calls the native update. +Its default function is hoisted, and dispatch allocates no payload table or +per-frame continuation closure on that unsubscribed path. + +## Verification + +```sh +luajit tests/engine/core_update.lua +luajit tests/engine/core_update_routing.lua +python3 tools/core-update-contract-check/check.py --gen1 /path/to/gen1recomp +python3 tools/core-update-contract-check/check.py --gen1 /path/to/gen1recomp --format unified +python3 tools/core-update-contract-check/check.py --gen1 /path/to/gen1recomp --api 3 --format unified +``` + +The reference is clean Gen1Recomp +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same committed mod entry chunk +runs through both engines' native loaders on Red, Gold, FireRed and Emerald. +Tests load the real main.lua and invoke the actual love.update callback, +including Gen1's profiler wrapper. Test-only upvalue injection supplies a +Game:update spy and host-owner fixtures without importing a cartridge; it does +not replace the native routing or PlatformHooks implementations. Unrelated +host housekeeping is isolated behind a counter. The checker compares frame +journals and rejects dirty or unexpected Gen1 sources. + +The Gen2 routing matrix covers every supported host version in API 1, +native/unified API 2 and API 3. These checks are ROM-free and headless; they +verify dispatch and lifecycle behavior rather than full-world simulation, +GPU rendering or native OS event delivery. diff --git a/examples/portable_core_update/README.md b/examples/portable_core_update/README.md new file mode 100644 index 000000000..766ec3342 --- /dev/null +++ b/examples/portable_core_update/README.md @@ -0,0 +1,13 @@ +# Portable interactive frame example + +Copy into `mods/`. This unchanged entry chunk works on Gen1Recomp and +Gen2Recomped. `exports.pause(true)` pauses Game updates while the mod continues +to observe interactive frames; `pause(false)` resumes. `exports.stop()` removes +the hook. The example starts unpaused and issues no synthetic input. + +The public API, poll count and latest Game/delta observation are exported for +the headless contract tests. It retains only the latest observation, without +allocating an unbounded frame journal. The API-2 manifest is also exercised as +unified API 2 and API 3 by the checker. See +[the frame contract](../../docs/portable-core-update.md), particularly the +separate deterministic-driver routing. diff --git a/examples/portable_core_update/main.lua b/examples/portable_core_update/main.lua new file mode 100644 index 000000000..74b6c9901 --- /dev/null +++ b/examples/portable_core_update/main.lua @@ -0,0 +1,18 @@ +-- Observe every interactive frame, including frames whose simulation is paused. +local mod = ... +local paused, polls, seen = false, 0, {} +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.pause(value) + paused = value +end +function mod.exports.polls() + return polls +end +mod.exports.stop = mod.hooks:wrap("core.update", function(next, game, dt) + polls = polls + 1 + seen.game, seen.dt = game, dt + if paused then + return "paused" + end + return next(game, dt) +end) diff --git a/examples/portable_core_update/manifest.json b/examples/portable_core_update/manifest.json new file mode 100644 index 000000000..4ebf27460 --- /dev/null +++ b/examples/portable_core_update/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_core_update", + "name": "Portable Frame Update", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3, 4, 5], + "affects_link": false +} diff --git a/main.lua b/main.lua index 18325ffdf..df242c60c 100644 --- a/main.lua +++ b/main.lua @@ -241,6 +241,7 @@ local lastLogFlush = 0 -- unrelated push overflows it and ends the process with a traceback pointing at -- innocent code. See src/render/GraphicsStack.lua. local GraphicsStack = require("src.render.GraphicsStack") +local PlatformHooks = require("src.core.PlatformHooks") -- Declared HERE rather than further down beside its first mark: bootGame below -- calls BootTrace.booted(), and a `local` declared after a function's body is -- not in scope inside it -- the name resolved to the nil GLOBAL instead and @@ -688,7 +689,9 @@ function love.update(dt) -- unguarded dereference here is not a theoretical race -- it is -- "attempt to index upvalue 'Game' (a nil value)" a second after launch. if not Game then return end - Game:update(dt) + -- Only the interactive game frame is a core.update seam, as in Gen1. + -- Scripted act+step loops above retain their deterministic direct updates. + PlatformHooks.update(Game, dt) end -- THE LAUNCHER'S OWN BOTTOM SCREEN. diff --git a/src/core/PlatformHooks.lua b/src/core/PlatformHooks.lua new file mode 100644 index 000000000..42a9018b3 --- /dev/null +++ b/src/core/PlatformHooks.lua @@ -0,0 +1,16 @@ +-- Shared frame boundary for mods that poll host state or pause simulation. +-- Match Gen1Recomp's main.lua -> PlatformHooks.update contract. +local Runtime = require("src.mods.Runtime") +local PlatformHooks = {} + +-- Hoisted: forwarding needs no per-frame closure or payload allocation. +-- Native Game:update results are deliberately discarded, as in Gen1. +local function vanillaUpdate(game, dt) + game:update(dt) +end + +function PlatformHooks.update(game, dt) + return Runtime.call("core.update", vanillaUpdate, game, dt) +end + +return PlatformHooks diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index cdadbbde8..7c7a344f5 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -16,7 +16,7 @@ end -- need not fire on every action (e.g. an always-hit move skips accuracy). add("hooks", "12345", [[ battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action - battle.exp_award battle.low_health_alarm battle.run battle.turn_order catch.rate + battle.exp_award battle.low_health_alarm battle.run battle.turn_order catch.rate core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 5f54085eb..02b5d214e 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -17,7 +17,7 @@ end -- every seam. The source-catalog test requires a decision for each new seam. C.hooks = names([[ battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award - battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate + battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite render.compose render.hud diff --git a/tests/core_update/cases.lua b/tests/core_update/cases.lua new file mode 100644 index 000000000..2630ed60e --- /dev/null +++ b/tests/core_update/cases.lua @@ -0,0 +1,180 @@ +local M = {} +local function pack(...) + return { n = select("#", ...), ... } +end +function M.run(f) + local T, g, e, api = f.T, f.game, f.exports, f.public + local rows = {} + local function state(label) + rows[#rows + 1] = { label = label, updates = g.calls, polls = e.polls(), hostPolls = f.hostPolls() } + end + for _, dt in ipairs({ 0, 1 / 60, 0.25, 2.5 }) do + local n, polls = g.calls, e.polls() + f.update(dt) + T.eq(g.calls, n + 1, "interactive frame forwards exactly one native update") + T.eq(e.polls(), polls + 1, "interactive frame invokes wrapper once") + T.eq(e.seen.game, g, "original Game identity") + T.eq(e.seen.dt, dt, "unmodified frame delta") + T.eq(g.dts[#g.dts], dt, "native update receives frame delta") + end + state("forwarded") + e.pause(true) + local n, polls, host = g.calls, e.polls(), f.hostPolls() + f.update(1 / 60) + f.update(1 / 30) + T.eq(g.calls, n, "pause suppresses Game:update") + T.eq(e.polls(), polls + 2, "mod continues polling while simulation paused") + T.eq(f.hostPolls(), host + 2, "host housekeeping continues while paused") + state("paused") + e.pause(false) + f.update(1 / 60) + T.eq(g.calls, n + 1, "resume forwards exactly one update") + state("resumed") + if api.supports then + T.eq(api:supports("hooks", "core.update"), true, "native capability advertised") + end + -- next() forwards current args; explicit args route to another Game/delta. + local alternate = { calls = 0 } + function alternate:update(dt) + self.calls = self.calls + 1 + self.dt = dt + end + local order = {} + local outer = api.hooks:wrap("core.update", function(next, game, dt) + T.eq(game, g, "outer sees original Game") + order[#order + 1] = "outer" + next(alternate, dt / 2) + order[#order + 1] = "after" + return nil, "wrapped", nil + end, 20) + local inner = api.hooks:wrap("core.update", function(next, game, dt) + order[#order + 1] = "inner" + T.eq(game, alternate, "rewritten Game reaches downstream wrapper") + T.eq(dt, 0.125, "rewritten delta reaches downstream wrapper") + return next() + end, 10) + n = g.calls + T.same(pack(f.bridge.update(g, 0.25)), { n = 3, [2] = "wrapped" }, "wrapper nil-hole results preserved") + T.same(order, { "outer", "inner", "after" }, "descending priority and postprocessing") + T.eq(alternate.calls, 1, "native fallback uses replacement Game") + T.eq(alternate.dt, 0.125, "native fallback uses replacement delta") + T.eq(g.calls, n, "original Game not updated after explicit replacement") + outer() + inner() + T.same(pack(f.bridge.update(g, 1 / 60)), { n = 0 }, "native Game:update results deliberately discarded") + for _, after in ipairs({ false, true }) do + local off = api.hooks:wrap("core.update", function(next) + if after then + next() + end + error("intentional core.update wrapper failure") + end, 100) + n = g.calls + f.update(1 / 60) + T.eq(g.calls, n + 1, "wrapper error preserves one native update") + off() + end + local original = g.update + local attempts = 0 + g.update = function() + attempts = attempts + 1 + error("native update failure") + end + T.raises(function() + f.update(1 / 60) + end, "native update failure", "native failure escapes") + T.eq(attempts, 1, "native failure is not retried") + g.update = original + if f.api >= 3 then + local off = api.hooks:wrap("core.update", function(next) + next() + return next() + end, 100) + n = g.calls + f.update(1 / 60) + T.eq(g.calls, n + 1, "unified API-3 continuation caches downstream result") + off() + end + -- Real love.update routing: non-game owners bypass the seam. + for _, mode in ipairs({ "Importer", "TouchEditor", "editorMode", "noGame" }) do + local owner = { calls = 0 } + if mode == "Importer" then + function owner:update() + self.calls = self.calls + 1 + end + f.set("Importer", owner) + elseif mode == "TouchEditor" then + owner.update = function() + owner.calls = owner.calls + 1 + end + f.set("TouchEditor", owner) + elseif mode == "editorMode" then + owner.update = function() + owner.calls = owner.calls + 1 + end + f.set("EditorApp", owner) + f.set("editorMode", true) + else + f.set("Game", nil) + end + polls, n = e.polls(), g.calls + f.update(1 / 60) + T.eq(e.polls(), polls, mode .. " does not fire mod frame hook") + T.eq(g.calls, n, mode .. " does not update game") + if mode ~= "noGame" then + T.eq(owner.calls, 1, mode .. " native owner still updates") + end + if mode == "editorMode" then + f.set("editorMode", false) + f.set("EditorApp", nil) + elseif mode == "noGame" then + f.set("Game", g) + else + f.set(mode, nil) + end + end + state("non-game-routing") + -- Scripted act+step loops remain deterministic and bypass core.update. + f.set("speedOverride", 2) + local acts = 0 + f.set("autopilot", { + update = function() + acts = acts + 1 + end, + }) + polls, n = e.polls(), g.calls + f.update(0.9) + T.eq(acts, 2, "autopilot observes each deterministic step") + T.eq(g.calls, n + 2, "autopilot runs both deterministic updates") + T.eq(g.dts[#g.dts], 1 / 60, "autopilot retains fixed delta") + T.eq(e.polls(), polls, "autopilot bypasses frame hook") + f.set("autopilot", nil) + acts = 0 + f.set( + "driverCo", + coroutine.create(function() + while true do + acts = acts + 1 + coroutine.yield() + end + end) + ) + polls, n = e.polls(), g.calls + f.update(0.9) + T.eq(acts, 2, "driver observes each deterministic step") + T.eq(g.calls, n + 2, "driver runs both deterministic updates") + T.eq(g.dts[#g.dts], 1 / 60, "driver retains fixed delta") + T.eq(e.polls(), polls, "driver bypasses frame hook") + f.set("driverCo", nil) + f.set("speedOverride", nil) + state("scripted-routing") + e.stop() + e.stop() + polls, n = e.polls(), g.calls + f.update(1 / 60) + T.eq(e.polls(), polls, "unsubscribe is idempotent") + T.eq(g.calls, n + 1, "empty chain still forwards native update") + state("unsubscribed") + return rows +end +return M diff --git a/tests/core_update/fixture.lua b/tests/core_update/fixture.lua new file mode 100644 index 000000000..1a0d63c9f --- /dev/null +++ b/tests/core_update/fixture.lua @@ -0,0 +1,104 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod) + local T = require("tests.modkit") + love.event = love.event or {} + local Version = require("src.core.GameVersion") + Version.set(version) + -- Load the real host callbacks without booting/importing a cartridge. + dofile("main.lua") + local entryUpdate = love.update + local function upvalue(fn, name) + for i = 1, 100 do + local n, value = debug.getupvalue(fn, i) + if not n then + break + end + if n == name then + return i, value + end + end + end + -- Gen1 decorates love.update with its native frame profiler. + local _, original = upvalue(entryUpdate, "gameUpdate") + local update = original or entryUpdate + local function set(name, value) + local owner, index = update, upvalue(update, name) + if not index then + owner, index = entryUpdate, upvalue(entryUpdate, name) + end + if not index then + local _, iterations = upvalue(update, "scriptedIterations") + if iterations then + owner, index = iterations, upvalue(iterations, name) + end + end + assert(index, "missing native update upvalue: " .. name) + debug.setupvalue(owner, index, value) + end + local files = {} + for _, path in ipairs({ "main.lua", "manifest.json" }) do + local f = assert(io.open(root .. "/examples/portable_core_update/" .. path, "rb")) + files["mods/portable_core_update/" .. path] = f:read("*a") + f:close() + end + local Json = require("src.link.Json") + local manifest = assert(Json.decode(files["mods/portable_core_update/manifest.json"])) + manifest.api, manifest.callback_format = api, format + files["mods/portable_core_update/manifest.json"] = Json.encode(manifest) + local generation = Version.generation() + local data = backend == "gen1" and generation == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_core_update" }, { + data = data, + generation = generation, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local game = { calls = 0, dts = {}, mods = result.loader, data = data } + function game:update(dt) + self.calls = self.calls + 1 + self.dts[#self.dts + 1] = dt + return "ignored native result", nil, "tail" + end + set("Game", game) + set("speedOverride", nil) + local hostPolls = 0 + if backend == "gen1" then + require("src.core.HostDisplay").setBackend({ + update = function() + hostPolls = hostPolls + 1 + end, + }) + else + -- Isolate the unrelated watts service; retain love.update's real routing. + set("walkerTick", function() + hostPolls = hostPolls + 1 + end) + end + local exports = result.loader.exports.portable_core_update + return { + T = T, + result = result, + game = game, + api = api, + format = format, + exports = exports, + public = exports and exports.api, + set = set, + update = entryUpdate, + bridge = require("src.core.PlatformHooks"), + hostPolls = function() + return hostPolls + end, + close = function() + if backend == "gen1" then + require("src.core.HostDisplay").setBackend(nil) + end + if result.loader._closeModInput then + result.loader:_closeModInput() + end + result.release() + end, + } +end +return M diff --git a/tests/core_update/worker.lua b/tests/core_update/worker.lua new file mode 100644 index 000000000..eb7714361 --- /dev/null +++ b/tests/core_update/worker.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local f = dofile(root .. "/tests/core_update/fixture.lua").new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/core_update/cases.lua").run(f) +f.close() +print("CORE_UPDATE_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable core.update contract") diff --git a/tests/engine/core_update.lua b/tests/engine/core_update.lua new file mode 100644 index 000000000..fcac5b9a9 --- /dev/null +++ b/tests/engine/core_update.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local Fixture = dofile("tests/core_update/fixture.lua") +local f = Fixture.new(".", "gen2", "red", 3, "unified") +dofile("tests/core_update/cases.lua").run(f) +f.close() +f.T.finish("portable core.update") diff --git a/tests/engine/core_update_routing.lua b/tests/engine/core_update_routing.lua new file mode 100644 index 000000000..515f520d5 --- /dev/null +++ b/tests/engine/core_update_routing.lua @@ -0,0 +1,102 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/core_update/fixture.lua") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local savedVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local e, g, api = f.exports, f.game, f.public + local label = version .. "/" .. mode[1] .. "/" .. mode[2] .. ": " + T.eq(api:supports("hooks", "core.update"), true, label .. "native hook capability") + f.update(0.125) + T.eq(g.calls, 1, label .. "native update runs once") + T.eq(e.polls(), 1, label .. "hook runs once") + T.eq(e.seen.game, g, label .. "Game stays opaque under every callback format") + T.eq(e.seen.dt, 0.125, label .. "delta stays scalar") + e.pause(true) + T.eq(f.bridge.update(g, 0.5), "paused", label .. "hook result returned by bridge") + T.eq(g.calls, 1, label .. "bridge respects pause") + e.pause(false) + -- Boot reports own their frame before any Game/mod processing. + f.set("bootReport", {}) + local polls, n, host = e.polls(), g.calls, f.hostPolls() + f.update(1 / 60) + T.eq(e.polls(), polls, label .. "boot report bypasses hook") + T.eq(g.calls, n, label .. "boot report bypasses game") + T.eq(f.hostPolls(), host, label .. "boot report retains native housekeeping boundary") + f.set("bootReport", nil) + -- Driver failure/completion must not accidentally fall into the hook. + local quit, oldQuit = {}, love.event.quit + love.event.quit = function(code) + quit[#quit + 1] = code or "normal" + end + f.set( + "driverCo", + coroutine.create(function() + error("intentional core.update driver failure") + end) + ) + f.update(1 / 60) + T.eq(quit[1], 1, label .. "driver error requests failure exit") + T.eq(e.polls(), polls, label .. "failed driver bypasses hook") + T.eq(g.calls, n, label .. "failed driver does not update game") + f.set("driverCo", coroutine.create(function() end)) + f.update(1 / 60) + T.eq(quit[2], "normal", label .. "completed driver requests native exit") + T.eq(e.polls(), polls, label .. "completed driver bypasses hook") + T.eq(g.calls, n, label .. "completed driver does not update game") + f.set("driverCo", nil) + love.event.quit = oldQuit + -- Owner cleanup removes only that contributor's frame wrapper. + local peerCalls = 0 + local off = f.result.loader.hooks:wrap("core.update", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) + end, 5, "other_owner") + f.result.loader.hooks:removeOwner("portable_core_update") + f.update(1 / 60) + T.eq(e.polls(), polls, label .. "owner removal retires mod wrapper") + T.eq(peerCalls, 1, label .. "owner removal preserves another wrapper") + T.eq(g.calls, n + 1, label .. "owner removal preserves native update") + off() + f.close() + end +end +-- With no mods, the actual love.update and direct bridge keep native behavior. +local bare = Fixture.new(".", "gen2", "red", 2, "native", true) +T.eq(Runtime.wantsHook("core.update"), false, "no-mod Runtime has no frame subscriber") +for _, dt in ipairs({ 0, 0.125, 2 }) do + bare.update(dt) +end +T.eq(bare.game.calls, 3, "no-mod interactive update runs once per call") +T.same(bare.game.dts, { 0, 0.125, 2 }, "no-mod delta forwarding unchanged") +T.eq(select("#", bare.bridge.update(bare.game, 0)), 0, "native return values discarded without subscribers") +bare.close() +-- A failed entry's registered update cannot survive its public-loader rollback. +local files = { + ["mods/broken_frame/manifest.json"] = [[{"id":"broken_frame","name":"Broken Frame", + "version":"1.0.0","api":3,"entry":"main.lua"}]], + ["mods/broken_frame/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("core.update", function() error("rolled-back wrapper survived") end) + error("intentional frame entry failure") + ]], +} +local failed = T.sdk.loadMods({ "mods/broken_frame" }, { fs = T.sdk.memfs(files), data = T.fixtures.fresh() }) +T.check(#failed.errors > 0, "failed frame entry is reported") +T.eq(Runtime.wantsHook("core.update"), false, "rollback removes registered frame wrapper") +local count = 0 +require("src.core.PlatformHooks").update({ + update = function() + count = count + 1 + end, +}, 1 / 60) +T.eq(count, 1, "rollback preserves native frame fallback") +Loader.teardown() +T.eq(Runtime.wantsHook("core.update"), false, "teardown leaves no frame wrapper") +failed.release() +Version.set(savedVersion) +T.finish("core.update routing") diff --git a/tools/core-update-contract-check/README.md b/tools/core-update-contract-check/README.md new file mode 100644 index 000000000..4dc330350 --- /dev/null +++ b/tools/core-update-contract-check/README.md @@ -0,0 +1,20 @@ +# Cross-engine core.update contract + +```sh +python3 tools/core-update-contract-check/check.py --gen1 /path/to/gen1recomp --lua luajit --report /tmp/core-update.json +``` + +Gen1 must be clean at `79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Both native +loaders execute `examples/portable_core_update/main.lua` unchanged. The tests +invoke each engine's actual love.update callback loaded from main.lua, with a +Game:update spy and fixtures for non-game owners supplied through test-only +upvalue injection. Neither native main.lua nor PlatformHooks is replaced. +Gen2's unrelated walker bookkeeping and Gen1's HostDisplay backend use a +counter so the test does not depend on external services. + +The checker covers Red, Gold, FireRed and Emerald, asserts forwarding, pause, +priority, argument replacement, errors, unsubscribe and host/driver routing, +then compares frame journals. `--format unified` selects unified API 2; +`--api 3 --format unified` selects API 3. `--gen2` selects an integration +checkout. No ROM, native window or renderer is needed, and no Gen1 source is +written. diff --git a/tools/core-update-contract-check/check.py b/tools/core-update-contract-check/check.py new file mode 100644 index 000000000..dec9d2568 --- /dev/null +++ b/tools/core-update-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged frame-update mod against both engines' native love.update routes.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/core_update/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("CORE_UPDATE_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("CORE_UPDATE_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"frame routing mismatch: {version}" + print(f"PASS {version}: matching native frame routing across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_core_update/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 7801f9d715c8c1f9910a7f982ef89a6396e4137e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:38:19 +0200 Subject: [PATCH 023/138] Add portable core.logic_speed dispatch and cross-engine contracts --- .github/workflows/logic-speed-contract.yml | 46 +++++ docs/modding.md | 9 +- docs/portable-logic-speed.md | 92 +++++++++ examples/portable_logic_speed/main.lua | 17 ++ examples/portable_logic_speed/manifest.json | 10 + src/core/FixedStep.lua | 6 + src/core/Game.lua | 61 ++++-- src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 2 +- tests/engine/logic_speed.lua | 6 + tests/engine/logic_speed_clock.lua | 161 ++++++++++++++++ tests/engine/logic_speed_routing.lua | 76 ++++++++ tests/engine/mod_supports.lua | 5 +- tests/logic_speed/cases.lua | 196 ++++++++++++++++++++ tests/logic_speed/fixture.lua | 95 ++++++++++ tests/logic_speed/worker.lua | 11 ++ tools/logic-speed-contract-check/check.py | 58 ++++++ 17 files changed, 832 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/logic-speed-contract.yml create mode 100644 docs/portable-logic-speed.md create mode 100644 examples/portable_logic_speed/main.lua create mode 100644 examples/portable_logic_speed/manifest.json create mode 100644 tests/engine/logic_speed.lua create mode 100644 tests/engine/logic_speed_clock.lua create mode 100644 tests/engine/logic_speed_routing.lua create mode 100644 tests/logic_speed/cases.lua create mode 100644 tests/logic_speed/fixture.lua create mode 100644 tests/logic_speed/worker.lua create mode 100644 tools/logic-speed-contract-check/check.py diff --git a/.github/workflows/logic-speed-contract.yml b/.github/workflows/logic-speed-contract.yml new file mode 100644 index 000000000..502f0fc04 --- /dev/null +++ b/.github/workflows/logic-speed-contract.yml @@ -0,0 +1,46 @@ +name: portable core.logic_speed contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: logic-speed-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native speed dispatch, ownership and clocks + run: | + luajit tests/engine/logic_speed.lua + luajit tests/engine/logic_speed_routing.lua + luajit tests/engine/logic_speed_clock.lua + - name: Compare native API 1 and 2 + run: | + mkdir -p /tmp/logic-speed-contract + python3 tools/logic-speed-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/logic-speed-contract/api1-native.json + python3 tools/logic-speed-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/logic-speed-contract/api2-native.json + - name: Compare unified callbacks and API 3 + run: | + python3 tools/logic-speed-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/logic-speed-contract/api2-unified.json + python3 tools/logic-speed-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/logic-speed-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: logic-speed-contract + path: /tmp/logic-speed-contract + if-no-files-found: ignore diff --git a/docs/modding.md b/docs/modding.md index a48460651..2cc59fa3f 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -59,8 +59,7 @@ them. Availability does not imply the current game state permits the action: return `nil, "map carries no elevation"`. Callback availability follows Gen2Recomped's actual implementation, rather -than Gen1Recomp's newer catalog. For example, `core.logic_speed`, `item.use` -and `egg.hatched` have no native call sites here; the hatch event here is +than Gen1Recomp's newer catalog. For example, `item.use` and `egg.hatched` have no native call sites here; the hatch event here is `pokemon.egg_hatched` (Gen 2–5). `battle.status_hud_visible` is available on Gen 4–5, `battle.overlay` on Gen 1–3, and the normal start-menu, party-submenu and naming-grid hooks on Gen 1–3. `ui.pc.items` is available on Gen 1–2. @@ -77,6 +76,12 @@ update `src/mods/Capabilities.lua` and the capability tests in the same change. Additional read-only world summaries: [map overview and encounter queries](world-overview.md). +## Logic speed hook + +The [portable logic speed contract](portable-logic-speed.md) documents +`core.logic_speed(next, game)`, its native speed resolution, hard locks and +explicit-override precedence for native API 1/2, unified API 2 and API 3. + ## Bulk storage (`mod.storage`) `mod.save` is the right home for a handful of per-playthrough values: it lives diff --git a/docs/portable-logic-speed.md b/docs/portable-logic-speed.md new file mode 100644 index 000000000..03f8502b1 --- /dev/null +++ b/docs/portable-logic-speed.md @@ -0,0 +1,92 @@ +# Portable logic speed hook + +`core.logic_speed(next, game)` wraps native speed resolution on Gen2Recomped, +matching Gen1Recomp. Native API 1/2, unified API 2 and API 3 all receive the +original opaque Game handle, with no record/ID conversion or extra arguments. +`mod:supports("hooks", "core.logic_speed")` reports the native producer on every +Gen2Recomped host, including before a Game/save exists. + +```lua +local mod = ... +local fastForward = false +mod.hooks:wrap("core.logic_speed", function(next, game) + if fastForward then return 20 end + return next(game) +end) +``` + +A call without `next` supplies the multiplier. `next()` retains the current +Game; `next(otherGame)` forwards that replacement to downstream wrappers and +the native `_resolveLogicSpeed` method. The native resolver reads live options. +These are engine handles: a replacement must implement that native method. +`next` resolves options; it does not re-enter the replacement's `logicSpeed` +policy checks. The original caller's hard locks and launch override have +already been checked before the chain runs. + +The precedence is: + +1. A link session or open link transport forces literal 1X and skips the hook. +2. Any stacked state with `isFixedSpeed` or `isMinigame` forces literal 1X and + skips the hook, including when a menu covers that state. These markers are + opt-in metadata, not a claim that every existing minigame declares them. +3. An explicit `--speed` / `POKEPORT_SPEED` override skips the hook and uses the + existing native clamp. +4. The hook chain wraps the saved-option resolver. Without a subscriber, the + resolver runs directly without a per-call closure or payload allocation. + +Gen2Recomped keeps its global `save.options.speed` and existing ladder +`1, 2, 4, 10, 20, 30, 50, 75, 100, 200`. Gen1 has a 3X rung and may use +category-specific options or cartridge ladders. A portable mod should choose +common supported levels or accept the host's nearest native level. This hook +does not migrate settings or promise identical ladders. + +The first hook result is clamped at the outer producer boundary. Numeric +strings are accepted; invalid/nil/false/table, NaN and infinite results become +1X; finite values choose the nearest native level. Hooks cannot introduce a +zero/negative timestep or poison the accumulator. Existing priority, +unsubscribe, owner cleanup and error rules apply: failures before forwarding +fall through; failures after forwarding preserve the downstream result without +replaying resolution. Native resolver failures propagate without retry. +Unified continuations retain their existing caching rules. + +The hook runs per unlocked `Game:logicSpeed()` call. Normal `Game:update` +samples once per frame, outside the fixed-step loop. Native title/intro code +may also query the multiplier; this is not a once-per-frame notification. +Only simulation time is multiplied. Audio, camera and presentation updates +retain their real-time delta. If a hard lock starts during an accelerated +step, the installed fixed-step callback drops the remaining frame budget; +it does not arm the separate hitch-suppression path or slow the next frame. + +Gen1's GBA boot/cinematic restrictions remain native to that host (and explicit +overrides keep their existing boot precedence). Gen2's own title/intro code +retains its timing. This feature does not transplant Gen1-only boot states. + +## Verification + +```sh +luajit tests/engine/logic_speed.lua +luajit tests/engine/logic_speed_routing.lua +luajit tests/engine/logic_speed_clock.lua +python3 tools/logic-speed-contract-check/check.py --gen1 /path/to/gen1recomp --api 1 +python3 tools/logic-speed-contract-check/check.py --gen1 /path/to/gen1recomp +python3 tools/logic-speed-contract-check/check.py --gen1 /path/to/gen1recomp --format unified +python3 tools/logic-speed-contract-check/check.py --gen1 /path/to/gen1recomp --api 3 --format unified +``` + +The checker requires clean Gen1Recomp +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36` and rejects a dirty/unexpected +reference. The same committed mod source runs through both native loaders on +Red, Gold, FireRed and Emerald, calling the real Game/Game2/Game3 speed and +lock methods on ROM-free owner fixtures. Journals compare the common contract; +separate assertions preserve native ladder and Gen1 GBA boot differences. +Gen1 does not opt into unified callbacks for API 2; that run verifies the same +consumer against native Gen1 API 2 and unified Gen2 API 2. API 3 is unified +on both engines. + +The Gen2 routing matrix covers all supported versions in native API 1/2, +unified API 2 and API 3, plus rollback, owner cleanup and no-mod behavior. +The clock suite captures the real callback installed by `Game:load` (isolating +unrelated import/render/save setup), then runs real `Game:update` and +`FixedStep` to check acceleration, mid-frame lock transitions and real-time +audio/presentation. These tests require no ROM or GPU and do not claim a +full imported-game playthrough or live network session. diff --git a/examples/portable_logic_speed/main.lua b/examples/portable_logic_speed/main.lua new file mode 100644 index 000000000..ed0e63757 --- /dev/null +++ b/examples/portable_logic_speed/main.lua @@ -0,0 +1,17 @@ +-- A speed overlay can choose a multiplier or restore the host's saved option. +-- Link/fixed-speed locks and explicit launch overrides remain the host's choice. +local mod = ... +local override, seen = nil, { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.setSpeed(value) + override = value +end +mod.exports.stop = mod.hooks:wrap("core.logic_speed", function(next, game, ...) + seen.calls = seen.calls + 1 + seen.game, seen.extra = game, select("#", ...) + if override ~= nil then + return override + end + seen.vanilla = next(game) + return seen.vanilla +end) diff --git a/examples/portable_logic_speed/manifest.json b/examples/portable_logic_speed/manifest.json new file mode 100644 index 000000000..93d141c6f --- /dev/null +++ b/examples/portable_logic_speed/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_logic_speed", + "name": "Portable Logic Speed", + "version": "1.0.0", + "api": 3, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3, 4, 5], + "affects_link": false +} diff --git a/src/core/FixedStep.lua b/src/core/FixedStep.lua index 792df70a5..cc283db20 100644 --- a/src/core/FixedStep.lua +++ b/src/core/FixedStep.lua @@ -50,6 +50,12 @@ function FixedStep:update(dt) end end +-- End only the current accelerated frame. Unlike discardCatchup, this must +-- not suppress the next real-time frame after a speed lock becomes active. +function FixedStep:endFrame() + self.accum = 0 +end + -- Drop any pending catch-up steps and arm the one-frame clamp above. A -- hitch inside one logic step (map seam setMap / song start) makes the -- next real-time dt huge; without this the while-loop above would advance diff --git a/src/core/Game.lua b/src/core/Game.lua index 54c8ed21c..0c9fb1506 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -17,6 +17,11 @@ local Screens = require("src.ui.Screens") local Game = {} +-- The default must follow an explicit next(otherGame), not capture the caller. +local function resolveLogicSpeedVanilla(game) + return game:_resolveLogicSpeed() +end + -- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev -- module unloaded, so a player boot never touches a byte of dev code local devMode = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true @@ -107,7 +112,10 @@ function Game:load() -- apply the persisted audio + display options before anything plays self:applyOptions(self.save.options) - FixedStep:init(function(step) self:step(step) end) + FixedStep:init(function(step) + self:step(step) + self:_speedLockEdge() + end) self.fixedStep = FixedStep local OverworldState = require("src.world.OverworldController") @@ -377,24 +385,46 @@ function Game:_step(dt) -- it on its own real-time 60Hz accumulator instead. end --- The logic multiplier for this frame. Read live rather than cached so the --- Options row takes effect immediately; speedOverride is the --speed / --- POKEPORT_SPEED run argument, which wins over the saved option so a bot --- or screenshot run does not depend on whatever the player last chose. -function Game:logicSpeed() +-- Keep the native global option; the portable hook wraps resolution, not +-- engine-specific category settings or the speed ladder. +function Game:_resolveLogicSpeed() local GameSpeed = require("src.core.GameSpeed") - -- Link play is always 1X on both machines, and this wins over every other - -- source including POKEPORT_SPEED. Fast-forward multiplies the logic - -- clock, so a peer at 10X burned a tournament shot clock ten times faster - -- than the opponent it is racing, and drove its own animation/message - -- queue at a different rate than the peer it is locked to. Nothing about - -- a match should depend on what either player set this to. + local opts = self.save and self.save.options + return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT) +end + +function Game:speedLocked() if self.linkSession or (self.linkNet and not self.linkNet.closed) then - return 1 + return true, "link" + end + -- A menu on top must not hide a fixed-speed state underneath it. + local states = self.stack and self.stack.states or {} + for i = #states, 1, -1 do + local state = states[i] + if state and (state.isFixedSpeed or state.isMinigame) then + return true, "minigame" + end end + return false +end + +-- A link/fixed-speed state may start inside an accelerated logic step. Drop +-- the remaining budget now instead of running that state at the old speed. +function Game:_speedLockEdge() + if (self._frameSpeed or 1) > 1 and self:speedLocked() then + FixedStep:endFrame() + end +end + +-- Read live on every resolution. Hard locks win over --speed/POKEPORT_SPEED, +-- and those explicit overrides win over mods. Clamp the final hook result as +-- well as the native default so invalid output cannot poison the accumulator. +function Game:logicSpeed() + if self:speedLocked() then return 1 end + local GameSpeed = require("src.core.GameSpeed") if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end - local opts = self.save and self.save.options - return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT) + if not ModRuntime.wantsHook("core.logic_speed") then return self:_resolveLogicSpeed() end + return GameSpeed.clamp(ModRuntime.call("core.logic_speed", resolveLogicSpeedVanilla, self)) end -- THE FREE CAMERA, when one owns the frame -- otherwise nil. @@ -597,6 +627,7 @@ function Game:update(dt) -- or the anti-spiral clamp quietly caps every level above ~15X. self:updateCameraStick(dt) local speed = self:logicSpeed() + self._frameSpeed = speed FixedStep.maxAccum = math.max(0.25, speed * FixedStep.STEP * 1.5) do local close = FrameProfile.section(" update: fixed steps") diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 7c7a344f5..16bfd31bc 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -16,7 +16,7 @@ end -- need not fire on every action (e.g. an always-hit move skips accuracy). add("hooks", "12345", [[ battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action - battle.exp_award battle.low_health_alarm battle.run battle.turn_order catch.rate core.update + battle.exp_award battle.low_health_alarm battle.run battle.turn_order catch.rate core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 02b5d214e..6f0d48233 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -17,7 +17,7 @@ end -- every seam. The source-catalog test requires a decision for each new seam. C.hooks = names([[ battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award - battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate core.update + battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite render.compose render.hud diff --git a/tests/engine/logic_speed.lua b/tests/engine/logic_speed.lua new file mode 100644 index 000000000..cee2e37f0 --- /dev/null +++ b/tests/engine/logic_speed.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local Fixture = dofile("tests/logic_speed/fixture.lua") +local f = Fixture.new(".", "gen2", "red", 3, "unified") +dofile("tests/logic_speed/cases.lua").run(f) +f.close() +f.T.finish("portable core.logic_speed") diff --git a/tests/engine/logic_speed_clock.lua b/tests/engine/logic_speed_clock.lua new file mode 100644 index 000000000..1ed5faff4 --- /dev/null +++ b/tests/engine/logic_speed_clock.lua @@ -0,0 +1,161 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Game = require("src.core.Game") +local FixedStep = require("src.core.FixedStep") +local Runtime = require("src.mods.Runtime") +local Hooks = require("src.mods.Hooks") +local noop = function() end +local g = setmetatable({ + bootConfig = noop, + adoptSave = noop, + applyOptions = noop, + updateCameraStick = noop, +}, { __index = Game }) +local savedModules, savedUpvalues = {}, {} +local function module(name, value) + savedModules[name] = { package.loaded[name] } + package.loaded[name] = value +end +local function upvalue(name, value) + for i = 1, 100 do + local key, original = debug.getupvalue(Game.load, i) + if not key then + break + end + if key == name then + savedUpvalues[i] = original + debug.setupvalue(Game.load, i, value) + return + end + end + error("missing load upvalue: " .. name) +end +-- Stop real Game:load immediately after it installs its real FixedStep +-- callback. Unrelated ROM loading, rendering and save services are isolated. +upvalue("Data", { load = noop, applyMapEditorOverlay = noop, publishGen2Palettes = noop }) +upvalue("ModLoader", { + new = function() + return { load = noop, status = noop } + end, +}) +for _, name in ipairs({ "Input", "TouchControls", "Renderer" }) do + upvalue(name, { init = noop }) +end +upvalue("StateStack", { init = noop }) +upvalue("SaveData", { + loadOptions = noop, + newGame = function() + return { options = { speed = 4 } } + end, +}) +module("src.render.Pipelines", { install = noop }) +module("src.translation.Service", { install = noop }) +for _, name in ipairs({ "src.render.Font", "src.ui.Theme", "src.core.Strings" }) do + module(name, { load = noop }) +end +local init = FixedStep.init +local stop = {} +FixedStep.init = function(self, callback) + init(self, callback) + error(stop) +end +local ok, err = pcall(Game.load, g) +FixedStep.init = init +for index, value in pairs(savedUpvalues) do + debug.setupvalue(Game.load, index, value) +end +T.eq(ok, false, "test stops at the intended boot boundary") +T.eq(err, stop, "real Game:load reached FixedStep installation") +T.eq(type(FixedStep.callback), "function", "native step callback captured") + +local logic, audio, tilt, pipeline, camera = 0, 0, 0, 0, 0 +module("src.pokemon.Gen4PoketchState", { tick = noop }) +module("src.core.Music", { + update = function() + audio = audio + 1 + end, +}) +module("src.render.Tilt", { + update = function(dt) + tilt = tilt + dt + end, +}) +package.loaded["src.render.Pipelines"] = { + update = function(dt) + pipeline = pipeline + dt + end, +} +module("src.core.DiscordPresence", { update = noop }) +g.updateCameraStick = function(_, dt) + camera = camera + dt +end +local hooks, savedHooks = Hooks.new(), Runtime.hooks +Runtime.hooks = hooks +local polls, value = 0, 4 +local off = hooks:wrap("core.logic_speed", function(next, game) + T.eq(game, g, "native update passes its Game to speed hook") + polls = polls + 1 + return value +end) +g.stack = { states = {} } +local lockAt, lockKind +function g:step(dt) + logic = logic + 1 + T.eq(dt, FixedStep.STEP, "simulation retains fixed delta") + if logic == lockAt then + if lockKind == "link" then + self.linkSession = true + else + self.stack.states = { { isMinigame = true }, { isMenu = true } } + end + end +end +local function reset() + logic, audio, tilt, pipeline, camera = 0, 0, 0, 0, 0 + FixedStep.accum, FixedStep.suppressCatchup = 0, false + g.audioAccum, g.linkSession, g.stack.states = 0, nil, {} +end +for _, speed in ipairs({ 1, 4, 20, 100, 200 }) do + reset() + value = speed + local before = polls + g:update(FixedStep.STEP) + -- Floating point subtraction can leave the final step just below STEP. + T.check(logic >= speed - 1 and logic <= speed, "speed controls native logic budget") + T.eq(polls, before + 1, "one speed dispatch per Game:update, not per simulation step") + T.eq(audio, 1, "audio stays at real-time 60Hz") + T.eq(tilt, FixedStep.STEP, "tilt uses real delta") + T.eq(pipeline, FixedStep.STEP, "render pipelines use real delta") + T.eq(camera, FixedStep.STEP, "camera uses real delta") + T.eq(g._frameSpeed, speed, "native update records frame speed for lock edges") +end +for _, kind in ipairs({ "link", "fixed" }) do + reset() + value, lockAt, lockKind = 200, 3, kind + local before = polls + g:update(FixedStep.STEP) + T.eq(logic, 3, kind .. " starts mid-frame and cancels remaining accelerated ticks") + T.eq(FixedStep.accum, 0, kind .. " drains only the current budget") + T.eq(FixedStep.suppressCatchup, false, kind .. " does not arm hitch suppression") + T.eq(audio, 1, kind .. " leaves audio real-time") + g:update(FixedStep.STEP) + T.eq(logic, 4, kind .. " next frame runs at literal 1X") + T.eq(polls, before + 1, kind .. " locked next frame bypasses hook") + g.linkSession, g.stack.states = nil, {} + lockAt = nil + value = 4 + g:update(FixedStep.STEP) + T.check(logic >= 7 and logic <= 8, kind .. " unlock restores accelerated simulation") +end +reset() +value = 0 / 0 +g:update(FixedStep.STEP) +T.eq(logic, 1, "invalid mod speed cannot poison simulation clock") +T.eq(g._frameSpeed, 1, "invalid mod speed normalized before accumulation") +T.eq(audio, 1, "invalid mod speed cannot poison audio clock") +off() +Runtime.hooks = savedHooks +for name, original in pairs(savedModules) do + package.loaded[name] = original[1] +end +T.finish("core.logic_speed native clocks") diff --git a/tests/engine/logic_speed_routing.lua b/tests/engine/logic_speed_routing.lua new file mode 100644 index 000000000..c73cd17a3 --- /dev/null +++ b/tests/engine/logic_speed_routing.lua @@ -0,0 +1,76 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/logic_speed/fixture.lua") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local e, g, api = f.exports, f.game, f.public + local label = version .. "/" .. mode[1] .. "/" .. mode[2] .. ": " + T.eq(api:supports("hooks", "core.logic_speed"), true, label .. "native capability") + T.eq(g:logicSpeed(), 4, label .. "native option through hook") + T.eq(e.seen.game, g, label .. "opaque owner identity") + T.eq(e.seen.extra, 0, label .. "scalar signature") + T.eq(e.seen.calls, 1, label .. "one dispatch") + e.setSpeed(20) + T.eq(g:logicSpeed(), 20, label .. "override") + local calls = e.seen.calls + g.linkNet = { closed = false } + g.speedOverride = 100 + T.eq(g:logicSpeed(), 1, label .. "open link beats override") + T.eq(e.seen.calls, calls, label .. "link bypasses hook") + g.linkNet.closed = true + T.eq(g:logicSpeed(), 100, label .. "closed link restores explicit override") + T.eq(e.seen.calls, calls, label .. "explicit override bypasses hook") + g.speedOverride = nil + T.eq(g:logicSpeed(), 20, label .. "closed link permits hook") + for _, marker in ipairs({ "isFixedSpeed", "isMinigame" }) do + g.stack.states = { { [marker] = true }, { isMenu = true } } + calls = e.seen.calls + T.eq(g:logicSpeed(), 1, label .. marker .. " stays locked under a menu") + T.eq(select(2, g:speedLocked()), "minigame", label .. "lock reason") + T.eq(e.seen.calls, calls, label .. "fixed-speed bypasses hook") + end + g.stack = nil + T.eq(g:logicSpeed(), 20, label .. "missing stack is unlocked") + -- Loader ownership removes only the affected mod's contribution. + local peers = 0 + local off = f.result.loader.hooks:wrap("core.logic_speed", function(next, ...) + peers = peers + 1 + return next(...) + end, 10, "other_speed_owner") + f.result.loader.hooks:removeOwner("portable_logic_speed") + calls = e.seen.calls + T.eq(g:logicSpeed(), 4, label .. "owner cleanup restores native option") + T.eq(e.seen.calls, calls, label .. "owner cleanup retires consumer") + T.eq(peers, 1, label .. "other owner still runs") + off() + f.close() + end +end +local bare = Fixture.new(".", "gen2", "red", 3, "unified", true) +T.eq(Runtime.wantsHook("core.logic_speed"), false, "no-mod fast path") +T.eq(bare.game:logicSpeed(), 4, "no-mod native option") +bare.game.save = nil +T.eq(bare.game:logicSpeed(), 1, "no save uses native default") +bare.close() +local files = { + ["mods/broken_speed/manifest.json"] = [[{"id":"broken_speed","name":"Broken Speed", + "version":"1.0.0","api":3,"entry":"main.lua"}]], + ["mods/broken_speed/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("core.logic_speed", function() return 200 end) + error("intentional speed entry failure") + ]], +} +local failed = T.sdk.loadMods({ "mods/broken_speed" }, { fs = T.sdk.memfs(files), data = T.fixtures.fresh() }) +T.check(#failed.errors > 0, "failed entry is reported") +T.eq(Runtime.wantsHook("core.logic_speed"), false, "entry rollback removes speed hook") +Loader.teardown() +T.eq(Runtime.wantsHook("core.logic_speed"), false, "teardown removes speed hook") +failed.release() +Version.set(originalVersion) +T.finish("core.logic_speed routing") diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua index 5d4562cd8..7ad2d3214 100644 --- a/tests/engine/mod_supports.lua +++ b/tests/engine/mod_supports.lua @@ -75,8 +75,9 @@ for _, version in ipairs(GameVersion.ORDER) do T.check(registries, version .. ': all API-2 registration surfaces available') T.eq(yes(api, 'world', 'flyTo'), type(WorldAPI.flyTo) == 'function', version .. ': additive native flight interface is reported when installed') - T.check(no(api, 'hooks', 'core.logic_speed') - and no(api, 'hooks', 'item.use') and no(api, 'events', 'egg.hatched'), + T.check(yes(api, 'hooks', 'core.logic_speed'), + version .. ': native speed hook is advertised') + T.check(no(api, 'hooks', 'item.use') and no(api, 'events', 'egg.hatched'), version .. ': absent Gen1Recomp API-3 features are not advertised') T.check(yes(api, 'events', 'mod.probe.ready') and yes(api, 'events', 'mod.other.ready'), version .. ': namespaced user events do not need a producer installed') diff --git a/tests/logic_speed/cases.lua b/tests/logic_speed/cases.lua new file mode 100644 index 000000000..0ed362199 --- /dev/null +++ b/tests/logic_speed/cases.lua @@ -0,0 +1,196 @@ +local M = {} +function M.run(f) + local T, g, e, api = f.T, f.game, f.exports, f.public + local rows = {} + local function observe(label, expected) + local value = g:logicSpeed() + T.eq(value, expected, label) + rows[#rows + 1] = { label = label, speed = value } + end + if api.supports then + T.eq(api:supports("hooks", "core.logic_speed"), true, "native speed capability") + end + for _ = 1, 3 do + observe("native saved option", 4) + end + T.eq(e.seen.calls, 3, "one hook call per unlocked resolution") + T.eq(g.resolves, 3, "one native resolver per forwarded resolution") + T.eq(e.seen.game, g, "original opaque Game identity") + T.eq(e.seen.extra, 0, "no invented arguments") + T.eq(e.seen.vanilla, 4, "next returns native resolution") + for key in pairs(g.save.options) do + g.save.options[key] = 10 + end + observe("live saved option", 10) + for key in pairs(g.save.options) do + g.save.options[key] = 4 + end + e.setSpeed(20) + local resolves = g.resolves + observe("mod override", 20) + T.eq(g.resolves, resolves, "omitting next skips native resolver") + e.setSpeed(nil) + + -- Clamp at the producer boundary, including nil/false and non-finite values. + for _, case in ipairs({ + { name = "nil", expected = 1 }, + { name = "false", value = false, expected = 1 }, + { name = "NaN", value = 0 / 0, expected = 1 }, + { name = "infinity", value = math.huge, expected = 1 }, + { name = "negative infinity", value = -math.huge, expected = 1 }, + { name = "zero", value = 0, expected = 1 }, + { name = "negative", value = -20, expected = 1 }, + { name = "table", value = {}, expected = 1 }, + { name = "string", value = "20", expected = 20 }, + { name = "large", value = 1e9, expected = 200 }, + }) do + local off = api.hooks:wrap("core.logic_speed", function() + return case.value + end, 100) + observe(case.name .. " hook output", case.expected) + off() + end + -- Each host retains its own ladder (Gen1 includes 3X; Gen2 does not). + local off = api.hooks:wrap("core.logic_speed", function() + return 3 + end, 100) + T.eq(g:logicSpeed(), f.backend == "gen1" and 3 or 2, "native ladder preserved") + off() + local other = f.owner(10) + off = api.hooks:wrap("core.logic_speed", function(next, game) + T.eq(game, g, "outer wrapper sees original Game") + return next(other) + end, 100) + resolves = g.resolves + observe("replacement next target", 10) + T.eq(other.resolves, 1, "native resolver uses replacement Game") + T.eq(g.resolves, resolves, "original Game is not resolved after replacement") + T.eq(e.seen.game, other, "replacement reaches downstream wrapper") + off() + local order = {} + local outer = api.hooks:wrap("core.logic_speed", function(next) + order[#order + 1] = "outer" + local result = next() + order[#order + 1] = "after" + return result * 5 + end, 20) + local inner = api.hooks:wrap("core.logic_speed", function(next, game) + order[#order + 1] = "inner" + T.eq(game, g, "next() retains current owner") + return next() + end, 10) + observe("priority and postprocessing", 20) + T.same(order, { "outer", "inner", "after" }, "descending wrapper priority") + outer() + inner() + + e.setSpeed(200) + g.speedOverride = 20 + local calls = e.seen.calls + resolves = g.resolves + observe("explicit override wins", 20) + T.eq(e.seen.calls, calls, "explicit override bypasses hook") + T.eq(g.resolves, resolves, "explicit override bypasses saved option") + for _, kind in ipairs({ "link", "fixed" }) do + f.lock(kind) + T.eq(g:speedLocked(), true, kind .. " native lock detected") + observe(kind .. " wins over explicit override", 1) + g.speedOverride = nil + observe(kind .. " wins over hook", 1) + T.eq(e.seen.calls, calls, kind .. " hook is never called") + T.eq(g.resolves, resolves, kind .. " saved option is not resolved") + f.lock(nil) + observe(kind .. " unlock restores mod", 200) + calls = e.seen.calls + g.speedOverride = 20 + end + g.speedOverride = nil + f.lock(nil) + e.setSpeed(nil) + + for _, after in ipairs({ false, true }) do + off = api.hooks:wrap("core.logic_speed", function(next) + if after then + next() + end + error("intentional speed wrapper failure") + end, 100) + resolves, calls = g.resolves, e.seen.calls + observe(after and "error after next" or "error before next", 4) + T.eq(g.resolves, resolves + 1, "wrapper failure resolves native exactly once") + T.eq(e.seen.calls, calls + 1, "wrapper failure traverses consumer once") + off() + end + local original = g._resolveLogicSpeed + local attempts = 0 + g._resolveLogicSpeed = function() + attempts = attempts + 1 + error("native speed resolver failure") + end + T.raises(function() + g:logicSpeed() + end, "native speed resolver failure", "native errors escape") + T.eq(attempts, 1, "native error is not retried") + g._resolveLogicSpeed = original + if f.api >= 3 then + off = api.hooks:wrap("core.logic_speed", function(next) + next() + return next() + end, 100) + resolves = g.resolves + observe("API-3 cached continuation", 4) + T.eq(g.resolves, resolves + 1, "API-3 repeated next caches native result") + off() + end + -- Gen1's GBA boot phases are a separate native restriction. Gen2's + -- titles/attract screens retain their own timing; do not invent that host. + if f.backend == "gen1" and f.generation == 3 then + local Boot = require("src.ui.game3.boot") + g.phase = "boot" + calls = e.seen.calls + for _, phase in ipairs({ + Boot.PHASE.INTRO, + Boot.PHASE.TITLE, + Boot.PHASE.TITLE_CRY, + Boot.PHASE.TITLE_RESTART, + }) do + g.boot = { phase = phase } + T.eq(g:logicSpeed(), 1, "Gen1 GBA boot cinematic lock") + T.eq(e.seen.calls, calls, "Gen1 GBA boot bypasses hook") + end + g.speedOverride = 20 + T.eq(g:logicSpeed(), 20, "Gen1 GBA explicit override retains boot precedence") + g.speedOverride, g.phase = nil, "field" + end + e.stop() + e.stop() + calls = e.seen.calls + observe("unsubscribed native fallback", 4) + T.eq(e.seen.calls, calls, "unsubscribe is idempotent") + -- The actual unwrapped resolver and launch override keep host-native + -- levels too. The shared contract does not add a 3X rung to Gen2. + for key in pairs(g.save.options) do + g.save.options[key] = 3 + end + T.eq(g:logicSpeed(), f.backend == "gen1" and 3 or 2, "unsubscribed native ladder") + g.speedOverride = 3 + T.eq(g:logicSpeed(), f.backend == "gen1" and 3 or 2, "explicit native ladder") + g.speedOverride = nil + for key in pairs(g.save.options) do + g.save.options[key] = 4 + end + local peerCalls = 0 + api.hooks:wrap("core.logic_speed", function() + return 200 + end, 100) + local peer = f.result.loader.hooks:wrap("core.logic_speed", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) + end, 10, "other_speed_owner") + f.result.loader.hooks:removeOwner("portable_logic_speed") + observe("owner cleanup restores native option", 4) + T.eq(peerCalls, 1, "owner cleanup preserves peer wrapper") + peer() + return rows +end +return M diff --git a/tests/logic_speed/fixture.lua b/tests/logic_speed/fixture.lua new file mode 100644 index 000000000..66c7b4732 --- /dev/null +++ b/tests/logic_speed/fixture.lua @@ -0,0 +1,95 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local generation = Version.generation() + local files = {} + for _, path in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_logic_speed/" .. path, "rb")) + files["mods/portable_logic_speed/" .. path] = file:read("*a") + file:close() + end + local Json = require("src.link.Json") + local manifest = assert(Json.decode(files["mods/portable_logic_speed/manifest.json"])) + manifest.api, manifest.callback_format = api, format + files["mods/portable_logic_speed/manifest.json"] = Json.encode(manifest) + local data = backend == "gen1" and generation == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_logic_speed" }, { + data = data, + generation = generation, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local module = "src.core.Game" + if backend == "gen1" and generation > 1 then + module = module .. generation + end + local Game = require(module) + local function owner(speed) + local opts = { speed = speed, speedOverworld = speed, speedBattle = speed, speedMenu = speed } + local game = setmetatable({ + generation = generation, + phase = "field", + data = data, + mods = result.loader, + save = { options = opts }, + options = opts, + stack = { states = { { isOverworld = true } } }, + resolves = 0, + }, { __index = Game }) + function game:_resolveLogicSpeed() + self.resolves = self.resolves + 1 + return Game._resolveLogicSpeed(self) + end + return game + end + local g = owner(4) + -- Host-specific state fixtures exercise the actual native speedLocked method. + local keys = { "src.core.game3.link", "src.core.game3.minigames.common" } + local saved = {} + for _, key in ipairs(keys) do + saved[key] = package.loaded[key] + end + local function lock(kind) + g.linkSession, g.linkNet = nil, nil + g.stack.states = { { isOverworld = true } } + if backend == "gen1" and generation == 3 then + package.loaded[keys[1]] = { link = kind == "link" and true or nil } + package.loaded[keys[2]] = { + isActive = function() + return kind == "fixed" + end, + } + elseif kind == "link" then + g.linkSession = true + elseif kind == "fixed" then + g.stack.states = { { isFixedSpeed = true }, { isMenu = true } } + end + end + local exports = result.loader.exports.portable_logic_speed + return { + T = T, + result = result, + game = g, + owner = owner, + lock = lock, + backend = backend, + generation = generation, + api = api, + format = format, + exports = exports, + public = exports and exports.api, + close = function() + for _, key in ipairs(keys) do + package.loaded[key] = saved[key] + end + if result.loader._closeModInput then + result.loader:_closeModInput() + end + result.release() + end, + } +end +return M diff --git a/tests/logic_speed/worker.lua b/tests/logic_speed/worker.lua new file mode 100644 index 000000000..1b1d47663 --- /dev/null +++ b/tests/logic_speed/worker.lua @@ -0,0 +1,11 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local f = dofile(root .. "/tests/logic_speed/fixture.lua").new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/logic_speed/cases.lua").run(f) +f.close() +local bare = dofile(root .. "/tests/logic_speed/fixture.lua").new(root, backend, version, api, format, true) +bare.T.eq(require("src.mods.Runtime").wantsHook("core.logic_speed"), false, "no-mod runtime") +bare.T.eq(bare.game:logicSpeed(), 4, "no-mod native resolver") +bare.close() +print("LOGIC_SPEED_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable core.logic_speed contract") diff --git a/tools/logic-speed-contract-check/check.py b/tools/logic-speed-contract-check/check.py new file mode 100644 index 000000000..73482836e --- /dev/null +++ b/tools/logic-speed-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged speed mod against both engines' native logicSpeed methods.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/logic_speed/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("LOGIC_SPEED_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("LOGIC_SPEED_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"logic speed mismatch: {version}" + print(f"PASS {version}: matching native logic speed across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_logic_speed/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From af4571fa9037273879229c42c9f0219f016ddffa Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:53:05 +0200 Subject: [PATCH 024/138] Add portable screen.render_visible dispatch and cross-engine contracts --- .../workflows/screen-visibility-contract.yml | 45 ++++++ docs/modding.md | 6 + docs/portable-screen-visibility.md | 98 ++++++++++++ examples/portable_screen_visibility/main.lua | 16 ++ .../portable_screen_visibility/manifest.json | 10 ++ src/core/Game.lua | 15 +- src/core/StateStack.lua | 30 +++- src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 2 +- tests/engine/screen_visibility.lua | 5 + tests/engine/screen_visibility_routing.lua | 113 +++++++++++++ tests/screen_visibility/cases.lua | 150 ++++++++++++++++++ tests/screen_visibility/fixture.lua | 148 +++++++++++++++++ tests/screen_visibility/worker.lua | 13 ++ .../screen-visibility-contract-check/check.py | 58 +++++++ 15 files changed, 701 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/screen-visibility-contract.yml create mode 100644 docs/portable-screen-visibility.md create mode 100644 examples/portable_screen_visibility/main.lua create mode 100644 examples/portable_screen_visibility/manifest.json create mode 100644 tests/engine/screen_visibility.lua create mode 100644 tests/engine/screen_visibility_routing.lua create mode 100644 tests/screen_visibility/cases.lua create mode 100644 tests/screen_visibility/fixture.lua create mode 100644 tests/screen_visibility/worker.lua create mode 100644 tools/screen-visibility-contract-check/check.py diff --git a/.github/workflows/screen-visibility-contract.yml b/.github/workflows/screen-visibility-contract.yml new file mode 100644 index 000000000..f616e6f26 --- /dev/null +++ b/.github/workflows/screen-visibility-contract.yml @@ -0,0 +1,45 @@ +name: portable screen.render_visible contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: screen-visibility-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native composition and input/update ownership + run: | + luajit tests/engine/screen_visibility.lua + luajit tests/engine/screen_visibility_routing.lua + - name: Compare native API 1 and 2 + run: | + mkdir -p /tmp/screen-visibility-contract + python3 tools/screen-visibility-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/screen-visibility-contract/api1-native.json + python3 tools/screen-visibility-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/screen-visibility-contract/api2-native.json + - name: Compare unified callbacks and API 3 + run: | + python3 tools/screen-visibility-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/screen-visibility-contract/api2-unified.json + python3 tools/screen-visibility-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/screen-visibility-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: screen-visibility-contract + path: /tmp/screen-visibility-contract + if-no-files-found: ignore diff --git a/docs/modding.md b/docs/modding.md index 2cc59fa3f..6a3fa5f76 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -76,6 +76,12 @@ update `src/mods/Capabilities.lua` and the capability tests in the same change. Additional read-only world summaries: [map overview and encounter queries](world-overview.md). +## Per-state main-screen visibility + +The [portable screen visibility contract](portable-screen-visibility.md) +describes `screen.render_visible(next, state)`: hide a native state's main +render, opacity and palette ownership while keeping its update/input owner. + ## Logic speed hook The [portable logic speed contract](portable-logic-speed.md) documents diff --git a/docs/portable-screen-visibility.md b/docs/portable-screen-visibility.md new file mode 100644 index 000000000..50f6672d3 --- /dev/null +++ b/docs/portable-screen-visibility.md @@ -0,0 +1,98 @@ +# Portable state visibility predicate + +Gen2Recomped dispatches `screen.render_visible(next, state)` through native +API 1/2, unified API 2 and API 3 on every supported host. A companion or custom +UI can mirror a native state elsewhere while omitting its main-screen render: + +```lua +local mod = ... +local mirrored = false +mod.hooks:wrap("screen.render_visible", function(next, state) + if mirrored and state.screenId == "BagMenu" then return false end + return next(state) +end) +``` + +The default is `true`. Only a literal first result of `false` hides a state; +nil, zero, strings and tables do not. `next()` retains current arguments; +`next(state)` forwards the supplied state to downstream predicates. The native +default ignores that argument and returns true. These returns do not consume +input, pop a screen, pause simulation or replace the state that is drawn. + +Treat this as a pure predicate. Composition may ask about the same state +several times, including opacity selection, the main draw loop and palette +selection. Do not mutate states, open/close screens or depend on a specific +number of calls. Use descriptive fields such as `screenId` or `kind`, checking +which identities the host's actual screen exposes. This hook does not establish +a new cross-generation catalog of screen IDs. + +A hidden state: + +- Does not draw through `StateStack:draw` or the main Game composition loop. +- Cannot be the topmost opaque render base, a world-backdrop opaque fallback, + or the owner of main-screen UI/world palette zones. +- Stays in the same stack position and keeps normal update/input ownership. + Native covered-state animation still uses the original opacity rules; + simulation and keyboard handling never evaluate this render predicate. + +The hook controls individual main-screen states, not the whole output window +or a separate device display. It does not change whole-stack surface sizing, +fill/anchor preferences or native lifecycle semantics. Hiding an empty/sole +state draws no state; it does not promise a particular clear color. Separate +external world passes remain subject to their native host routing. + +`mod:supports("hooks", "screen.render_visible")` reports the real Gen2 producer, +including during mod entry before a Game is created. Unsubscribed calls return +true directly, without allocating a predicate closure or payload. Existing +priority, cleanup, rollback and error rules apply. A wrapper failure before +forwarding falls through; one after forwarding preserves the downstream +result. Native draw failures propagate and are not retried. Unified +continuations retain their existing caching rules. + +## Reference boundaries + +The verified Gen1Recomp reference is clean commit +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Its Gen1 and Gold-family games +use the common StateStack producer. Its separate GBA UI stack does **not** +dispatch this predicate, despite listing the name in its callback catalog. +Gen2Recomped uses its common native stack for all five generations, so this +addition is also available on its GBA/DS hosts. The shared tests compare Red, +Yellow, Gold and Crystal; the Gen2-only matrix covers every supported version. +No Gen1 sources are modified to make the contract pass. + +Native Gen1 API 1/2 passes the original state; Gen1 API 3 currently supplies a +shallow context view and can restore edits through its generic adapter. +Gen2 passes the original opaque state in all four tested modes. Therefore +table identity, arbitrary state edits, metatable access and replacement-state +mutation are **not** shared guarantees. The same pure, property-based consumer +runs on both engines unchanged. Tests assert each host's existing identity +behavior separately. They do not retrofit Gen1's adapter into Gen2 or mask +that remaining distinction. Gen1 API 2 ignores the unified opt-in, so that +comparison uses native Gen1 API 2 against unified Gen2 API 2. + +Gen1 Gold draws its overworld outside the state stack and has its own native +widescreen/palette routing. Common journals compare state draw order and render +base, not external-world draw counts or palette implementations that differ +between hosts. The Gen1/Gen2 palette paths that use the shared Renderer are +asserted separately. + +## Verification + +```sh +luajit tests/engine/screen_visibility.lua +luajit tests/engine/screen_visibility_routing.lua +python3 tools/screen-visibility-contract-check/check.py --gen1 /path/to/gen1recomp --api 1 +python3 tools/screen-visibility-contract-check/check.py --gen1 /path/to/gen1recomp +python3 tools/screen-visibility-contract-check/check.py --gen1 /path/to/gen1recomp --format unified +python3 tools/screen-visibility-contract-check/check.py --gen1 /path/to/gen1recomp --api 3 --format unified +``` + +The checker rejects a dirty/unexpected reference and runs the same committed +mod through both native loaders. It invokes the real StateStack and Game draw +methods (including native Gold `drawScene`) with ROM-free states and isolated +Renderer presentation methods. The Gen2 matrix also checks animation/input +ownership, world palettes/clear ownership, hidden backdrop states, no-mod fast +paths, owner removal and failed-entry rollback. The CI workflow pins the same +reference and publishes machine-readable contract reports. These headless +checks do not claim a cartridge playthrough, actual mirrored-device output +or GPU pixel equivalence. diff --git a/examples/portable_screen_visibility/main.lua b/examples/portable_screen_visibility/main.lua new file mode 100644 index 000000000..63ab03435 --- /dev/null +++ b/examples/portable_screen_visibility/main.lua @@ -0,0 +1,16 @@ +-- A companion can mirror a native menu while its original state keeps input. +-- Keep the predicate pure: composition may ask about a state repeatedly. +local mod = ... +local hidden, seen = {}, { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.hide(screenId, value) + hidden[screenId] = value == true +end +mod.exports.stop = mod.hooks:wrap("screen.render_visible", function(next, state, ...) + seen.calls = seen.calls + 1 + seen.state, seen.extra = state, select("#", ...) + if hidden[state.screenId] then + return false + end + return next(state) +end) diff --git a/examples/portable_screen_visibility/manifest.json b/examples/portable_screen_visibility/manifest.json new file mode 100644 index 000000000..ec5483f0e --- /dev/null +++ b/examples/portable_screen_visibility/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_screen_visibility", + "name": "Portable Screen Visibility", + "version": "1.0.0", + "api": 3, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3, 4, 5], + "affects_link": false +} diff --git a/src/core/Game.lua b/src/core/Game.lua index 0c9fb1506..3b18cf1aa 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -17,6 +17,11 @@ local Screens = require("src.ui.Screens") local Game = {} +-- Legacy/tool stack doubles may not expose the optional visibility method. +local function renderVisible(stack, state) + return state ~= nil and (not stack.renderVisible or stack:renderVisible(state)) +end + -- The default must follow an explicit next(otherGame), not capture the caller. local function resolveLogicSpeedVanilla(game) return game:_resolveLogicSpeed() @@ -856,11 +861,11 @@ function Game.drawBaseInStack(stack, visibleBase) local states = stack and stack.states or {} for i = visibleBase - 1, 1, -1 do local state = states[i] - if state and state.bgMode and state:bgMode() == "world" then + if renderVisible(stack, state) and state.bgMode and state:bgMode() == "world" then -- restart the search from under the battle: the highest opaque state at -- or below it (the overworld), not the menu sitting over it for j = i, 1, -1 do - if states[j].isOpaque then return j end + if renderVisible(stack, states[j]) and states[j].isOpaque then return j end end return 1 end @@ -1041,10 +1046,12 @@ function Game:_draw() -- white clear local base = self.stack:visibleBase() local worldBelow = self.stack.states[base] == self.overworld + and renderVisible(self.stack, self.overworld) -- a world-bg battle keeps the map drawing under whatever it opened, so the -- world pass can run for a frame whose CLEAR is still an opaque menu's local drawFrom = Game.drawBaseInStack(self.stack, base) local worldDrawn = self.stack.states[drawFrom] == self.overworld + and renderVisible(self.stack, self.overworld) -- A wide battle holds its 304px surface through every menu or prompt it -- opens. States that do not draw the wide battle composition are centred -- in that surface below, so their classic coordinates and hit testing stay @@ -1120,7 +1127,7 @@ function Game:_draw() local ownsSurface = wideState or (native ~= nil and state == native) or Game.wantsThisSurface(state) - if state and state.draw then + if renderVisible(self.stack, state) and state.draw then -- ...and it is centred by ITS OWN size, not by the Game Boy's (see -- Game.homeOffset). classicOffset still gates it: a surface that is -- the Game Boy's centres nothing, which is every frame outside a wide @@ -1145,7 +1152,7 @@ function Game:_draw() local zones, worldZones, zoneOwner for i = #self.stack.states, 1, -1 do local s = self.stack.states[i] - if s.sgbPalettes then + if renderVisible(self.stack, s) and s.sgbPalettes then zones = s:sgbPalettes(self) zoneOwner = s break diff --git a/src/core/StateStack.lua b/src/core/StateStack.lua index baf622c4c..00e8f8390 100644 --- a/src/core/StateStack.lua +++ b/src/core/StateStack.lua @@ -172,26 +172,48 @@ end -- update opens the menu on A, and running that under the menu would open a -- second one on the same press) and it must not push, pop or finish. A state -- that wants both puts the motion in animate and calls it from update. +-- Update/covered animation follows native opacity, regardless of rendering. +-- Hiding a mirrored menu must not animate newly exposed states during logic. +local function opaqueBase(states) + for i = #states, 1, -1 do + if states[i].isOpaque then return i end + end + return 1 +end + function StateStack:update(dt) local top = self:top() if top and top.update then top:update(dt) end - for i = self:visibleBase(), #self.states - 1 do + for i = opaqueBase(self.states), #self.states - 1 do local state = self.states[i] if state.animate then state:animate(dt) end end end --- index of the lowest state drawn this frame (highest opaque, else 1) +local function visibleByDefault() return true end + +-- A pure main-screen predicate: only literal false hides the native state. +-- Update/input ownership and the stored stack remain unchanged. +function StateStack:renderVisible(state) + if not state then return false end + if not Runtime.wantsHook("screen.render_visible") then return true end + return Runtime.call("screen.render_visible", visibleByDefault, state) ~= false +end + +-- index of the lowest state drawn this frame (highest visible opaque, else 1) function StateStack:visibleBase() + if not Runtime.wantsHook("screen.render_visible") then return opaqueBase(self.states) end for i = #self.states, 1, -1 do - if self.states[i].isOpaque then return i end + local state = self.states[i] + if self:renderVisible(state) and state.isOpaque then return i end end return 1 end function StateStack:draw() for i = self:visibleBase(), #self.states do - if self.states[i].draw then self.states[i]:draw() end + local state = self.states[i] + if self:renderVisible(state) and state.draw then state:draw() end end end diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 16bfd31bc..45c919cb2 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -20,7 +20,7 @@ add("hooks", "12345", [[ encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite - render.compose render.hud render.letterbox render.zones save.new_game save.write + render.compose render.hud render.letterbox render.zones save.new_game save.write screen.render_visible script.command trainer.party transition.style ui.list_menu ui.options.rows ui.title_menu.items warp.destination world.tod zoom.range ]]) diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 6f0d48233..c31c7be4e 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -21,7 +21,7 @@ C.hooks = names([[ encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite render.compose render.hud - render.letterbox render.zones save.new_game save.write script.command trainer.party transition.style + render.letterbox render.zones save.new_game save.write screen.render_visible script.command trainer.party transition.style ui.list_menu ui.naming.grid ui.options.rows ui.party.submenu ui.pc.items ui.start_menu.items ui.title_menu.items warp.destination world.tod zoom.range ]]) diff --git a/tests/engine/screen_visibility.lua b/tests/engine/screen_visibility.lua new file mode 100644 index 000000000..aa8858b16 --- /dev/null +++ b/tests/engine/screen_visibility.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local f = dofile("tests/screen_visibility/fixture.lua").new(".", "gen2", "red", 3, "unified") +dofile("tests/screen_visibility/cases.lua").run(f) +f.close() +f.T.finish("portable screen.render_visible") diff --git a/tests/engine/screen_visibility_routing.lua b/tests/engine/screen_visibility_routing.lua new file mode 100644 index 000000000..f3e84272d --- /dev/null +++ b/tests/engine/screen_visibility_routing.lua @@ -0,0 +1,113 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/screen_visibility/fixture.lua") +local Cases = dofile("tests/screen_visibility/cases.lua") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Loader = require("src.mods.Loader") +local Game = require("src.core.Game") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local e, stack, base, menu, overlay = f.exports, f.stack, f.base, f.menu, f.overlay + e.hide("BagMenu", true) + local baseAnimations, menuAnimations = 0, 0 + base.animate = function() + baseAnimations = baseAnimations + 1 + end + menu.animate = function() + menuAnimations = menuAnimations + 1 + end + stack.states = { base, menu, overlay } + local polls = e.seen.calls + stack:update(1 / 60) + T.eq(baseAnimations, 0, "hidden opaque menu still covers lower simulation animation") + T.eq(menuAnimations, 1, "hidden covered menu retains native animation") + T.eq(overlay.updates, 1, "visible overlay retains update ownership") + T.eq(e.seen.calls, polls, "animation does not invoke render predicate") + base.animate, menu.animate = nil, nil + stack.states = { base, menu } + local key + menu.onKeyPressed = function(_, value) + key = value + end + f.game:keypressed("screen-contract-key") + T.eq(key, "screen-contract-key", "hidden menu retains native keyboard dispatch") + T.eq(e.seen.calls, polls, "keyboard does not invoke render predicate") + menu.onKeyPressed = nil + local worldPalettes = 0 + base.sgbWorldZones = function() + worldPalettes = worldPalettes + 1 + return "world zones" + end + local trace, zones, worldZones, clearWorld = f.frame({ base, menu }) + T.same(trace, { "Base" }, "world below hidden opaque menu is drawn") + T.eq(zones, "Base zones", "hidden menu cannot claim UI palettes") + T.eq(worldZones, "world zones", "visible world retains separate palettes") + T.eq(clearWorld, true, "visible world retains transparent UI clear") + e.hide("Base", true) + trace, zones, worldZones, clearWorld = f.frame({ base, menu }) + T.same(trace, {}, "all hidden states draw nothing") + T.eq(zones, nil, "all hidden states claim no UI palette") + T.eq(worldZones, nil, "hidden world cannot claim world palette") + T.eq(worldPalettes, 1, "hidden world palette provider is never invoked") + T.eq(clearWorld, false, "hidden world cannot claim clear ownership") + e.hide("Base", false) + -- World-backed battles can extend drawing below the opaque base. Their + -- fallback search must not select a hidden opaque state as that base. + local middle = f.state("BagMenu", true) + local battle = f.state("Battle", false) + battle.bgMode = function() + return "world" + end + overlay.isOpaque = true + stack.states = { base, middle, battle, overlay } + T.eq(Game.drawBaseInStack(stack, 4), 1, "backdrop search skips hidden opaque states") + e.hide("Battle", true) + T.eq(Game.drawBaseInStack(stack, 4), 4, "hidden battle does not extend native backdrop") + e.hide("Battle", false) + overlay.isOpaque = false + base.sgbWorldZones = nil + e.hide("BagMenu", false) + base.updates, menu.updates, menu.presses, overlay.updates = 0, 0, 0, 0 + Cases.run(f) + f.close() + end +end +local bare = Fixture.new(".", "gen2", "red", 3, "unified", true) +local originalCall, calls = Runtime.call, 0 +Runtime.call = function(name, ...) + if name == "screen.render_visible" then + calls = calls + 1 + end + return originalCall(name, ...) +end +T.eq(Runtime.wantsHook("screen.render_visible"), false, "no-mod runtime") +T.eq(bare.stack:renderVisible(nil), false, "nil remains hidden without mods") +T.eq(bare.stack:renderVisible(bare.menu), true, "no-mod default true") +T.same(bare.frame({ bare.base, bare.menu }), { "BagMenu" }, "no-mod native main render") +bare.stack:draw() +T.eq(calls, 0, "unsubscribed predicate never dispatches Runtime.call") +Runtime.call = originalCall +bare.close() +local files = { + ["mods/broken_screen/manifest.json"] = [[{"id":"broken_screen","name":"Broken Screen", + "version":"1.0.0","api":3,"entry":"main.lua"}]], + ["mods/broken_screen/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("screen.render_visible", function() return false end) + error("intentional visibility entry failure") + ]], +} +local failed = T.sdk.loadMods( + { "mods/broken_screen" }, + { fs = T.sdk.memfs(files), data = T.fixtures.fresh() } +) +T.check(#failed.errors > 0, "failed entry reported") +T.eq(Runtime.wantsHook("screen.render_visible"), false, "rollback removes hidden-state predicate") +Loader.teardown() +T.eq(Runtime.wantsHook("screen.render_visible"), false, "teardown removes hidden-state predicate") +failed.release() +Version.set(originalVersion) +T.finish("screen.render_visible native routing") diff --git a/tests/screen_visibility/cases.lua b/tests/screen_visibility/cases.lua new file mode 100644 index 000000000..c169023b6 --- /dev/null +++ b/tests/screen_visibility/cases.lua @@ -0,0 +1,150 @@ +local M = {} +function M.run(f) + local T, stack, e, api = f.T, f.stack, f.exports, f.public + local base, menu, overlay = f.base, f.menu, f.overlay + local rows = {} + local function draw(label, states, expected, palette) + local trace, zones = f.frame(states) + T.same(trace, expected, label .. " native draw order") + if f.palettePath then + T.eq(zones, palette, label .. " visible palette owner") + end + T.same(stack.states, states, label .. " rendering does not rewrite stack") + rows[#rows + 1] = { label = label, drawn = trace, base = stack:visibleBase() } + end + if api.supports then + T.eq(api:supports("hooks", "screen.render_visible"), true, "native capability") + end + T.eq(stack:renderVisible(nil), false, "nil is not a renderable state") + draw("native opaque menu", { base, menu }, { "BagMenu" }, "BagMenu zones") + T.eq(e.seen.extra, 0, "opaque state signature has no invented args") + T.eq(e.seen.state.screenId, menu.screenId, "native screen identifier reaches public hook") + T.eq(e.seen.state == menu, f.stateIdentity, "host-native state identity semantics") + e.hide("BagMenu", true) + draw("mirrored menu", { base, menu }, { "Base" }, "Base zones") + T.same(f.stackFrame({ base, menu }), { "Base" }, "native StateStack:draw hides mirrored menu") + T.eq(stack:top(), menu, "hidden menu remains the native top") + local before, baseUpdates, presses = e.seen.calls, base.updates, menu.presses + f.input:keypressed("z") + f.input:step() + stack:update(1 / 60) + T.eq(menu.updates, 1, "hidden menu keeps update ownership") + T.eq(base.updates, baseUpdates, "world below does not steal update ownership") + T.eq(menu.presses, presses + 1, "hidden menu receives native Input press") + T.eq(e.seen.calls, before, "simulation never queries the render predicate") + f.input:keyreleased("z") + f.input:step() + draw("overlay above mirrored menu", { base, menu, overlay }, { "Base", "TextBox" }, "TextBox zones") + local hiddenChild = f.state("BagMenu", true) + draw("multiple mirrored menus", { base, menu, hiddenChild }, { "Base" }, "Base zones") + local replacement = {} + for key, value in pairs(base) do + replacement[key] = value + end + replacement.contractMarker = "replacement" + + local off = api.hooks:wrap("screen.render_visible", function(next, state) + T.eq(state.screenId, base.screenId, "outer predicate sees actual state fields") + return next(replacement) + end, 100) + T.eq(stack:renderVisible(base), true, "next(replacement) reaches downstream predicate") + T.eq(e.seen.state.contractMarker, "replacement", "explicit next forwards replacement fields") + off() + base.contractMarker = nil + -- Only literal false hides. No boolean coercion or special consume value. + for _, case in ipairs({ + { name = "nil" }, + { name = "true", value = true }, + { name = "zero", value = 0 }, + { name = "empty string", value = "" }, + { name = "table", value = {} }, + }) do + off = api.hooks:wrap("screen.render_visible", function() + return case.value + end, 100) + T.eq(stack:renderVisible(menu), true, case.name .. " remains visible") + off() + end + off = api.hooks:wrap("screen.render_visible", function() + return false, true + end, 100) + T.eq(stack:renderVisible(base), false, "only first result controls visibility") + off() + local order = {} + local outer = api.hooks:wrap("screen.render_visible", function(next) + order[#order + 1] = "outer" + local value = next() + order[#order + 1] = "after" + return value + end, 20) + local inner = api.hooks:wrap("screen.render_visible", function(next, state) + order[#order + 1] = "inner" + T.eq(state.screenId, base.screenId, "next() keeps current state fields") + return next() + end, 10) + T.eq(stack:renderVisible(base), true, "default true passes through public wrappers") + T.same(order, { "outer", "inner", "after" }, "priority and next ordering") + outer() + inner() + for _, after in ipairs({ false, true }) do + off = api.hooks:wrap("screen.render_visible", function(next) + if after then + next() + end + error("intentional screen predicate failure") + end, 100) + T.eq(stack:renderVisible(menu), false, "wrapper failure preserves downstream false") + off() + end + if f.api >= 3 then + off = api.hooks:wrap("screen.render_visible", function(next) + next() + return next() + end, 100) + before = e.seen.calls + T.eq(stack:renderVisible(base), true, "API-3 repeated next result") + T.eq(e.seen.calls, before + 1, "API-3 next caches downstream predicate") + off() + end + draw("hidden sole menu", { menu }, {}, nil) + draw("empty stack", {}, {}, nil) + local nativeDraw, attempts = menu.draw, 0 + menu.draw = function() + attempts = attempts + 1 + error("native screen draw failure") + end + f.frame({ base, menu }) + T.eq(attempts, 0, "hidden native drawer is never invoked") + e.hide("BagMenu", false) + T.raises(function() + f.frame({ base, menu }) + end, "native screen draw failure", "native draw errors escape") + T.eq(attempts, 1, "native draw failure is not retried") + menu.draw = nativeDraw + e.hide("BagMenu", true) + + e.hide("Base", true) + e.hide("TextBox", true) + draw("all hidden", { base, menu, overlay }, {}, nil) + e.hide("Base", false) + e.hide("TextBox", false) + e.stop() + e.stop() + before = e.seen.calls + draw("unsubscribed", { base, menu }, { "BagMenu" }, "BagMenu zones") + T.eq(e.seen.calls, before, "unsubscribe is idempotent") + api.hooks:wrap("screen.render_visible", function() + return false + end, 100) + local peers = 0 + local peer = f.result.loader.hooks:wrap("screen.render_visible", function(next, ...) + peers = peers + 1 + return next(...) + end, 10, "other_screen_owner") + f.result.loader.hooks:removeOwner("portable_screen_visibility") + T.eq(stack:renderVisible(menu), true, "owner removal restores default") + T.eq(peers, 1, "owner removal preserves another mod") + peer() + return rows +end +return M diff --git a/tests/screen_visibility/fixture.lua b/tests/screen_visibility/fixture.lua new file mode 100644 index 000000000..4ea71a75a --- /dev/null +++ b/tests/screen_visibility/fixture.lua @@ -0,0 +1,148 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local generation = Version.generation() + local files = {} + for _, path in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_screen_visibility/" .. path, "rb")) + files["mods/portable_screen_visibility/" .. path] = file:read("*a") + file:close() + end + local Json = require("src.link.Json") + local manifest = assert(Json.decode(files["mods/portable_screen_visibility/manifest.json"])) + manifest.api, manifest.callback_format = api, format + files["mods/portable_screen_visibility/manifest.json"] = Json.encode(manifest) + local data = T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_screen_visibility" }, { + data = data, + generation = generation, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local StateStack = require("src.core.StateStack") + local stack = setmetatable({}, { __index = StateStack }) + stack:init() + local module = backend == "gen1" and generation == 2 and "src.core.Game2" or "src.core.Game" + local Game = require(module) + local g = setmetatable({ + stack = stack, + mods = result.loader, + data = data, + save = { options = {} }, + inFillBoot = function() + return false + end, + letterbox = function() end, + paintBattleSurround = function() end, + }, { __index = Game }) + local trace, zones, worldZones, clearWorld = {}, nil, nil, nil + local externalDraws = 0 + local Renderer = require("src.render.Renderer") + local Touch = require("src.core.TouchControls") + local saved = { Renderer.setUISize, Renderer.uiSize, Renderer.beginFrame, Renderer.endFrame, Touch.draw } + local width, height = Renderer.WIDTH, Renderer.HEIGHT + Renderer.setUISize = function(_, w, h) + width, height = w, h + end + Renderer.uiSize = function() + return width, height + end + Renderer.beginFrame = function(_, world) + clearWorld = world + end + Renderer.endFrame = function(_, z, wz) + zones, worldZones = z, wz + return {} + end + Touch.draw = function() end + local Input = require("src.core.Input") + Input:init() + local function state(id, opaque) + return { + screenId = id, + isOpaque = opaque, + updates = 0, + presses = 0, + palettes = 0, + draw = function() + trace[#trace + 1] = id + end, + update = function(self) + self.updates = self.updates + 1 + if Input:wasPressed("a") then + self.presses = self.presses + 1 + end + end, + sgbPalettes = function(self) + self.palettes = self.palettes + 1 + return id .. " zones" + end, + } + end + local base, menu, overlay = state("Base", true), state("BagMenu", true), state("TextBox", false) + g.overworld = base + g.world = { + map = {}, + fitScale = function() + return 4 + end, + draw = function() + externalDraws = externalDraws + 1 + end, + } + local function frame(states) + trace, zones, worldZones, clearWorld = {}, nil, nil, nil + externalDraws = 0 + stack.states = states + if backend == "gen1" and generation == 2 then + g:drawScene(640, 576) + elseif backend == "gen1" then + g:draw() + else + g:_draw() + end + return trace, zones, worldZones, clearWorld + end + local exports = result.loader.exports.portable_screen_visibility + return { + T = T, + result = result, + game = g, + stack = stack, + base = base, + menu = menu, + overlay = overlay, + stackFrame = function(states) + trace = {} + stack.states = states + stack:draw() + return trace + end, + frame = frame, + state = state, + input = Input, + backend = backend, + generation = generation, + palettePath = backend ~= "gen1" or generation == 1, + stateIdentity = backend ~= "gen1" or api < 3, + externalDraws = function() + return externalDraws + end, + api = api, + format = format, + exports = exports, + public = exports and exports.api, + close = function() + Renderer.setUISize, Renderer.uiSize, Renderer.beginFrame, Renderer.endFrame, Touch.draw = + saved[1], saved[2], saved[3], saved[4], saved[5] + if result.loader._closeModInput then + result.loader:_closeModInput() + end + result.release() + end, + } +end +return M diff --git a/tests/screen_visibility/worker.lua b/tests/screen_visibility/worker.lua new file mode 100644 index 000000000..77c34ef3d --- /dev/null +++ b/tests/screen_visibility/worker.lua @@ -0,0 +1,13 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local fixture = dofile(root .. "/tests/screen_visibility/fixture.lua") +local f = fixture.new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/screen_visibility/cases.lua").run(f) +f.close() +local bare = fixture.new(root, backend, version, api, format, true) +bare.T.eq(require("src.mods.Runtime").wantsHook("screen.render_visible"), false, "no-mod Runtime") +bare.T.eq(bare.stack:renderVisible(bare.menu), true, "no-mod visibility default") +bare.T.same(bare.frame({ bare.base, bare.menu }), { "BagMenu" }, "no-mod native composition") +bare.close() +print("SCREEN_VISIBILITY_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable screen.render_visible contract") diff --git a/tools/screen-visibility-contract-check/check.py b/tools/screen-visibility-contract-check/check.py new file mode 100644 index 000000000..d364f92b1 --- /dev/null +++ b/tools/screen-visibility-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged visibility mod through both engines' native screen composition.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "crystal"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/screen_visibility/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("SCREEN_VISIBILITY_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("SCREEN_VISIBILITY_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"screen visibility mismatch: {version}" + print(f"PASS {version}: matching native screen composition across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_screen_visibility/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 5764ec80f5d4f0070d1069ea6ae4bd30fa3b45c9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:46:17 +0200 Subject: [PATCH 025/138] Add portable battle.bottom_ui_visible dispatch and cross-engine contracts --- .../workflows/battle-bottom-ui-contract.yml | 49 +++++ docs/modding.md | 6 + docs/portable-battle-bottom-ui.md | 100 +++++++++ examples/portable_battle_bottom_ui/main.lua | 16 ++ .../portable_battle_bottom_ui/manifest.json | 10 + src/battle/BattleState.lua | 8 +- src/battle/UIVisibility.lua | 71 +++++- src/battle/WideBattle.lua | 2 + src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 2 +- src/render/TextBox.lua | 2 + src/ui/ChoiceBox.lua | 2 + tests/battle_bottom_ui/cases.lua | 129 +++++++++++ tests/battle_bottom_ui/fixture.lua | 161 ++++++++++++++ tests/battle_bottom_ui/worker.lua | 13 ++ tests/engine/battle_bottom_ui.lua | 5 + tests/engine/battle_bottom_ui_composition.lua | 132 ++++++++++++ tests/engine/battle_bottom_ui_routing.lua | 203 ++++++++++++++++++ .../battle-bottom-ui-contract-check/check.py | 58 +++++ 19 files changed, 960 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/battle-bottom-ui-contract.yml create mode 100644 docs/portable-battle-bottom-ui.md create mode 100644 examples/portable_battle_bottom_ui/main.lua create mode 100644 examples/portable_battle_bottom_ui/manifest.json create mode 100644 tests/battle_bottom_ui/cases.lua create mode 100644 tests/battle_bottom_ui/fixture.lua create mode 100644 tests/battle_bottom_ui/worker.lua create mode 100644 tests/engine/battle_bottom_ui.lua create mode 100644 tests/engine/battle_bottom_ui_composition.lua create mode 100644 tests/engine/battle_bottom_ui_routing.lua create mode 100644 tools/battle-bottom-ui-contract-check/check.py diff --git a/.github/workflows/battle-bottom-ui-contract.yml b/.github/workflows/battle-bottom-ui-contract.yml new file mode 100644 index 000000000..38754d2d6 --- /dev/null +++ b/.github/workflows/battle-bottom-ui-contract.yml @@ -0,0 +1,49 @@ +name: portable battle.bottom_ui_visible contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: battle-bottom-ui-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check bottom UI, inherited overlays and native battle composition + run: | + luajit tests/engine/battle_bottom_ui.lua + luajit tests/engine/battle_bottom_ui_routing.lua + luajit tests/engine/battle_bottom_ui_composition.lua + - name: Validate pinned native Gen1 GBA battle UI + working-directory: .contract-reference/gen1 + run: luajit tests/modkit/cases/unified_gen3_battle_hooks.lua + - name: Compare native API 1 and 2 + run: | + mkdir -p /tmp/battle-bottom-ui-contract + python3 tools/battle-bottom-ui-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/battle-bottom-ui-contract/api1-native.json + python3 tools/battle-bottom-ui-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/battle-bottom-ui-contract/api2-native.json + - name: Compare unified callbacks and API 3 + run: | + python3 tools/battle-bottom-ui-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/battle-bottom-ui-contract/api2-unified.json + python3 tools/battle-bottom-ui-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/battle-bottom-ui-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: battle-bottom-ui-contract + path: /tmp/battle-bottom-ui-contract + if-no-files-found: ignore diff --git a/docs/modding.md b/docs/modding.md index 6a3fa5f76..3cb3f8ba3 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -76,6 +76,12 @@ update `src/mods/Capabilities.lua` and the capability tests in the same change. Additional read-only world summaries: [map overview and encounter queries](world-overview.md). +## Battle bottom UI visibility + +The [portable battle bottom UI contract](portable-battle-bottom-ui.md) documents +`battle.bottom_ui_visible(next, state)` for battle text/menu drawing and +inherited text/YES-NO overlays, without changing native input/update ownership. + ## Per-state main-screen visibility The [portable screen visibility contract](portable-screen-visibility.md) diff --git a/docs/portable-battle-bottom-ui.md b/docs/portable-battle-bottom-ui.md new file mode 100644 index 000000000..59d89326b --- /dev/null +++ b/docs/portable-battle-bottom-ui.md @@ -0,0 +1,100 @@ +# Portable battle bottom UI visibility + +`battle.bottom_ui_visible(next, state)` controls native battle text and menu +presentation on every Gen2Recomped host, through native API 1/2, unified API 2 +and API 3. It complements `screen.render_visible`: a mod can hide the bottom +UI without hiding the battle scene, sprites, health/status panels or effects. + +```lua +local mod = ... +local replacingBattleUI = false +mod.hooks:wrap("battle.bottom_ui_visible", function(next, state) + if replacingBattleUI then return false end + return next(state) +end) +``` + +Keep the flag active only while the mod owns the intended presentation. +Native TextBox also uses this hook outside battles, so a global false can hide +field dialogue too. Mods may instead select specific handles or state +properties. Battle metadata/layout internals differ between engines: Gen2's +native class uses `isBattleState`, while Gen1's classic classes use `isBattle`. +The Gen2 inheritance helper recognizes both without altering those classes. + +The callback receives the original opaque state, with no extra arguments or +record/ID conversion. The default is true; only a literal first result of +false hides UI. Nil, zero, strings and tables remain visible. `next()` keeps +the current arguments; explicit `next(otherState)` changes downstream +predicate arguments, not the actual state or drawing target. The default +ignores the argument. Treat the callback as a pure predicate: rendering and +menu clipping may query it repeatedly during one frame. + +## Native routing + +The primary gate runs before BattleState dispatches its text area to classic, +GBA or DS renderers. WideBattle's separate bottom draw also honors it. On DS, +this gates the text/menu path that owns both compact and device-screen action, +move and target controls; it does not hide independent device content or +change input ownership. The Game Boy move/Mimic row clip is removed while +that menu is hidden, retaining existing unrelated field/side clips. + +TextBox and ChoiceBox inherit a false decision from the nearest enclosing +battle below them on the native stack. A hidden battle short-circuits before +text frames, choice backings, glyphs and UI anchors are written. When the +battle is visible, TextBox also has its own individual predicate. ChoiceBox +only inherits: a field YES/NO prompt never becomes a battle-hook state. +A battle above an overlay is not its owner. + +The predicate does not remove states, consume input or pause logic. Typewriter +updates and delayed YES/NO confirmation remain native. It does not modify +existing rendering-driven presentation counters or their timing when a drawer +is skipped. `battle.status_hud_visible` remains a separate surface with its +existing generation coverage; this change does not add missing GB/GBA HUD +producers. `screen.render_visible` may independently hide an entire state. + +`mod:supports("hooks", "battle.bottom_ui_visible")` reports the native producer +before Game creation. With no subscriber the helper returns true directly; +the default function is hoisted and the unhooked path allocates no predicate +closure/payload. Existing priorities, unsubscribe, owner cleanup and failed- +entry rollback apply. Gen2's existing `claimBottom`/`releaseBottom`, claimant +introspection, reset and failing-claim removal remain intact. A legacy claim +that denies drawing wins before the public hook; a hook returning true cannot +force another owner's hidden strip visible. A wrapper error before forwarding falls through; an +error after forwarding preserves downstream results without replay. Unified +callbacks retain existing cached-continuation rules. Native renderer error +handling is unchanged, including DS's existing guarded text-area drawing. + +## Verification + +```sh +luajit tests/engine/battle_bottom_ui.lua +luajit tests/engine/battle_bottom_ui_routing.lua +luajit tests/engine/battle_bottom_ui_composition.lua +python3 tools/battle-bottom-ui-contract-check/check.py --gen1 /path/to/gen1recomp --api 1 +python3 tools/battle-bottom-ui-contract-check/check.py --gen1 /path/to/gen1recomp +python3 tools/battle-bottom-ui-contract-check/check.py --gen1 /path/to/gen1recomp --format unified +python3 tools/battle-bottom-ui-contract-check/check.py --gen1 /path/to/gen1recomp --api 3 --format unified +``` + +The checker rejects dirty/unexpected Gen1 sources and pins +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same committed example runs +through both native loaders on Red, Gold, FireRed and Emerald. It calls the +real native predicates and bottom render routes: Gen1 BattleState, Gold's +`drawBottom`, and GBA battle Ui.draw's caught-Pokemon scene, compared with +Gen2's native BattleState dispatch. GBA inheritance is exercised through its +own Ui helper; the shared classic TextBox helper is not presented as Gen1 +GBA's native message implementation. Gen1 API 2 ignores the unified opt-in; +that run compares native Gen1 API 2 against unified Gen2 API 2. API 3 is +unified on both hosts, with opaque state identity preserved for this hook. + +The Gen2 routing matrix covers every supported version in all four modes, +actual inherited TextBox/ChoiceBox drawers and input/update behavior, native +move clipping, cleanup and the no-mod fast path. The composition suite builds +real native wild battles across every version in classic/wide options. It +runs their compositors with asset-layer spies, retaining the actual bottom +render path, and checks field/actor/HUD/animation dispatch plus identical +native menu input and logic ticks. It also covers the GB palette-canvas path. +The CI workflow also runs the reference engine's unchanged native GBA battle +UI contract suite, covering its regular menus, HUD and native confirmations. +These are ROM-free/headless dispatch tests, not GPU pixel comparisons, live +mirrored-device output or cartridge playthroughs. diff --git a/examples/portable_battle_bottom_ui/main.lua b/examples/portable_battle_bottom_ui/main.lua new file mode 100644 index 000000000..d8a64c6a4 --- /dev/null +++ b/examples/portable_battle_bottom_ui/main.lua @@ -0,0 +1,16 @@ +-- A custom battle UI can hide the native bottom layer without pausing input. +-- Decide from state properties; composition may query the predicate repeatedly. +local mod = ... +local hidden, seen = false, { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.hide(value) + hidden = value == true +end +mod.exports.stop = mod.hooks:wrap("battle.bottom_ui_visible", function(next, state, ...) + seen.calls = seen.calls + 1 + seen.state, seen.extra = state, select("#", ...) + if hidden then + return false + end + return next(state) +end) diff --git a/examples/portable_battle_bottom_ui/manifest.json b/examples/portable_battle_bottom_ui/manifest.json new file mode 100644 index 000000000..f674b2d36 --- /dev/null +++ b/examples/portable_battle_bottom_ui/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_battle_bottom_ui", + "name": "Portable Battle Bottom UI", + "version": "1.0.0", + "api": 3, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3, 4, 5], + "affects_link": false +} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index c2f51594a..3d2dc912d 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -24,6 +24,7 @@ local MoveEffects = require("src.battle.MoveEffects") local Party = require("src.pokemon.Party") local Pokemon = require("src.pokemon.Pokemon") local Runtime = require("src.mods.Runtime") +local UIVisibility = require("src.battle.UIVisibility") local Screens = require("src.ui.Screens") local Status = require("src.battle.Status") local StatusRegistry = require("src.battle.StatusRegistry") @@ -577,6 +578,10 @@ function BattleState:uiSize() return 160, 144 end +function BattleState:bottomUIVisible() + return UIVisibility.bottomVisible(self, true) +end + -- Battle colors itself per-pixel (species pics + HP bar tints), so the -- SGB whole-screen remap must not run over it. The wide layout still -- needs a zone list of its own: the invented 160x144 one would leave its @@ -11927,7 +11932,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) -- .mimicmenu) wipes rows 7+. The port draws pics above the menu -- layer in the colorized pipeline, so clip them to the visible rows. local g = love.graphics - local clipY = not skipMenuClip + local clipY = not skipMenuClip and self:bottomUIVisible() and (self.phase == "mimicSelect" and 56 or self.phase == "moveSelect" and 64) or nil @@ -12376,6 +12381,7 @@ function BattleState:drawHUDs(slide) end function BattleState:drawTextArea() + if not self:bottomUIVisible() then return end -- EMERALD'S OWN, and reached through this method rather than around it. -- Gen3Battle.draw used to call its layer functions directly, which meant a -- mod wrapping this name never saw a Gen 3 battle at all. diff --git a/src/battle/UIVisibility.lua b/src/battle/UIVisibility.lua index 3165af0ca..a79808544 100644 --- a/src/battle/UIVisibility.lua +++ b/src/battle/UIVisibility.lua @@ -23,7 +23,8 @@ -- -- `box` is the TextBox / ChoiceBox instance about to draw and may be nil; -- `isTextBox` is true for the text box and false for the choice box. Both are --- passed to every predicate; neither is interpreted here. +-- passed unchanged to every legacy claim. The public hook below uses +-- isTextBox to decide whether to query that individual state as well. -- -- A predicate that throws is treated as "no opinion" and its claim is dropped, -- because this is called from inside a draw and a broken claim must not be @@ -31,6 +32,7 @@ -- session, which would leave the game unplayable with no error on screen. local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") local UIVisibility = {} @@ -42,39 +44,92 @@ local bottomClaims = {} -- twice with the same owner replaces the predicate rather than stacking, so -- an install that runs again after a reload cannot leak a claim. function UIVisibility.claimBottom(owner, predicate) - if owner == nil or type(predicate) ~= "function" then return false end + if owner == nil or type(predicate) ~= "function" then + return false + end bottomClaims[owner] = predicate return true end function UIVisibility.releaseBottom(owner) - if owner == nil then return false end + if owner == nil then + return false + end local had = bottomClaims[owner] ~= nil bottomClaims[owner] = nil return had end +local function visibleByDefault() + return true +end + +local function enclosingBattle(state) + local stack = state and state.game and state.game.stack + local states = stack and stack.states + if not states then + return nil + end + local found = false + for i = #states, 1, -1 do + local candidate = states[i] + if candidate == state then + found = true + end + if found and candidate and (candidate.isBattle or candidate.isBattleState) then + return candidate + end + end +end + +-- TextBox is queried individually too. ChoiceBox only inherits an enclosing +-- battle, so a field YES/NO never becomes a battle-hook state. +local function hookBottomVisible(state, queryState) + if not Runtime.wantsHook("battle.bottom_ui_visible") then + return true + end + local battle = enclosingBattle(state) + if + battle + and battle ~= state + and Runtime.call("battle.bottom_ui_visible", visibleByDefault, battle) == false + then + return false + end + if queryState or battle == state then + return Runtime.call("battle.bottom_ui_visible", visibleByDefault, state) ~= false + end + return true +end + -- Whoever is asking is allowed to draw the bottom box, unless a claim says no. function UIVisibility.bottomVisible(box, isTextBox) for owner, predicate in pairs(bottomClaims) do local ok, visible = pcall(predicate, box, isTextBox) if not ok then bottomClaims[owner] = nil - Logger.warn("UIVisibility: bottom claim %s failed and was dropped: %s", - tostring(owner), tostring(visible)) + Logger.warn( + "UIVisibility: bottom claim %s failed and was dropped: %s", + tostring(owner), + tostring(visible) + ) elseif visible == false then return false end end - return true + return hookBottomVisible(box, isTextBox) end -- Introspection for the mod manager / a diagnostic overlay: who is currently -- holding the strip. Returns a fresh array of owner ids. function UIVisibility.bottomClaimants() local out = {} - for owner in pairs(bottomClaims) do out[#out + 1] = owner end - table.sort(out, function(a, b) return tostring(a) < tostring(b) end) + for owner in pairs(bottomClaims) do + out[#out + 1] = owner + end + table.sort(out, function(a, b) + return tostring(a) < tostring(b) + end) return out end diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index b8a4a4ca5..eb8e263cb 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -15,6 +15,7 @@ local Font = require("src.render.Font") local HudTiles = require("src.render.HudTiles") local PaletteFX = require("src.render.PaletteFX") local Runtime = require("src.mods.Runtime") +local UIVisibility = require("src.battle.UIVisibility") local Strings = require("src.core.Strings") local TypeChart = require("src.battle.TypeChart") @@ -359,6 +360,7 @@ local function drawMoveMenu(battle) end local function drawTextArea(battle) + if not UIVisibility.bottomVisible(battle, true) then return end if battle.phase == "messages" and (battle.current or battle.animPlaying) then drawMessageBox(battle) elseif battle.phase == "menu" then diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 45c919cb2..716524749 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -15,7 +15,7 @@ end -- Shared simulation, world, input, rendering and lifecycle code. A callback -- need not fire on every action (e.g. an always-hit move skips accuracy). add("hooks", "12345", [[ - battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action + battle.accuracy battle.bottom_ui_visible battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award battle.low_health_alarm battle.run battle.turn_order catch.rate core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step link.fingerprint map.palette movement.collision diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index c31c7be4e..3ba666cd1 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -16,7 +16,7 @@ end -- This is an audited boundary catalog, not a claim that every game executes -- every seam. The source-catalog test requires a decision for each new seam. C.hooks = names([[ - battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award + battle.accuracy battle.bottom_ui_visible battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 359964018..7e4e2f7ac 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -9,6 +9,7 @@ local Logger = require("src.core.Logger") local Assets = require("src.render.Assets") local Font = require("src.render.Font") +local UIVisibility = require("src.battle.UIVisibility") local Theme = require("src.ui.Theme") local Timing = require("src.core.Timing") @@ -636,6 +637,7 @@ function TextBox:uiSize() end function TextBox:draw() + if not UIVisibility.bottomVisible(self, true) then return end -- The dialogue box belongs against the bottom of the screen, not floating -- in the middle of a zoomed-out letterbox. Declared per frame; the -- renderer blits this region to the screen edge and the rest of the UI diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index ba7edbdd0..6261b64ec 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -1,6 +1,7 @@ -- YES/NO choice box (InitYesNoTextBoxParameters: above the text box, right). local Font = require("src.render.Font") +local UIVisibility = require("src.battle.UIVisibility") local Theme = require("src.ui.Theme") local Strings = require("src.core.Strings") local Timing = require("src.core.Timing") @@ -67,6 +68,7 @@ function ChoiceBox:update(dt) end function ChoiceBox:draw() + if not UIVisibility.bottomVisible(self, false) then return end local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th -- rides the same bottom anchor as the dialogue box it sits above, so the -- pair travels together (the anchor keeps each element's gap from the edge) diff --git a/tests/battle_bottom_ui/cases.lua b/tests/battle_bottom_ui/cases.lua new file mode 100644 index 000000000..827922827 --- /dev/null +++ b/tests/battle_bottom_ui/cases.lua @@ -0,0 +1,129 @@ +local M = {} +function M.run(f) + local T, b, api, e = f.T, f.battle, f.public, f.exports + local rows = {} + local function observe(label, expected) + T.eq(f.visible(), expected, label .. " native predicate") + local drawn = f.draw() + T.eq(drawn, expected, label .. " native bottom draw") + rows[#rows + 1] = { label = label, visible = drawn } + end + if api.supports then + T.eq(api:supports("hooks", "battle.bottom_ui_visible"), true, "native capability") + end + f.stack.states = { b } + observe("native", true) + T.eq(e.seen.state, b, "original battle handle in every callback format") + T.eq(e.seen.extra, 0, "no invented arguments") + e.hide(true) + observe("hidden", false) + T.eq(f.stack:top(), b, "hidden bottom leaves native state on stack") + local polls = e.seen.calls + f.input:keypressed("z") + f.input:step() + T.eq(f.input:wasPressed("a"), true, "predicate does not consume native input") + T.eq(e.seen.calls, polls, "input does not query rendering predicate") + f.input:keyreleased("z") + f.input:step() + local text, choice = f.textBox(), f.choiceBox() + f.stack.states = { b, text, choice } + T.eq(f.inherited(text, true), false, "text inherits enclosing battle false") + T.eq(e.seen.state, b, "inherited decision receives enclosing battle") + T.eq(f.inherited(choice, false), false, "choice inherits enclosing battle false") + e.hide(false) + T.eq(f.inherited(text, true), true, "text returns when enclosing battle visible") + T.eq(e.seen.state, text, "visible battle permits individual text predicate") + local off = api.hooks:wrap("battle.bottom_ui_visible", function(next, state) + if state == text then + return false + end + return next(state) + end, 100) + T.eq(f.inherited(text, true), false, "individual text can hide without hiding battle") + T.eq(f.inherited(choice, false), true, "individual text decision does not hide choice") + off() + for _, case in ipairs({ + { name = "nil" }, + { name = "true", value = true }, + { name = "zero", value = 0 }, + { name = "empty string", value = "" }, + { name = "table", value = {} }, + }) do + off = api.hooks:wrap("battle.bottom_ui_visible", function() + return case.value + end, 100) + T.eq(f.visible(), true, case.name .. " result remains visible") + off() + end + off = api.hooks:wrap("battle.bottom_ui_visible", function() + return false, true + end, 100) + T.eq(f.visible(), false, "first result alone controls visibility") + off() + local replacement = { isBattle = true, contract = "replacement" } + off = api.hooks:wrap("battle.bottom_ui_visible", function(next, state) + T.eq(state, b, "outer sees original battle") + return next(replacement) + end, 100) + T.eq(f.visible(), true, "next forwards replacement predicate argument") + T.eq(e.seen.state, replacement, "downstream sees replacement identity") + off() + local order = {} + local outer = api.hooks:wrap("battle.bottom_ui_visible", function(next) + order[#order + 1] = "outer" + local value = next() + order[#order + 1] = "after" + return value + end, 20) + local inner = api.hooks:wrap("battle.bottom_ui_visible", function(next, state) + order[#order + 1] = "inner" + T.eq(state, b, "next() retains current handle") + return next() + end, 10) + T.eq(f.visible(), true, "forwarded default true") + T.same(order, { "outer", "inner", "after" }, "descending priority") + outer() + inner() + e.hide(true) + for _, after in ipairs({ false, true }) do + off = api.hooks:wrap("battle.bottom_ui_visible", function(next) + if after then + next() + end + error("intentional bottom UI predicate failure") + end, 100) + T.eq(f.visible(), false, "wrapper failure preserves downstream false") + off() + end + if f.api >= 3 then + off = api.hooks:wrap("battle.bottom_ui_visible", function(next) + next() + return next() + end, 100) + polls = e.seen.calls + T.eq(f.visible(), false, "API-3 cached false") + T.eq(e.seen.calls, polls + 1, "API-3 repeated next does not replay downstream") + off() + end + e.hide(false) + observe("restored", true) + e.stop() + e.stop() + polls = e.seen.calls + observe("unsubscribed", true) + T.eq(e.seen.calls, polls, "unsubscribe is idempotent") + api.hooks:wrap("battle.bottom_ui_visible", function() + return false + end, 100) + local peerCalls = 0 + local peer = f.result.loader.hooks:wrap("battle.bottom_ui_visible", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) + end, 10, "other_bottom_owner") + f.result.loader.hooks:removeOwner("portable_battle_bottom_ui") + T.eq(f.visible(), true, "owner cleanup restores native visibility") + T.eq(peerCalls, 1, "owner cleanup preserves another mod") + peer() + return rows +end +return M diff --git a/tests/battle_bottom_ui/fixture.lua b/tests/battle_bottom_ui/fixture.lua new file mode 100644 index 000000000..f83de5d31 --- /dev/null +++ b/tests/battle_bottom_ui/fixture.lua @@ -0,0 +1,161 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local generation = Version.generation() + local files = {} + for _, path in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_battle_bottom_ui/" .. path, "rb")) + files["mods/portable_battle_bottom_ui/" .. path] = file:read("*a") + file:close() + end + local Json = require("src.link.Json") + local manifest = assert(Json.decode(files["mods/portable_battle_bottom_ui/manifest.json"])) + manifest.api, manifest.callback_format = api, format + files["mods/portable_battle_bottom_ui/manifest.json"] = Json.encode(manifest) + local data = backend == "gen1" and generation == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_battle_bottom_ui" }, { + data = data, + generation = generation, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local restores = {} + local function patch(owner, name, value) + local old = owner[name] + restores[#restores + 1] = function() + owner[name] = old + end + owner[name] = value + end + local counters = { ink = 0, field = 0, actors = 0, hud = 0 } + local function ink() + counters.ink = counters.ink + 1 + end + local Font = require("src.render.Font") + for _, key in ipairs({ "drawBox", "drawDialogueBox", "draw", "drawCode", "drawHPBar" }) do + patch(Font, key, ink) + end + local Input = require("src.core.Input") + Input:init() + local stack = setmetatable({}, { __index = require("src.core.StateStack") }) + stack:init() + local game = { stack = stack, input = Input, data = data, save = { options = {} }, options = {} } + local class = + require(backend == "gen1" and generation == 2 and "src.ui.gen2.BattleState" or "src.battle.BattleState") + local battle = setmetatable({ + game = game, + data = data, + save = game.save, + phase = "contract", + frame = 0, + shown = {}, + fx = {}, + battle = { player = {}, enemy = {} }, + printMessage = function() end, + }, { __index = class }) + local Ui + local visibility = require("src.battle.UIVisibility") + local drawBottom + if backend == "gen1" and generation == 2 then + local Chrome = require("src.ui.gen2.Chrome") + patch(Chrome, "box", ink) + patch(Chrome, "printThrough", ink) + drawBottom = function() + battle:drawBottom(0) + end + elseif backend == "gen1" and generation == 3 then + Ui = require("src.core.game3.battle.ui") + Ui.reset({ headless = true }) + Ui.bindState(battle, { options = {} }) + local Chrome = require("src.ui.game3.battle_chrome") + patch(Chrome, "drawPanel", ink) + patch(Chrome, "drawPostDexBg", function() + counters.field = counters.field + 1 + end) + local image = love.graphics.newImage("bottom-contract-sprite.png") + Ui._caughtDexScene = { + pal = { + fx = function() + return nil + end, + }, + sprite = { img = image, x = 0, y = 0, scaleY = 1 }, + } + local draw = love.graphics.draw + patch(love.graphics, "draw", function(img, ...) + if img == image then + counters.actors = counters.actors + 1 + end + return draw(img, ...) + end) + drawBottom = function() + Ui.draw(240, 160, true) + end + else + drawBottom = function() + battle:drawTextArea() + end + end + local TextBox = require("src.render.TextBox") + local ChoiceBox = require("src.ui.ChoiceBox") + local function textBox() + return TextBox.new(game, "PORTABLE", nil, { drawFrame = true }) + end + local function choiceBox(callback) + return ChoiceBox.new(game, callback or function() end, { noSound = true }) + end + local exports = result.loader.exports.portable_battle_bottom_ui + return { + T = T, + result = result, + game = game, + battle = battle, + stack = stack, + backend = backend, + generation = generation, + api = api, + format = format, + exports = exports, + public = exports and exports.api, + patch = patch, + input = Input, + counters = counters, + textBox = textBox, + choiceBox = choiceBox, + visible = function() + if Ui then + return Ui.bottomUIVisible() + end + return battle:bottomUIVisible() + end, + inherited = function(state, query) + if Ui then + return Ui.bottomUIVisible(state, query) + end + return visibility.bottomVisible(state, query) + end, + draw = function() + for key in pairs(counters) do + counters[key] = 0 + end + drawBottom() + return counters.ink > 0 + end, + close = function() + if Ui then + Ui.reset({ headless = true }) + end + for i = #restores, 1, -1 do + restores[i]() + end + if result.loader._closeModInput then + result.loader:_closeModInput() + end + result.release() + end, + } +end +return M diff --git a/tests/battle_bottom_ui/worker.lua b/tests/battle_bottom_ui/worker.lua new file mode 100644 index 000000000..844290d40 --- /dev/null +++ b/tests/battle_bottom_ui/worker.lua @@ -0,0 +1,13 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local fixture = dofile(root .. "/tests/battle_bottom_ui/fixture.lua") +local f = fixture.new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/battle_bottom_ui/cases.lua").run(f) +f.close() +local bare = fixture.new(root, backend, version, api, format, true) +bare.T.eq(require("src.mods.Runtime").wantsHook("battle.bottom_ui_visible"), false, "no-mod Runtime") +bare.T.eq(bare.visible(), true, "no-mod default true") +bare.T.eq(bare.draw(), true, "no-mod native bottom draw") +bare.close() +print("BATTLE_BOTTOM_UI_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable battle.bottom_ui_visible contract") diff --git a/tests/engine/battle_bottom_ui.lua b/tests/engine/battle_bottom_ui.lua new file mode 100644 index 000000000..ab44a6a05 --- /dev/null +++ b/tests/engine/battle_bottom_ui.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local f = dofile("tests/battle_bottom_ui/fixture.lua").new(".", "gen2", "red", 3, "unified") +dofile("tests/battle_bottom_ui/cases.lua").run(f) +f.close() +f.T.finish("portable battle.bottom_ui_visible") diff --git a/tests/engine/battle_bottom_ui_composition.lua b/tests/engine/battle_bottom_ui_composition.lua new file mode 100644 index 000000000..b539665b1 --- /dev/null +++ b/tests/engine/battle_bottom_ui_composition.lua @@ -0,0 +1,132 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/battle_bottom_ui/fixture.lua") +local Version = require("src.core.GameVersion") +local Battle = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, layout in ipairs({ "classic", "wide" }) do + local f = Fixture.new(".", "gen2", version, 3, "unified") + local game = f.game + game.save = SaveData.newGame() + game.save.options.battleLayout = layout + game.save.party = { Pokemon.new(game.data, "FIXMON_A", 20) } + f.result.loader.game = game + Font.load(game.data) + local b = Battle.newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase, b.frame, b.queue = "messages", 0, {} + b.current, b.shown, b.fx = {}, {}, {} + b.animPlaying = true + b.animPlayer = { stepIndex = 1, steps = { { sprites = {} } } } + game.stack.states = { b } + local marks = {} + local function mark(name) + marks[name] = (marks[name] or 0) + 1 + end + f.patch(b, "drawPicsLayer", function() + mark("actors") + end) + f.patch(b, "drawHUDs", function() + mark("hud") + end) + f.patch(b, "drawAnimLayer", function() + mark("animation") + end) + f.patch(b, "drawBattleField", function() + mark("field") + end) + f.patch(b, "colorMode", function() + return false + end) + local rectangle = love.graphics.rectangle + f.patch(love.graphics, "rectangle", function(mode, x, y, w, h, ...) + if mode == "fill" and w >= 160 and h >= 120 then + mark("field") + end + return rectangle(mode, x, y, w, h, ...) + end) + for _, name in ipairs({ "drawBox", "drawDialogueBox" }) do + f.patch(Font, name, function(tx, ty) + if ty >= 12 then + mark("bottom") + end + end) + end + local Hud = require("src.render.HudTiles") + f.patch(Hud, "tile", function() + mark("hud") + end) + f.patch(Hud, "drawHPBar", function() + mark("hud") + end) + local G4 = require("src.battle.Gen4Battle") + f.patch(G4, "drawBattlers", function() + mark("actors") + end) + f.patch(G4, "drawHealthboxes", function() + mark("hud") + return true + end) + f.patch(G4, "drawParticles", function() + mark("animation") + end) + local function frame(hidden) + f.exports.hide(hidden) + marks = {} + b:draw() + local out = {} + for key, value in pairs(marks) do + out[key] = value + end + return out + end + local native, hidden = frame(false), frame(true) + T.check((native.bottom or 0) > 0, version .. "/" .. layout .. " native bottom backing exists") + T.eq(hidden.bottom or 0, 0, version .. "/" .. layout .. " actual compositor omits bottom backing") + for _, part in ipairs({ "field", "actors", "hud" }) do + T.check((native[part] or 0) > 0, part .. " exists before hiding") + T.eq(hidden[part], native[part], part .. " dispatch unchanged when hiding bottom") + end + T.eq(hidden.animation, native.animation, "animation dispatch unchanged") + -- Palette-canvas branch uses the same bottom gate as the flat GB path. + if f.generation <= 2 and layout == "classic" then + b.colorMode = function() + return true + end + f.patch(b, "drawZonePass", function() + mark("palette") + end) + local visibleColor, hiddenColor = frame(false), frame(true) + T.check((visibleColor.bottom or 0) > 0, "palette compositor has native bottom") + T.eq(hiddenColor.bottom or 0, 0, "palette compositor hides bottom") + T.eq(hiddenColor.palette, visibleColor.palette, "palette pass is preserved") + T.eq(hiddenColor.actors, visibleColor.actors, "palette actor pass is preserved") + end + -- Exercise the real battle update with identical inputs in both modes. + b.animPlayer, b.animPlaying, b.current = nil, false, nil + b.phase, b.menuIndex, b.gen4CursorActive = "menu", 1, true + local function move(hiddenBottom) + f.exports.hide(hiddenBottom) + b.menuIndex, b.frame, b.gen4MenuColumn = 1, 0, nil + f.input:reset() + f.input:keypressed("down") + f.input:step() + local polls = f.exports.seen.calls + b:update(1 / 60) + T.eq(f.exports.seen.calls, polls, "native update never queries draw predicate") + return b.menuIndex, b.frame + end + local index, ticks = move(false) + local hiddenIndex, hiddenTicks = move(true) + T.neq(index, 1, "native menu handles directional input") + T.eq(hiddenIndex, index, "hidden native menu handles same input") + T.eq(hiddenTicks, ticks, "hidden native battle advances same logic clock") + f.close() + end +end +Version.set(originalVersion) +T.finish("battle.bottom_ui_visible native composition") diff --git a/tests/engine/battle_bottom_ui_routing.lua b/tests/engine/battle_bottom_ui_routing.lua new file mode 100644 index 000000000..aa7594d48 --- /dev/null +++ b/tests/engine/battle_bottom_ui_routing.lua @@ -0,0 +1,203 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/battle_bottom_ui/fixture.lua") +local Cases = dofile("tests/battle_bottom_ui/cases.lua") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Loader = require("src.mods.Loader") +local Battle = require("src.battle.BattleState") +local TextBox = require("src.render.TextBox") +local Font = require("src.render.Font") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local e, b, g, stack = f.exports, f.battle, f.game, f.stack + T.eq(b.isBattleState, true, "real Gen2 prototype supplies battle classification") + T.eq(rawget(b, "isBattle"), nil, "inheritance does not rely on a fabricated isBattle marker") + local anchors = 0 + g.renderer = { + setUIAnchor = function() + anchors = anchors + 1 + end, + } + local text, choice = f.textBox(), f.choiceBox() + stack.states = { b, text, choice } + e.hide(true) + f.counters.ink = 0 + text:draw() + choice:draw() + T.eq(f.counters.ink, 0, "hidden battle suppresses actual text/choice backings and glyphs") + T.eq(anchors, 0, "hidden overlays do not leave a render anchor") + T.eq(e.seen.state, b, "inherited overlay decision queries native enclosing battle") + e.hide(false) + text:draw() + choice:draw() + T.check(f.counters.ink > 0, "unhidden actual text/choice draws again") + T.check(anchors > 0, "unhidden text resumes native anchoring") + -- A field choice has no battle owner and must never become a hook state. + stack.states = { text, choice } + local polls = e.seen.calls + f.counters.ink = 0 + e.hide(true) + choice:draw() + T.check(f.counters.ink > 0, "field choice remains visible under an active bottom predicate") + T.eq(e.seen.calls, polls, "field choice does not query battle hook") + text:draw() + T.eq(e.seen.state, text, "field text retains its individual predicate contract") + -- A battle above a text state is not its owner. + stack.states = { text, b, choice } + e.hide(false) + local off = f.public.hooks:wrap("battle.bottom_ui_visible", function(next, state) + if state == b then + return false + end + return next(state) + end, 100) + T.eq( + require("src.battle.UIVisibility").bottomVisible(text, true), + true, + "battle above a text state is not inherited" + ) + off() + -- Native hidden YES/NO input still completes its own delayed callback. + local answer + choice = f.choiceBox(function(value) + answer = value + end) + stack.states = { b, text, choice } + e.hide(true) + f.input:keypressed("z") + f.input:step() + polls = e.seen.calls + stack:update(1 / 60) + f.input:keyreleased("z") + f.input:step() + for _ = 1, 40 do + if stack:top() == choice then + stack:update(1 / 60) + end + end + T.eq(answer, true, "hidden native YES/NO confirms through real input and hold frames") + T.eq(stack:top(), text, "hidden choice pops exactly itself") + T.eq(e.seen.calls, polls, "native choice update never queries rendering predicate") + -- Hidden typewriter text remains a live native foreground state. + text = f.textBox() + stack.states = { b, text } + local chars = text.charIndex or 0 + polls = e.seen.calls + for _ = 1, 60 do + stack:update(1 / 60) + end + T.check((text.charIndex or 0) > chars or text.done, "hidden native text continues typing") + T.eq(e.seen.calls, polls, "native typewriter update never queries draw predicate") + -- No menu-row scissor may remain after the corresponding UI is hidden. + local clips = 0 + f.patch(love.graphics, "getScissor", function() + return nil + end) + f.patch(love.graphics, "intersectScissor", function() + clips = clips + 1 + end) + for _, phase in ipairs({ "moveSelect", "mimicSelect" }) do + b.phase = phase + local before = clips + e.hide(false) + b:drawPicsLayer(0, 0, 0) + T.eq(clips, before + 1, "visible native move menu clips its rows") + before = clips + e.hide(true) + b:drawPicsLayer(0, 0, 0) + T.eq(clips, before, "hidden native move menu removes its own clip") + e.hide(false) + b:drawPicsLayer(0, 0, 0, nil, true) + T.eq(clips, before, "explicit skipMenuClip retains native behavior") + end + b.phase = "contract" + e.hide(false) + Cases.run(f) + f.close() + end +end +local bare = Fixture.new(".", "gen2", "red", 3, "unified", true) +local originalCall, calls = Runtime.call, 0 +Runtime.call = function(name, ...) + if name == "battle.bottom_ui_visible" then + calls = calls + 1 + end + return originalCall(name, ...) +end +T.eq(Runtime.wantsHook("battle.bottom_ui_visible"), false, "no-mod runtime") +T.eq(bare.visible(), true, "no-mod default") +T.eq(bare.draw(), true, "no-mod actual bottom draw") +T.eq(calls, 0, "unsubscribed path never dispatches Runtime.call") +Runtime.call = originalCall +bare.close() +-- Preserve the pre-existing private claim API while adding the public hook. +local legacy = Fixture.new(".", "gen2", "red", 3, "unified") +local Visibility = require("src.battle.UIVisibility") +Visibility.reset() +T.eq(Visibility.claimBottom(nil, function() end), false, "nil legacy owner rejected") +T.eq(Visibility.claimBottom("bad", false), false, "non-callable legacy claim rejected") +local queried, queryKind +T.eq( + Visibility.claimBottom("legacy_owner", function(state, isTextBox) + queried, queryKind = state, isTextBox + return false + end), + true, + "legacy claim accepted" +) +local polls = legacy.exports.seen.calls +T.eq(legacy.visible(), false, "legacy denial wins over public default true") +T.eq(legacy.exports.seen.calls, polls, "legacy denial bypasses hook") +T.eq(queried, legacy.battle, "legacy claim receives original state") +T.eq(queryKind, true, "legacy predicate receives original kind flag") +T.same(Visibility.bottomClaimants(), { "legacy_owner" }, "claimant introspection preserved") +local owners = Visibility.bottomClaimants() +owners[1] = "changed" +T.same(Visibility.bottomClaimants(), { "legacy_owner" }, "claimant result is detached") +T.eq( + Visibility.claimBottom("legacy_owner", function() + return true + end), + true, + "legacy owner replacement" +) +legacy.exports.hide(true) +T.eq(legacy.visible(), false, "public hook may still hide after legacy approval") +legacy.exports.hide(false) +T.eq(legacy.visible(), true, "both layers must allow drawing") +T.eq(Visibility.releaseBottom("legacy_owner"), true, "legacy release reports removed owner") +T.eq(Visibility.releaseBottom("legacy_owner"), false, "legacy release is idempotent") +Visibility.claimBottom("throwing", function() + error("intentional legacy claim failure") +end) +T.eq(legacy.visible(), true, "throwing legacy claim fails open") +T.same(Visibility.bottomClaimants(), {}, "throwing legacy claim is retired") +Visibility.claimBottom("reset_me", function() + return false +end) +Visibility.reset() +T.eq(legacy.visible(), true, "legacy reset restores ordinary visibility") +legacy.close() +local files = { + ["mods/broken_bottom/manifest.json"] = [[{"id":"broken_bottom","name":"Broken Bottom", + "version":"1.0.0","api":3,"entry":"main.lua"}]], + ["mods/broken_bottom/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("battle.bottom_ui_visible", function() return false end) + error("intentional bottom entry failure") + ]], +} +local failed = T.sdk.loadMods( + { "mods/broken_bottom" }, + { fs = T.sdk.memfs(files), data = T.fixtures.fresh() } +) +T.check(#failed.errors > 0, "failed entry is reported") +T.eq(Runtime.wantsHook("battle.bottom_ui_visible"), false, "entry rollback removes predicate") +Loader.teardown() +T.eq(Runtime.wantsHook("battle.bottom_ui_visible"), false, "teardown removes predicate") +failed.release() +Version.set(originalVersion) +T.finish("battle.bottom_ui_visible native routing") diff --git a/tools/battle-bottom-ui-contract-check/check.py b/tools/battle-bottom-ui-contract-check/check.py new file mode 100644 index 000000000..cac046c63 --- /dev/null +++ b/tools/battle-bottom-ui-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged bottom UI mod through both engines' native battle UI renderers.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/battle_bottom_ui/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("BATTLE_BOTTOM_UI_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("BATTLE_BOTTOM_UI_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"battle bottom UI mismatch: {version}" + print(f"PASS {version}: matching native battle UI renderers across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_battle_bottom_ui/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 442e7167f9afae6ec0fc74ad867a64245d7cfa4b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:06:31 +0200 Subject: [PATCH 026/138] Add portable battle.status_hud_visible dispatch for GB and GBA --- .github/workflows/status-hud-contract.yml | 49 +++++ docs/modding.md | 8 +- docs/portable-battle-bottom-ui.md | 5 +- docs/portable-status-hud.md | 76 ++++++++ examples/portable_status_hud/main.lua | 16 ++ examples/portable_status_hud/manifest.json | 10 + src/battle/BattleState.lua | 8 +- src/battle/Gen3Battle.lua | 8 +- src/battle/Gen4Battle.lua | 21 +-- src/battle/StatusHUDVisibility.lua | 25 +++ src/battle/WideBattle.lua | 6 +- src/mods/Capabilities.lua | 2 +- tests/engine/mod_supports.lua | 4 +- tests/engine/status_hud.lua | 5 + tests/engine/status_hud_composition.lua | 138 ++++++++++++++ tests/engine/status_hud_renderers.lua | 137 ++++++++++++++ tests/status_hud/cases.lua | 114 ++++++++++++ tests/status_hud/fixture.lua | 201 +++++++++++++++++++++ tests/status_hud/worker.lua | 13 ++ tools/status-hud-contract-check/check.py | 58 ++++++ 20 files changed, 871 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/status-hud-contract.yml create mode 100644 docs/portable-status-hud.md create mode 100644 examples/portable_status_hud/main.lua create mode 100644 examples/portable_status_hud/manifest.json create mode 100644 src/battle/StatusHUDVisibility.lua create mode 100644 tests/engine/status_hud.lua create mode 100644 tests/engine/status_hud_composition.lua create mode 100644 tests/engine/status_hud_renderers.lua create mode 100644 tests/status_hud/cases.lua create mode 100644 tests/status_hud/fixture.lua create mode 100644 tests/status_hud/worker.lua create mode 100644 tools/status-hud-contract-check/check.py diff --git a/.github/workflows/status-hud-contract.yml b/.github/workflows/status-hud-contract.yml new file mode 100644 index 000000000..d6c8f6c31 --- /dev/null +++ b/.github/workflows/status-hud-contract.yml @@ -0,0 +1,49 @@ +name: portable battle.status_hud_visible contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: status-hud-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check status HUD and native battle composition + run: | + luajit tests/engine/status_hud.lua + luajit tests/engine/status_hud_composition.lua + luajit tests/engine/status_hud_renderers.lua + - name: Validate pinned native Gen1 GBA battle UI + working-directory: .contract-reference/gen1 + run: luajit tests/modkit/cases/unified_gen3_battle_hooks.lua + - name: Compare native API 1 and 2 + run: | + mkdir -p /tmp/status-hud-contract + python3 tools/status-hud-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/status-hud-contract/api1-native.json + python3 tools/status-hud-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/status-hud-contract/api2-native.json + - name: Compare unified callbacks and API 3 + run: | + python3 tools/status-hud-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/status-hud-contract/api2-unified.json + python3 tools/status-hud-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/status-hud-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: status-hud-contract + path: /tmp/status-hud-contract + if-no-files-found: ignore diff --git a/docs/modding.md b/docs/modding.md index 3cb3f8ba3..181abb341 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -61,7 +61,7 @@ return `nil, "map carries no elevation"`. Callback availability follows Gen2Recomped's actual implementation, rather than Gen1Recomp's newer catalog. For example, `item.use` and `egg.hatched` have no native call sites here; the hatch event here is `pokemon.egg_hatched` (Gen 2–5). `battle.status_hud_visible` is available on -Gen 4–5, `battle.overlay` on Gen 1–3, and the normal start-menu, party-submenu +Gen 1–5, `battle.overlay` on Gen 1–3, and the normal start-menu, party-submenu and naming-grid hooks on Gen 1–3. `ui.pc.items` is available on Gen 1–2. Oak-speech callbacks apply to Gen 1–2 except Prism; Birch-speech events apply to Emerald. Other versions use their own intros. An event still needs its @@ -76,6 +76,12 @@ update `src/mods/Capabilities.lua` and the capability tests in the same change. Additional read-only world summaries: [map overview and encounter queries](world-overview.md). +## Battle status HUD visibility + +The [portable battle status HUD contract](portable-status-hud.md) documents +`battle.status_hud_visible(next, state)` for GB/GBA status panels and DS +healthboxes, independently of battle text/menu drawing and native logic. + ## Battle bottom UI visibility The [portable battle bottom UI contract](portable-battle-bottom-ui.md) documents diff --git a/docs/portable-battle-bottom-ui.md b/docs/portable-battle-bottom-ui.md index 59d89326b..004aa9de3 100644 --- a/docs/portable-battle-bottom-ui.md +++ b/docs/portable-battle-bottom-ui.md @@ -48,9 +48,8 @@ A battle above an overlay is not its owner. The predicate does not remove states, consume input or pause logic. Typewriter updates and delayed YES/NO confirmation remain native. It does not modify existing rendering-driven presentation counters or their timing when a drawer -is skipped. `battle.status_hud_visible` remains a separate surface with its -existing generation coverage; this change does not add missing GB/GBA HUD -producers. `screen.render_visible` may independently hide an entire state. +is skipped. `battle.status_hud_visible` is a [separate status-panel surface](portable-status-hud.md) +with GB/GBA and DS producers. `screen.render_visible` may independently hide an entire state. `mod:supports("hooks", "battle.bottom_ui_visible")` reports the native producer before Game creation. With no subscriber the helper returns true directly; diff --git a/docs/portable-status-hud.md b/docs/portable-status-hud.md new file mode 100644 index 000000000..8312c45d1 --- /dev/null +++ b/docs/portable-status-hud.md @@ -0,0 +1,76 @@ +# Portable battle status HUD visibility + +`battle.status_hud_visible(next, state)` is a synchronous render predicate. +Only the first result being literal `false` hides the native status panels: +name, level, status, HP/EXP bars, numbers and their panel art. `nil`, `true`, +zero, strings and tables leave the panels visible. The default is true. + +```lua +local mod = ... +mod.hooks:wrap("battle.status_hud_visible", function(next, state) + if customBattleUIOwnsStatus(state) then return false end + return next(state) +end) +``` + +The argument is the original opaque native battle owner, with no extra +arguments. API 1/2 retain their native callback format; unified API 2 and +API 3 deliberately keep this hook's owner opaque, too. This hook does not +provide a portable battle record: use the public battle snapshot for that. +Predicates should read state and return a decision; renderers can query them +repeatedly, so the callback must not advance simulation or consume input. + +Gen2Recomped dispatches it from classic GB/GBC status rows, the wide GB/GBC +panels, GBA singles and all four doubles panels, and the existing DS healthbox +route. Native GB palette rendering and GBA widened surfaces use the same gate. +The standalone renderers accept partial battle objects. Existing DS custom +`statusHUDVisible` overrides retain their veto and error fallback; the shared +native method invokes the public hook once per query, rather than once in +the method and again in the DS helper. Hidden DS boxes count as handled and +cannot trigger a fallback GB panel. `mod:supports("hooks", +"battle.status_hud_visible")` reports the producer on all supported hosts. + +This does not hide the battlefield, battlers, effects, intro party summaries, +replacement party rows or bottom text/menu layer in Gen2Recomped. The latter +has its own [bottom UI predicate](portable-battle-bottom-ui.md). Input, +updates, HP/EXP presentation state and battle ownership remain native. +Legacy overrides, hook priorities, unsubscribe, owner cleanup and failure +fallback still apply. With no subscriber the default path returns true +without allocating a per-query closure or payload. + +## Reproducible contracts + +`examples/portable_status_hud` is the unchanged consumer loaded by both +engines. The worker uses each engine's real Loader, hook bus and native +predicate/renderer, from that engine's working directory. The GB/GBC test +spies on HUD leaf drawing; the Gen1 GBA worker uses its real Ui compositor +with a healthbox leaf spy. The pinned reference's unmodified native GBA suite +also tests actual healthbox art, text, singles/doubles and Oak relighting. +The reference tree must be clean at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. + +```sh +luajit tests/engine/status_hud.lua +luajit tests/engine/status_hud_renderers.lua +luajit tests/engine/status_hud_composition.lua +python3 tools/status-hud-contract-check/check.py --gen1 /path/to/gen1 --api 1 +python3 tools/status-hud-contract-check/check.py --gen1 /path/to/gen1 +python3 tools/status-hud-contract-check/check.py --gen1 /path/to/gen1 --format unified +python3 tools/status-hud-contract-check/check.py --gen1 /path/to/gen1 --api 3 --format unified +``` + +The cross-engine matrix covers Red, Gold, FireRed and Emerald with API 1, +native/unified API 2 and unified API 3. Gen1 ignores API-2 `callback_format`; +that row compares its native API-2 callback to Gen2's unified callback, using +the same consumer. Observations check native/hidden/restored/unsubscribed +status drawing, false-only semantics, identity, forwarding, priorities, +failure recovery, API-3 cached continuation and owner cleanup. + +Gen2-only tests cover all 15 hosts, both layout options, palette composition, +GBA native panel art and doubles, independent bottom UI, unchanged menu input, +logic clocks, DS delegation and legacy overrides. These are ROM-free render +routing/composition checks, not pixel goldens or imported-ROM playthroughs. +Gen1 Gold's native HUD also owns its party-ball rows and hides those with +its status predicate; Gen2 keeps its existing separate party-row ownership. +The shared observations intentionally compare status panels, not identical +pixels or generation-specific intro presentation. diff --git a/examples/portable_status_hud/main.lua b/examples/portable_status_hud/main.lua new file mode 100644 index 000000000..d7dc8ea97 --- /dev/null +++ b/examples/portable_status_hud/main.lua @@ -0,0 +1,16 @@ +-- A custom battle UI can hide the native status panels without pausing input. +-- Decide from state properties; composition may query the predicate repeatedly. +local mod = ... +local hidden, seen = false, { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.hide(value) + hidden = value == true +end +mod.exports.stop = mod.hooks:wrap("battle.status_hud_visible", function(next, state, ...) + seen.calls = seen.calls + 1 + seen.state, seen.extra = state, select("#", ...) + if hidden then + return false + end + return next(state) +end) diff --git a/examples/portable_status_hud/manifest.json b/examples/portable_status_hud/manifest.json new file mode 100644 index 000000000..be7bdee7b --- /dev/null +++ b/examples/portable_status_hud/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_status_hud", + "name": "Portable Status HUD", + "version": "1.0.0", + "api": 3, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3, 4, 5], + "affects_link": false +} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 3d2dc912d..f3a59f20c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -25,6 +25,7 @@ local Party = require("src.pokemon.Party") local Pokemon = require("src.pokemon.Pokemon") local Runtime = require("src.mods.Runtime") local UIVisibility = require("src.battle.UIVisibility") +local StatusHUDVisibility = require("src.battle.StatusHUDVisibility") local Screens = require("src.ui.Screens") local Status = require("src.battle.Status") local StatusRegistry = require("src.battle.StatusRegistry") @@ -578,6 +579,8 @@ function BattleState:uiSize() return 160, 144 end +BattleState.statusHUDVisible = StatusHUDVisibility.visible + function BattleState:bottomUIVisible() return UIVisibility.bottomVisible(self, true) end @@ -12240,6 +12243,7 @@ function BattleState:drawHUDs(slide) -- per-pixel tint (grayFill) -- otherwise GREENBAR's red-channel-0 fill -- double-applies and the zone shade shader maps the whole bar to black (#229). local grayFill = self:colorMode() + local showStatus = self:statusHUDVisible() local barData = self.data local fx = self.fx local hudShake = (fx and fx.hudShakeX) or 0 @@ -12249,7 +12253,7 @@ function BattleState:drawHUDs(slide) -- DrawEnemyHUDAndHPBar is called from _InitBattleCommon (core.asm:6763) -- AFTER PrintBeginningBattleText returns, so "Wild X appeared!" shows the -- player's ball row with no enemy HUD beside it (#317) - if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut + if showStatus and self.enemy and not self.showEnemyTrainer and not self.enemySendingOut and not self:growInScale(self.enemy) and slide == 0 and not self.introBalls and not self.enemy.fainted then -- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, +level (4,1), @@ -12336,7 +12340,7 @@ function BattleState:drawHUDs(slide) self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8) end local hidePlayer = self.safari or self:demoHidesPlayer() - if self.player and not hidePlayer and not self.showPlayerBack + if showStatus and self.player and not hidePlayer and not self.showPlayerBack and slide == 0 then -- player HUD (DrawPlayerHUDAndHPBar): name (10,7), +level -- (14,8), HP bar (10,9), HP numbers row 10, underline row 11 with diff --git a/src/battle/Gen3Battle.lua b/src/battle/Gen3Battle.lua index dcaa62f71..d7d19462b 100644 --- a/src/battle/Gen3Battle.lua +++ b/src/battle/Gen3Battle.lua @@ -42,6 +42,7 @@ local Font = require("src.render.Font") local HudTiles = require("src.render.HudTiles") local PaletteFX = require("src.render.PaletteFX") local Runtime = require("src.mods.Runtime") +local StatusHUDVisibility = require("src.battle.StatusHUDVisibility") local Strings = require("src.core.Strings") local TypeChart = require("src.battle.TypeChart") local GameVersion = require("src.core.GameVersion") @@ -1440,17 +1441,18 @@ local function healthboxAt(battle, key, panel, fx, fy) end local function drawHUDs(battle, slide) + local showStatus = StatusHUDVisibility.forBattle(battle) local record = hudRecord(battle) local images = record and record.images or {} local double = battle.isDouble and battle:isDouble() - if not double and battle.enemy and not battle.showEnemyTrainer + if showStatus and not double and battle.enemy and not battle.showEnemyTrainer and not battle.enemySendingOut and not battle:growInScale(battle.enemy) and slide == 0 and not battle.introBalls and not battle.enemy.fainted then local x, y = healthboxAt(battle, "singlesOpponent", hudImage(images.opponent), 8, 8) drawStatusPanel(battle, battle.enemy, x, y, false) end - if not double and not battle.safari and battle.player and not battle.demo + if showStatus and not double and not battle.safari and battle.player and not battle.demo and not battle.showPlayerBack and slide == 0 then local x, y = healthboxAt(battle, "singlesPlayer", hudImage(images.player), 112, 64) @@ -1467,7 +1469,7 @@ local function drawHUDs(battle, slide) -- The player's doubles panel is its own art too -- shorter, and with no -- EXP bar, which is why the cartridge ships five healthbox blobs and not -- two. - if double and slide == 0 then + if showStatus and double and slide == 0 then local pairs_ = { { pos = 0, key = "playerLeft", img = images.player, player = true }, { pos = 2, key = "playerRight", img = images.playerDoubles diff --git a/src/battle/Gen4Battle.lua b/src/battle/Gen4Battle.lua index 196cee404..4ba9bac09 100644 --- a/src/battle/Gen4Battle.lua +++ b/src/battle/Gen4Battle.lua @@ -1827,25 +1827,8 @@ function Gen4Battle.drawHealthboxIcons(battle, key, battler, boxX, boxY, side) return true end --- A mod that owns the battle HUD (Colosseum UI, realtime host) already --- answers `battle.status_hud_visible`. Gen 1/2 HP rows go through --- BattleState:drawHUDs, which those mods wrap. Sinnoh paints Platinum's --- healthboxes HERE instead, so that wrap never ran and the native bars --- stayed on screen over the Colosseum HUD. Returning true (boxes "drawn") --- is what keeps Gen4Battle.draw from falling through to the Game Boy panels. -local function statusHudVisible(battle) - if type(battle) == "table" and type(battle.statusHUDVisible) == "function" then - local ok, vis = pcall(battle.statusHUDVisible, battle) - if ok and vis == false then return false end - end - local okR, Runtime = pcall(require, "src.mods.Runtime") - if okR and Runtime and Runtime.wantsHook and Runtime.wantsHook("battle.status_hud_visible") then - local vis = Runtime.call("battle.status_hud_visible", - function() return true end, battle) - if vis == false then return false end - end - return true -end +-- A hidden DS healthbox counts as handled: do not fall back to GB panels. +local statusHudVisible = require("src.battle.StatusHUDVisibility").forBattle -- drawHealthboxes(battle) -> true when BOTH were drawn -- diff --git a/src/battle/StatusHUDVisibility.lua b/src/battle/StatusHUDVisibility.lua new file mode 100644 index 000000000..37c64aec0 --- /dev/null +++ b/src/battle/StatusHUDVisibility.lua @@ -0,0 +1,25 @@ +-- One native predicate for GB/GBA and DS healthboxes. Only literal false hides. +local Runtime = require("src.mods.Runtime") +local M = {} +local function defaultVisible() + return true +end +function M.visible(battle) + if not Runtime.wantsHook("battle.status_hud_visible") then + return true + end + return Runtime.call("battle.status_hud_visible", defaultVisible, battle) ~= false +end +-- Standalone renderers also accept partial battle fixtures and legacy overrides. +-- The native method is M.visible itself, so it must not dispatch the hook twice. +function M.forBattle(battle) + local method = type(battle) == "table" and battle.statusHUDVisible + if type(method) == "function" and method ~= M.visible then + local ok, visible = pcall(method, battle) + if ok and visible == false then + return false + end + end + return M.visible(battle) +end +return M diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index eb8e263cb..e280d8f1f 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -15,6 +15,7 @@ local Font = require("src.render.Font") local HudTiles = require("src.render.HudTiles") local PaletteFX = require("src.render.PaletteFX") local Runtime = require("src.mods.Runtime") +local StatusHUDVisibility = require("src.battle.StatusHUDVisibility") local UIVisibility = require("src.battle.UIVisibility") local Strings = require("src.core.Strings") local TypeChart = require("src.battle.TypeChart") @@ -225,7 +226,8 @@ local function drawIntroBalls(battle) end local function drawHUDs(battle, slide) - if battle.enemy and not battle.showEnemyTrainer + local showStatus = StatusHUDVisibility.forBattle(battle) + if showStatus and battle.enemy and not battle.showEnemyTrainer and not battle.enemySendingOut and not battle:growInScale(battle.enemy) and slide == 0 and not battle.introBalls and not battle.enemy.fainted then drawStatusPanel(battle, battle.enemy, 0, 0, false) @@ -236,7 +238,7 @@ local function drawHUDs(battle, slide) -- item, not a HUD element (DisplayBattleMenu prints wNumSafariBalls inside -- the battle menu box, engine/battle/core.asm:2074-2079), so it rides in -- drawCommandMenu below like the classic layout's (#540). - if not battle.safari and battle.player and not battle.demo + if showStatus and not battle.safari and battle.player and not battle.demo and not battle.showPlayerBack and slide == 0 then -- WHERE THE PLAYER'S BLOCK SITS. The framed panel was fifteen tiles -- wide and filled the corner; the Game Boy block is only four tiles plus diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 716524749..31211e4ce 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -31,7 +31,7 @@ add("hooks", "12345", [[ -- does not dispatch battle.overlay, unlike BattleState/Gen3Battle/WideBattle. add("hooks", "123", "battle.overlay ui.naming.grid ui.party.submenu ui.start_menu.items") add("hooks", "12", "ui.pc.items") -add("hooks", "45", "battle.status_hud_visible") +add("hooks", "12345", "battle.status_hud_visible") add("hooks", "12", "intro.oak_speech.build") add("events", "12345", [[ diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua index 7ad2d3214..edb226283 100644 --- a/tests/engine/mod_supports.lua +++ b/tests/engine/mod_supports.lua @@ -170,8 +170,8 @@ T.check(result == nil and reason == 'no overworld' and yes(api, 'world', 'warpTo for _, version in ipairs(GameVersion.ORDER) do local mod = probe(version) local gen = GameVersion.generation(version) - T.eq(mod:supports('hooks', 'battle.status_hud_visible'), gen >= 4, - version .. ': DS-only HUD seam') + T.eq(mod:supports('hooks', 'battle.status_hud_visible'), true, + version .. ': native GB/GBA/DS HUD seam') T.eq(mod:supports('hooks', 'battle.overlay'), gen <= 3, version .. ': GB/GBA-only overlay seam') T.eq(mod:supports('hooks', 'ui.party.submenu'), gen <= 3, diff --git a/tests/engine/status_hud.lua b/tests/engine/status_hud.lua new file mode 100644 index 000000000..be15c53c8 --- /dev/null +++ b/tests/engine/status_hud.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local f = dofile("tests/status_hud/fixture.lua").new(".", "gen2", "red", 3, "unified") +dofile("tests/status_hud/cases.lua").run(f) +f.close() +f.T.finish("portable battle.status_hud_visible") diff --git a/tests/engine/status_hud_composition.lua b/tests/engine/status_hud_composition.lua new file mode 100644 index 000000000..874d3d2e2 --- /dev/null +++ b/tests/engine/status_hud_composition.lua @@ -0,0 +1,138 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/status_hud/fixture.lua") +local Version = require("src.core.GameVersion") +local Battle = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, layout in ipairs({ "classic", "wide" }) do + local f = Fixture.new(".", "gen2", version, 3, "unified") + local game = f.game + game.save = SaveData.newGame() + game.save.options.battleLayout = layout + game.save.party = { Pokemon.new(game.data, "FIXMON_A", 20) } + f.result.loader.game = game + Font.load(game.data) + local b = Battle.newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase, b.frame, b.queue = "messages", 0, {} + b.current, b.shown, b.fx = {}, {}, {} + b.showPlayerBack, b.showEnemyTrainer, b.enemySendingOut, b.introBalls = false, false, false, false + b.animPlaying = true + b.animPlayer = { stepIndex = 1, steps = { { sprites = {} } } } + game.stack.states = { b } + local marks = {} + local function mark(name) + marks[name] = (marks[name] or 0) + 1 + end + f.patch(b, "drawPicsLayer", function() + mark("actors") + end) + f.patch(b, "drawAnimLayer", function() + mark("animation") + end) + f.patch(b, "drawBattleField", function() + mark("field") + end) + f.patch(b, "colorMode", function() + return false + end) + local rectangle = love.graphics.rectangle + f.patch(love.graphics, "rectangle", function(mode, x, y, w, h, ...) + if mode == "fill" and w >= 160 and h >= 120 then + mark("field") + end + return rectangle(mode, x, y, w, h, ...) + end) + for _, name in ipairs({ "drawBox", "drawDialogueBox" }) do + f.patch(Font, name, function(tx, ty) + if ty >= 12 then + mark("bottom") + end + end) + end + f.patch(Font, "draw", function(_, _, y) + if type(y) == "number" and y < 104 then + mark("hud") + end + return 16 + end) + local G4 = require("src.battle.Gen4Battle") + f.patch(G4, "drawBattlers", function() + mark("actors") + end) + f.patch(G4, "drawParticles", function() + mark("animation") + end) + local function frame(hidden) + f.exports.hide(hidden) + marks = {} + b:draw() + local out = {} + for key, value in pairs(marks) do + out[key] = value + end + return out + end + local native, hidden = frame(false), frame(true) + T.check((native.bottom or 0) > 0, version .. "/" .. layout .. " native bottom backing exists") + T.eq(hidden.bottom, native.bottom, "status predicate preserves native bottom backing") + T.check((native.hud or 0) > 0, "native status text exists") + T.eq(hidden.hud or 0, 0, "actual compositor omits status text") + for _, part in ipairs({ "field", "actors" }) do + T.check((native[part] or 0) > 0, part .. " exists before hiding") + T.eq(hidden[part], native[part], part .. " dispatch unchanged when hiding status") + end + T.eq(hidden.animation, native.animation, "animation dispatch unchanged") + -- Palette-canvas branch uses the same status gate as the flat GB path. + if f.generation <= 2 and layout == "classic" then + b.colorMode = function() + return true + end + f.patch(b, "drawZonePass", function() + mark("palette") + end) + local visibleColor, hiddenColor = frame(false), frame(true) + T.check((visibleColor.bottom or 0) > 0, "palette compositor has native bottom") + T.eq(hiddenColor.bottom, visibleColor.bottom, "palette compositor preserves bottom") + T.check((visibleColor.hud or 0) > 0, "palette compositor has status text") + T.eq(hiddenColor.hud or 0, 0, "palette compositor hides status text") + T.eq(hiddenColor.palette, visibleColor.palette, "palette pass is preserved") + T.eq(hiddenColor.actors, visibleColor.actors, "palette actor pass is preserved") + end + -- Bottom UI and status predicates remain independent in both directions. + f.exports.hide(false) + local off = f.public.hooks:wrap("battle.bottom_ui_visible", function() + return false + end) + local bottomHidden = frame(false) + T.eq(bottomHidden.bottom or 0, 0, "bottom predicate independently hides menu") + T.eq(bottomHidden.hud, native.hud, "bottom predicate preserves native status") + off() + -- Exercise the real battle update with identical inputs in both modes. + b.animPlayer, b.animPlaying, b.current = nil, false, nil + b.phase, b.menuIndex, b.gen4CursorActive = "menu", 1, true + local function move(hiddenBottom) + f.exports.hide(hiddenBottom) + b.menuIndex, b.frame, b.gen4MenuColumn = 1, 0, nil + f.input:reset() + f.input:keypressed("down") + f.input:step() + local polls = f.exports.seen.calls + b:update(1 / 60) + T.eq(f.exports.seen.calls, polls, "native update never queries draw predicate") + return b.menuIndex, b.frame + end + local index, ticks = move(false) + local hiddenIndex, hiddenTicks = move(true) + T.neq(index, 1, "native menu handles directional input") + T.eq(hiddenIndex, index, "hidden HUD menu handles same input") + T.eq(hiddenTicks, ticks, "hidden native battle advances same logic clock") + f.close() + end +end +Version.set(originalVersion) +T.finish("battle.status_hud_visible native composition") diff --git a/tests/engine/status_hud_renderers.lua b/tests/engine/status_hud_renderers.lua new file mode 100644 index 000000000..3f450e709 --- /dev/null +++ b/tests/engine/status_hud_renderers.lua @@ -0,0 +1,137 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/status_hud/fixture.lua") +local Version = require("src.core.GameVersion") +local Visibility = require("src.battle.StatusHUDVisibility") +local Battle = require("src.battle.BattleState") +local G3 = require("src.battle.Gen3Battle") +local G4 = require("src.battle.Gen4Battle") +local G5 = require("src.battle.Gen5BattleHud") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local b, e = f.battle, f.exports + T.eq(f.public:supports("hooks", "battle.status_hud_visible"), true, version .. " capability") + for _, owner in ipairs({ b, {} }) do + local calls = e.seen.calls + T.eq(Visibility.forBattle(owner), true, "native or partial owner visible") + T.eq(e.seen.calls, calls + 1, "one hook dispatch, including DS native method") + T.eq(e.seen.state, owner, "same opaque owner") + e.hide(true) + T.eq(Visibility.forBattle(owner), false, "native or partial owner hidden") + e.hide(false) + end + b.statusHUDVisible = function() + return false + end + local calls = e.seen.calls + T.eq(Visibility.forBattle(b), false, "legacy override veto") + T.eq(e.seen.calls, calls, "legacy veto precedes hook") + b.statusHUDVisible = function() + return true + end + e.hide(true) + T.eq(Visibility.forBattle(b), false, "hook veto survives legacy true") + b.statusHUDVisible = function() + error("legacy failure") + end + T.eq(Visibility.forBattle(b), false, "legacy failure retains public predicate") + b.statusHUDVisible = Battle.statusHUDVisible + e.hide(false) + if Version.isGen3() then + local Assets = require("src.render.Assets") + local image = love.graphics.newImage("contract-healthbox.png") + local summaryImage = love.graphics.newImage("contract-summary.png") + f.patch(Assets, "image", function(path) + return path == "summary" and summaryImage or image + end) + b.data.constants.gen3BattleHud = { + images = { + player = "panel", + opponent = "panel", + playerDoubles = "panel", + opponentDoubles = "panel", + }, + } + b.data.constants.gen3BattlerCoords = { + healthbox = { + playerLeft = { x = 159, y = 76 }, + playerRight = { x = 171, y = 101 }, + opponentLeft = { x = 44, y = 19 }, + opponentRight = { x = 32, y = 44 }, + }, + } + local panels, summaries = 0, 0 + f.patch(love.graphics, "draw", function(img) + if img == image then + panels = panels + 1 + end + if img == summaryImage then + summaries = summaries + 1 + end + end) + for _, double in ipairs({ false, true }) do + b.isDouble = function() + return double + end + b.battlerAt = function(self, pos) + return pos % 2 == 0 and self.player or self.enemy + end + panels = 0 + G3.drawHUDs(b, 0) + T.eq(panels, double and 4 or 2, "native GBA art for every active panel") + panels = 0 + e.hide(true) + G3.drawHUDs(b, 0) + T.eq(panels, 0, "native GBA single/double art entirely hidden") + e.hide(false) + end + -- Intro party summaries are independent of status panels. + b.data.constants.gen3BattleHud.partySummary = { + bar = "summary", + balls = "summary", + count = 1, + icons = { healthy = 0, empty = 0 }, + player = { barX = 0, barY = 0, ballX = 0, ballY = 0 }, + } + b.introBalls, b.playerParty = true, { b.player.mon } + summaries = 0 + e.hide(true) + G3.drawHUDs(b, 0) + T.check(summaries > 0, "hidden GBA status preserves party summary") + elseif Version.generation() >= 4 then + local delegated = 0 + f.patch(G5, "active", function() + return true + end) + f.patch(G5, "drawHealthboxes", function() + delegated = delegated + 1 + return true + end) + local calls = e.seen.calls + T.eq(G4.drawHealthboxes(b), true, "existing DS delegation handled") + T.eq(e.seen.calls, calls + 1, "DS true dispatches once") + T.eq(delegated, 1, "native DS healthbox delegate executes") + e.hide(true) + T.eq(G4.drawHealthboxes(b), true, "hidden DS handled, no GB fallback") + T.eq(delegated, 1, "hidden DS skips healthbox delegate") + else + local balls = 0 + b.introBalls, b.playerParty = true, { b.player.mon } + b.drawBallRow = function() + balls = balls + 1 + end + e.hide(true) + b:drawHUDs(0) + T.eq(balls, 1, "hidden GB status preserves intro party row") + b.introBalls, b.showEnemyBalls, b.enemyParty = false, true, { b.enemy.mon } + balls = 0 + b:drawHUDs(0) + T.eq(balls, 1, "hidden GB status preserves replacement party row") + end + f.close() + end +end +Version.set(originalVersion) +T.finish("battle.status_hud_visible native renderers") diff --git a/tests/status_hud/cases.lua b/tests/status_hud/cases.lua new file mode 100644 index 000000000..4dbf8c665 --- /dev/null +++ b/tests/status_hud/cases.lua @@ -0,0 +1,114 @@ +local M = {} +function M.run(f) + local T, b, api, e = f.T, f.battle, f.public, f.exports + local rows = {} + local function observe(label, expected) + T.eq(f.visible(), expected, label .. " native predicate") + local drawn = f.draw() + T.eq(drawn, expected, label .. " native HUD draw") + rows[#rows + 1] = { label = label, visible = drawn } + end + if api.supports then + T.eq(api:supports("hooks", "battle.status_hud_visible"), true, "native capability") + end + f.stack.states = { b } + observe("native", true) + T.eq(e.seen.state, b, "original battle handle in every callback format") + T.eq(e.seen.extra, 0, "no invented arguments") + e.hide(true) + observe("hidden", false) + T.eq(f.stack:top(), b, "hidden HUD leaves native state on stack") + local polls = e.seen.calls + f.input:keypressed("z") + f.input:step() + T.eq(f.input:wasPressed("a"), true, "predicate does not consume native input") + T.eq(e.seen.calls, polls, "input does not query rendering predicate") + f.input:keyreleased("z") + f.input:step() + local off + for _, case in ipairs({ + { name = "nil" }, + { name = "true", value = true }, + { name = "zero", value = 0 }, + { name = "empty string", value = "" }, + { name = "table", value = {} }, + }) do + off = api.hooks:wrap("battle.status_hud_visible", function() + return case.value + end, 100) + T.eq(f.visible(), true, case.name .. " result remains visible") + off() + end + off = api.hooks:wrap("battle.status_hud_visible", function() + return false, true + end, 100) + T.eq(f.visible(), false, "first result alone controls visibility") + off() + e.hide(false) + local replacement = { isBattle = true, contract = "replacement" } + off = api.hooks:wrap("battle.status_hud_visible", function(next, state) + T.eq(state, b, "outer sees original battle") + return next(replacement) + end, 100) + T.eq(f.visible(), true, "next forwards replacement predicate argument") + T.eq(e.seen.state, replacement, "downstream sees replacement identity") + off() + local order = {} + local outer = api.hooks:wrap("battle.status_hud_visible", function(next) + order[#order + 1] = "outer" + local value = next() + order[#order + 1] = "after" + return value + end, 20) + local inner = api.hooks:wrap("battle.status_hud_visible", function(next, state) + order[#order + 1] = "inner" + T.eq(state, b, "next() retains current handle") + return next() + end, 10) + T.eq(f.visible(), true, "forwarded default true") + T.same(order, { "outer", "inner", "after" }, "descending priority") + outer() + inner() + e.hide(true) + for _, after in ipairs({ false, true }) do + off = api.hooks:wrap("battle.status_hud_visible", function(next) + if after then + next() + end + error("intentional status HUD predicate failure") + end, 100) + T.eq(f.visible(), false, "wrapper failure preserves downstream false") + off() + end + if f.api >= 3 then + off = api.hooks:wrap("battle.status_hud_visible", function(next) + next() + return next() + end, 100) + polls = e.seen.calls + T.eq(f.visible(), false, "API-3 cached false") + T.eq(e.seen.calls, polls + 1, "API-3 repeated next does not replay downstream") + off() + end + e.hide(false) + observe("restored", true) + e.stop() + e.stop() + polls = e.seen.calls + observe("unsubscribed", true) + T.eq(e.seen.calls, polls, "unsubscribe is idempotent") + api.hooks:wrap("battle.status_hud_visible", function() + return false + end, 100) + local peerCalls = 0 + local peer = f.result.loader.hooks:wrap("battle.status_hud_visible", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) + end, 10, "other_bottom_owner") + f.result.loader.hooks:removeOwner("portable_status_hud") + T.eq(f.visible(), true, "owner cleanup restores native visibility") + T.eq(peerCalls, 1, "owner cleanup preserves another mod") + peer() + return rows +end +return M diff --git a/tests/status_hud/fixture.lua b/tests/status_hud/fixture.lua new file mode 100644 index 000000000..5ad707119 --- /dev/null +++ b/tests/status_hud/fixture.lua @@ -0,0 +1,201 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local generation = Version.generation() + local files, Json = {}, require("src.link.Json") + for _, path in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_status_hud/" .. path, "rb")) + files["mods/portable_status_hud/" .. path] = file:read("*a") + file:close() + end + local manifest = Json.decode(files["mods/portable_status_hud/manifest.json"]) + manifest.api, manifest.callback_format = api, format + files["mods/portable_status_hud/manifest.json"] = Json.encode(manifest) + local data = backend == "gen1" and generation == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_status_hud" }, { + data = data, + generation = generation, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local restores, counters = {}, { ink = 0 } + local function patch(owner, key, value) + local old = owner[key] + restores[#restores + 1] = function() + owner[key] = old + end + owner[key] = value + end + local function ink() + counters.ink = counters.ink + 1 + return 16 + end + local Input = require("src.core.Input") + Input:init() + local stack = setmetatable({}, { __index = require("src.core.StateStack") }) + stack:init() + local game = { stack = stack, input = Input, data = data, save = { options = {} }, options = {} } + local b, Ui, draw + if backend == "gen1" and generation == 3 then + patch(require("src.core.game3.battle.pic_coords"), "active", function() + return { front = {}, back = {}, elev = {} } + end) + local State = require("src.core.game3.battle.state") + local function mon(species) + return { + species = species, + nickname = "CONTRACT", + level = 20, + hp = 60, + maxHp = 60, + attack = 30, + defense = 30, + speed = 30, + spAtk = 30, + spDef = 30, + moves = { 33 }, + pp = { 35 }, + maxPp = { 35 }, + item = 0, + ability = 0, + } + end + b = State.new({ playerParty = { mon(4) }, foeParty = { mon(16) }, wild = true }) + Ui = require("src.core.game3.battle.ui") + Ui.reset({ headless = true }) + Ui.bindState(b, { options = {} }) + local Anim = require("src.core.game3.battle.anim") + Anim.reset({ headless = true }) + for id = 0, 1 do + Anim.setShown(id, State.battler(b, id)) + end + -- Spy at the healthbox leaf, leaving the native GBA compositor/predicate intact. + patch(require("src.core.game3.battle.healthbox"), "draw", ink) + draw = function() + Ui.draw(240, 160, true) + end + else + local class = + require(backend == "gen1" and generation == 2 and "src.ui.gen2.BattleState" or "src.battle.BattleState") + local function side() + return { + name = "CONTRACT", + mon = { species = "FIXMON_A", level = 20, hp = 60, stats = { hp = 60 } }, + shownHP = 60, + } + end + b = setmetatable( + { + game = game, + data = data, + phase = "contract", + frame = 0, + shown = {}, + fx = {}, + player = side(), + enemy = side(), + ballRows = {}, + showEnemyHud = true, + showPlayerHud = true, + }, + { __index = class } + ) + local Font = require("src.render.Font") + for _, name in ipairs({ "draw", "drawCode", "drawBox" }) do + patch(Font, name, ink) + end + if backend == "gen1" and generation == 2 then + local Chrome = require("src.ui.gen2.Chrome") + patch(Chrome, "printThrough", ink) + patch(Chrome, "printRightThrough", ink) + b.hud = { + available = function() + return true + end, + drawEnemyFrame = ink, + drawPlayerFrame = ink, + drawExpBar = ink, + } + b.activeMon = function(self, side) + return self[side].mon + end + b.name = function() + return "CONTRACT" + end + b.hudCleared = function() + return false + end + b.statusTag = function() + return nil + end + b.genderSymbol = function() + return nil + end + b.caughtMarkerVisible = function() + return false + end + b.hudHp = function() + return 60 + end + b.drawHpBar = ink + draw = function() + b:drawEnemyHud() + b:drawPlayerHud() + end + else + b.growInScale = function() + return nil + end + b.colorMode = function() + return false + end + b.demoHidesPlayer = function() + return false + end + draw = function() + b:drawHUDs(0) + end + end + end + local exports = result.loader.exports.portable_status_hud + return { + T = T, + result = result, + game = game, + battle = b, + stack = stack, + input = Input, + api = api, + generation = generation, + exports = exports, + public = exports and exports.api, + patch = patch, + visible = function() + if Ui then + return Ui.statusHUDVisible() + end + return b:statusHUDVisible() + end, + draw = function() + counters.ink = 0 + draw() + return counters.ink > 0 + end, + close = function() + if Ui then + Ui.reset({ headless = true }) + end + for i = #restores, 1, -1 do + restores[i]() + end + if result.loader._closeModInput then + result.loader:_closeModInput() + end + result.release() + end, + } +end +return M diff --git a/tests/status_hud/worker.lua b/tests/status_hud/worker.lua new file mode 100644 index 000000000..31ff676a8 --- /dev/null +++ b/tests/status_hud/worker.lua @@ -0,0 +1,13 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local fixture = dofile(root .. "/tests/status_hud/fixture.lua") +local f = fixture.new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/status_hud/cases.lua").run(f) +f.close() +local bare = fixture.new(root, backend, version, api, format, true) +bare.T.eq(require("src.mods.Runtime").wantsHook("battle.status_hud_visible"), false, "no-mod Runtime") +bare.T.eq(bare.visible(), true, "no-mod default true") +bare.T.eq(bare.draw(), true, "no-mod native HUD draw") +bare.close() +print("STATUS_HUD_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable battle.status_hud_visible contract") diff --git a/tools/status-hud-contract-check/check.py b/tools/status-hud-contract-check/check.py new file mode 100644 index 000000000..def52b3db --- /dev/null +++ b/tools/status-hud-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged status HUD mod through both engines' native battle UI renderers.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/status_hud/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("STATUS_HUD_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("STATUS_HUD_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"battle status HUD mismatch: {version}" + print(f"PASS {version}: matching native battle UI renderers across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_status_hud/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From eae79aaaa0bb7048cc9c4d8e4b7b8919a9e908f1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:08:06 +0200 Subject: [PATCH 027/138] Normalize contract fixture formatting --- tests/status_hud/fixture.lua | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/tests/status_hud/fixture.lua b/tests/status_hud/fixture.lua index 5ad707119..8123de1a3 100644 --- a/tests/status_hud/fixture.lua +++ b/tests/status_hud/fixture.lua @@ -87,22 +87,19 @@ function M.new(root, backend, version, api, format, noMod) shownHP = 60, } end - b = setmetatable( - { - game = game, - data = data, - phase = "contract", - frame = 0, - shown = {}, - fx = {}, - player = side(), - enemy = side(), - ballRows = {}, - showEnemyHud = true, - showPlayerHud = true, - }, - { __index = class } - ) + b = setmetatable({ + game = game, + data = data, + phase = "contract", + frame = 0, + shown = {}, + fx = {}, + player = side(), + enemy = side(), + ballRows = {}, + showEnemyHud = true, + showPlayerHud = true, + }, { __index = class }) local Font = require("src.render.Font") for _, name in ipairs({ "draw", "drawCode", "drawBox" }) do patch(Font, name, ink) From 95d6c7555cdcc99675a01f5cd716e40496ddc9dd Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:00:11 +0200 Subject: [PATCH 028/138] Add portable world.talk dispatch and cross-engine NPC contracts --- .github/workflows/world-talk-contract.yml | 51 ++++++ docs/api3-portability.md | 2 +- docs/modding.md | 6 + docs/portable-world-talk.md | 95 ++++++++++ examples/portable_world_talk/main.lua | 18 ++ examples/portable_world_talk/manifest.json | 10 ++ src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 2 +- src/world/OverworldController.lua | 8 +- tests/engine/mod_supports.lua | 1 + tests/engine/world_talk.lua | 5 + tests/engine/world_talk_dialogue.lua | 87 ++++++++++ tests/engine/world_talk_routing.lua | 86 +++++++++ tests/world_talk/cases.lua | 118 +++++++++++++ tests/world_talk/fixture.lua | 193 +++++++++++++++++++++ tests/world_talk/worker.lua | 32 ++++ tools/world-talk-contract-check/check.py | 62 +++++++ 17 files changed, 774 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/world-talk-contract.yml create mode 100644 docs/portable-world-talk.md create mode 100644 examples/portable_world_talk/main.lua create mode 100644 examples/portable_world_talk/manifest.json create mode 100644 tests/engine/world_talk.lua create mode 100644 tests/engine/world_talk_dialogue.lua create mode 100644 tests/engine/world_talk_routing.lua create mode 100644 tests/world_talk/cases.lua create mode 100644 tests/world_talk/fixture.lua create mode 100644 tests/world_talk/worker.lua create mode 100644 tools/world-talk-contract-check/check.py diff --git a/.github/workflows/world-talk-contract.yml b/.github/workflows/world-talk-contract.yml new file mode 100644 index 000000000..d364dcbb2 --- /dev/null +++ b/.github/workflows/world-talk-contract.yml @@ -0,0 +1,51 @@ +name: portable world.talk contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: world-talk-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native NPC dispatch and dialogue + run: | + luajit tests/engine/world_talk.lua + luajit tests/engine/world_talk_routing.lua + luajit tests/engine/world_talk_dialogue.lua + - name: Validate pinned native Gen1 NPC contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/world_talk.lua + luajit tests/modkit/cases/unified_npcs.lua + - name: Compare native API 1 and 2 + run: | + mkdir -p /tmp/world-talk-contract + python3 tools/world-talk-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/world-talk-contract/api1-native.json + python3 tools/world-talk-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/world-talk-contract/api2-native.json + - name: Compare unified callbacks and API 3 + run: | + python3 tools/world-talk-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/world-talk-contract/api2-unified.json + python3 tools/world-talk-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/world-talk-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: world-talk-contract + path: /tmp/world-talk-contract + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 52f592613..bf6315cfd 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -95,7 +95,7 @@ emits nothing. Invalid save stores are refused without overwriting them. Gen1 API-3 callback names from the pinned reference are recognized even when this engine has no call site for them. Registration is allowed; availability still requires a native call site in the selected version. For example, -`render.output` can be subscribed to but reports unavailable here. Unknown +`trainer.before_battle` can be subscribed to but reports unavailable here. Unknown names remain errors. An accepted subscription is not proof that a feature runs. Source-specific engine handles in lifecycle/UI payloads remain native. diff --git a/docs/modding.md b/docs/modding.md index 181abb341..455f2886f 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -76,6 +76,12 @@ update `src/mods/Capabilities.lua` and the capability tests in the same change. Additional read-only world summaries: [map overview and encounter queries](world-overview.md). +## NPC conversation hook + +The [portable NPC talk contract](portable-world-talk.md) documents +`world.talk(next, owner, target)`: claim a native NPC conversation or forward +to the engine, with explicit handle and Gen1 Gold producer boundaries. + ## Battle status HUD visibility The [portable battle status HUD contract](portable-status-hud.md) documents diff --git a/docs/portable-world-talk.md b/docs/portable-world-talk.md new file mode 100644 index 000000000..23c9b78d6 --- /dev/null +++ b/docs/portable-world-talk.md @@ -0,0 +1,95 @@ +# Portable NPC conversation hook + +`world.talk(next, owner, target)` runs when native A-button interaction resolves +a talkable NPC, before the native talk dispatcher acquires its freeze, faces +the actor or starts its dialogue/script. Call `next()` or `next(owner, target)` +to continue. Omit `next` to answer instead. A return value, including `false` +or `nil`, does not request fallthrough; this is a dispatch hook, not a boolean +visibility predicate. + +```lua +local mod = ... +mod.hooks:wrap("world.talk", function(next, owner, target) + if target and target.id == myRuntimeNpcId then + -- Start this mod's public dialogue/script flow here. + return + end + return next(owner, target) +end) +``` + +`examples/portable_world_talk` is an unchanged consumer loaded by both engines. +It can claim a selected actor ID, observe the original handles and unsubscribe. +It does not patch engine classes or require internal modules. + +## Native boundaries + +Both arguments are original opaque engine handles in API 1, native/unified +API 2 and API 3. They are not canonical NPC or Game records. Gen2Recomped's +owner is its OverworldController; Gen1's classic owner is its overworld, while +its separate native GBA field supplies Game. Use public mod APIs for game +operations and do not depend on a common owner layout. + +Gen2's shared producer covers all 15 supported hosts, including native GBA +and DS worlds. It retains the existing counter lookup, hidden/moving/blocker +and GBA elevation rules before dispatch. Special Pikachu/HGSS follower talk +keeps its separate native route. Signs, field tiles, ordinary script calls +to `talkTo` and trainer sight approaches are not new hook producers. +`world.interacted` still reports the original resolved actor once for the NPC +interaction, including when a mod claims it. This hook does not replace the +notification or the separate trainer-start contract. + +The default continuation matches Gen1's classic void `talkTo` dispatch. +Explicit `next` arguments reach downstream hooks unchanged. Redirecting the +native conversation to a different actor is not a portable promise: Gen1's +GBA script continuation closes over the originally resolved actor. Hook +priorities, unsubscribe, entry rollback, owner cleanup and failure recovery +retain their bus contracts. A failing wrapper before forwarding falls through; +a failure after forwarding does not repeat the completed native conversation. +Native talk errors propagate without replay. + +API 3 caches repeated continuation calls on both engines. Gen2 rejects a +continuation after its callback returns; pinned Gen1 can return the cached +result later. A portable mod calls `next` synchronously and does not retain it. +API 1/2 keep their existing native/unified continuation rules. + +The pinned Gen1 Gold engine **does not call `world.talk` from its native +`src.world.gen2.World:interactBody`**, despite the name being accepted by its +catalog. The shared checker exercises this real native boundary and expects +no callback there. Gen2 Gold does execute the common contract. The checker +does not substitute the classic Gen1 controller for Gold or modify reference +sources to force a match. + +## Verification + +```sh +luajit tests/engine/world_talk.lua +luajit tests/engine/world_talk_routing.lua +luajit tests/engine/world_talk_dialogue.lua +python3 tools/world-talk-contract-check/check.py --gen1 /path/to/gen1 --api 1 +python3 tools/world-talk-contract-check/check.py --gen1 /path/to/gen1 +python3 tools/world-talk-contract-check/check.py --gen1 /path/to/gen1 --format unified +python3 tools/world-talk-contract-check/check.py --gen1 /path/to/gen1 --api 3 --format unified +``` + +The checker requires a clean Gen1 checkout at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Each worker runs from its own +engine's working directory, using the real Loader, hook bus and native +interaction method. Red, Yellow, FireRed and Emerald have matching shared +observations; Gold has the explicit producer boundary above. Gen1 ignores +API-2 `callback_format`, so that row compares native Gen1 API 2 against unified +Gen2 API 2 with the same consumer. + +The common fixture spies on the native conversation/script destination after +real object lookup. Gen1 GBA uses its actual event-object pool, counter lookup, +Field.interact and freeze/face calls. Gold uses its own native interactBody. +Gen2 routing covers all 15 hosts and four callback modes; a separate native +runtime-NPC test exercises real spawn, NPC construction, talkTo, TextBox +confirmation, freeze release, unsubscribe and removal. It binds the same +module-local Game/script dependencies normally installed by enter(), avoiding +an imported map/GPU. Its spawn uses the native public WorldAPI, independently +of the separately stacked API-3 NPC adapters. + +The workflow also runs Gen1's unmodified `world_talk` and `unified_npcs` suites, +including real GBA runtime dialogue/registered scripts and cancellation. These +are ROM-free behavioral tests, not imported-ROM playthroughs or pixel goldens. diff --git a/examples/portable_world_talk/main.lua b/examples/portable_world_talk/main.lua new file mode 100644 index 000000000..1e129b1a1 --- /dev/null +++ b/examples/portable_world_talk/main.lua @@ -0,0 +1,18 @@ +-- A portable mod can answer its own NPC without replacing engine methods. +local mod = ... +local out = mod.exports +local claimedId +out.api, out.seen = mod, { calls = 0, claimed = 0 } +function out.claim(id) + claimedId = id +end +out.stop = mod.hooks:wrap("world.talk", function(next, owner, target, ...) + local seen = out.seen + seen.calls = seen.calls + 1 + seen.owner, seen.target, seen.extra = owner, target, select("#", ...) + if claimedId and target and target.id == claimedId then + seen.claimed = seen.claimed + 1 + return "claimed" + end + return next(owner, target) +end) diff --git a/examples/portable_world_talk/manifest.json b/examples/portable_world_talk/manifest.json new file mode 100644 index 000000000..c3212a247 --- /dev/null +++ b/examples/portable_world_talk/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_world_talk", + "name": "Portable NPC Talk", + "version": "1.0.0", + "api": 3, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3, 4, 5], + "affects_link": false +} diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 31211e4ce..d25eece43 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -22,7 +22,7 @@ add("hooks", "12345", [[ movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite render.compose render.hud render.letterbox render.zones save.new_game save.write screen.render_visible script.command trainer.party transition.style ui.list_menu ui.options.rows - ui.title_menu.items warp.destination world.tod zoom.range + ui.title_menu.items warp.destination world.talk world.tod zoom.range ]]) -- Screens have distinct implementations: Screens.lua aliases the DS party, -- naming and start menus; those implementations do not call the GB/GBA hooks. diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 3ba666cd1..7b6c34fb9 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -23,7 +23,7 @@ C.hooks = names([[ music.select music.volume player.sprite pokemon.icon pokemon.sprite render.compose render.hud render.letterbox render.zones save.new_game save.write screen.render_visible script.command trainer.party transition.style ui.list_menu ui.naming.grid ui.options.rows ui.party.submenu ui.pc.items ui.start_menu.items - ui.title_menu.items warp.destination world.tod zoom.range + ui.title_menu.items warp.destination world.talk world.tod zoom.range ]]) C.events = names([[ assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt battle.ended diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 05f6dc9d0..65cc1d75c 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -7007,6 +7007,12 @@ function OverworldState:npcAtCell(cx, cy) return nil end +-- The continuation receives the original opaque world and NPC handles. +-- Keep it outside interact so an unhooked A press allocates no closure. +local function vanillaTalk(world, target) + world:talkTo(target) +end + -- what the A press resolved to, for world.interacted's listeners -- A LINE THE CARTRIDGE ITSELF PRINTS, named by the import rather than typed -- here. `constants.gen3` holds a TEXT_ key and the text pool holds the @@ -7197,7 +7203,7 @@ function OverworldState:interact() -- rules, answered mid-step like the Pikachu above if not npc.hidden then require("src.world.HgssFollowMon").talk(Game, self, npc) end elseif not npc.moving then - self:talkTo(npc) + Runtime.call("world.talk", vanillaTalk, self, npc) end interacted(self, fx, fy, "npc", npc) return diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua index edb226283..8837ecca4 100644 --- a/tests/engine/mod_supports.lua +++ b/tests/engine/mod_supports.lua @@ -170,6 +170,7 @@ T.check(result == nil and reason == 'no overworld' and yes(api, 'world', 'warpTo for _, version in ipairs(GameVersion.ORDER) do local mod = probe(version) local gen = GameVersion.generation(version) + T.eq(mod:supports('hooks', 'world.talk'), true, version .. ': native NPC talk seam') T.eq(mod:supports('hooks', 'battle.status_hud_visible'), true, version .. ': native GB/GBA/DS HUD seam') T.eq(mod:supports('hooks', 'battle.overlay'), gen <= 3, diff --git a/tests/engine/world_talk.lua b/tests/engine/world_talk.lua new file mode 100644 index 000000000..0a084d32e --- /dev/null +++ b/tests/engine/world_talk.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local f = dofile("tests/world_talk/fixture.lua").new(".", "gen2", "red", 3, "unified") +dofile("tests/world_talk/cases.lua").run(f) +f.close() +f.T.finish("portable world.talk") diff --git a/tests/engine/world_talk_dialogue.lua b/tests/engine/world_talk_dialogue.lua new file mode 100644 index 000000000..d5b733e26 --- /dev/null +++ b/tests/engine/world_talk_dialogue.lua @@ -0,0 +1,87 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/world_talk/fixture.lua") +local Version = require("src.core.GameVersion") +local World = require("src.world.OverworldController") +local Stack = require("src.core.StateStack") +local NPC = require("src.world.NPC") +local TextBox = require("src.render.TextBox") +local originalVersion = Version.get() +-- Bind the same module-local dependencies that enter() installs, without +-- loading an imported map/GPU. Neither interact nor talkTo is replaced. +local function bind(fn, name, value) + for i = 1, 100 do + local key, old = debug.getupvalue(fn, i) + if not key then + break + end + if key == name then + debug.setupvalue(fn, i, value) + return function() + debug.setupvalue(fn, i, old) + end + end + end + error("missing native fixture binding " .. name) +end +for _, version in ipairs(Version.ORDER) do + local f = Fixture.new(".", "gen2", version, 3, "unified") + local game, ow = f.game, f.world + game.stack = setmetatable({}, { __index = Stack }) + game.stack:init() + game.stack.states = { ow } + f.result.loader.game = game + game.world, game.overworld = ow, ow + game.save.flags, game.save.party = {}, {} + local mapId = ow.map.id + ow.map.def = game.data.maps[mapId] + ow.map.def.objects = {} + ow.map.def.label = mapId + ow.entities, ow.npcPool, ow.npcs = {}, {}, {} + local restoreGame = bind(World.enter, "Game", game) + local restoreScripts = bind(World.enter, "mapScripts", require("src.script.MapScripts")) + -- Exercise the native public world adapter and real runtime NPC constructor. + local api = require("src.world.WorldAPI").new(game, "portable_world_talk") + local id, why = api:spawnNpc(mapId, { + name = "GUIDE", + x = 4, + y = 5, + sprite = "SPRITE_YOUNGSTER", + movement = "STAY", + facing = "UP", + text = "A native conversation.", + }) + T.check(type(id) == "string", version .. " runtime spawn: " .. tostring(why)) + local npc = ow.npcs[1] + assert(npc, "native runtime actor materialized") + T.eq(getmetatable(npc), NPC, "real NPC constructor") + T.eq(npc.def.owner, "portable_world_talk", "runtime ownership preserved") + ow.talkTo = nil + f.exports.claim(id) + ow:interact() + T.eq(game.stack:top(), ow, "claimed runtime actor opens no native dialogue") + T.eq(npc.frozen, false, "claim does not acquire native freeze") + f.exports.claim(nil) + ow:interact() + local box = game.stack:top() + T.eq(getmetatable(box), TextBox, "fallthrough opens actual native TextBox") + T.eq(npc.frozen, true, "native conversation freezes actor") + T.eq(npc.facing, "down", "native conversation faces player") + box.done = true + box:advance() + T.eq(game.stack:top(), ow, "native confirmation closes dialogue") + T.eq(npc.frozen, false, "native completion unfreezes actor") + f.exports.stop() + ow:interact() + box = game.stack:top() + T.eq(getmetatable(box), TextBox, "unsubscribe preserves native dialogue") + box.done = true + box:advance() + T.eq(api:removeNpc(id), true, "runtime owner can remove its actor") + T.eq(#ow.npcs, 0, "runtime actor removed from interaction pool") + restoreScripts() + restoreGame() + f.close() +end +Version.set(originalVersion) +T.finish("world.talk native runtime NPC dialogue") diff --git a/tests/engine/world_talk_routing.lua b/tests/engine/world_talk_routing.lua new file mode 100644 index 000000000..ae4f0aa08 --- /dev/null +++ b/tests/engine/world_talk_routing.lua @@ -0,0 +1,86 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local Fixture = dofile("tests/world_talk/fixture.lua") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + dofile("tests/world_talk/cases.lua").run(f) + local probe = f.public.hooks:wrap("world.talk", function(next, ...) + f.exports.seen.calls = f.exports.seen.calls + 1 + return next(...) + end) + -- Preserve the engine's existing guards before the public seam. + for _, guard in ipairs({ "moving", "hidden", "gen5Blocker" }) do + local polls, calls = f.exports.seen.calls, #f.calls + f.patch(f.world.map, "signAtCell", function() + return nil + end) + f.patch(f.world, "tryPcTile", function() + return true + end) + if guard == "gen5Blocker" then + f.npc.def.gen5Blocker = true + else + f.npc[guard] = true + end + f.interact() + T.eq(f.exports.seen.calls, polls, guard .. " never queries talk hook") + T.eq(#f.calls, calls, guard .. " never reaches native talk") + f.npc[guard], f.npc.def.gen5Blocker = false, nil + end + if f.gen == 3 then + f.world.player.elevation, f.npc.elevation = 1, 2 + local polls, calls = f.exports.seen.calls, #f.calls + f.interact() + T.eq(f.exports.seen.calls, polls, "GBA wrong elevation never talks") + T.eq(#f.calls, calls, "GBA wrong elevation keeps native guard") + end + for _, follower in ipairs({ + { "pikachuFollower", "src.world.PikachuFollower" }, + { "hgssFollowMon", "src.world.HgssFollowMon" }, + }) do + local called, polls = 0, f.exports.seen.calls + f.patch(require(follower[2]), "talk", function() + called = called + 1 + end) + f.npc[follower[1]] = true + f.world.player.elevation, f.npc.elevation = nil, nil + f.interact() + T.eq(called, 1, "special follower keeps native dispatch") + T.eq(f.exports.seen.calls, polls, "special follower bypasses general NPC hook") + f.npc[follower[1]] = nil + end + probe() + local returned + f.patch(f.world, "talkTo", function() + return "native private result" + end) + local capture = f.public.hooks:wrap("world.talk", function(next) + returned = next() + return returned + end) + f.interact() + T.eq(returned, nil, "default continuation matches Gen1's void talk contract") + capture() + -- Original talk failures propagate without being retried as a mod error. + f.world.player.elevation, f.npc.elevation = nil, nil + local failures = 0 + f.patch(f.world, "talkTo", function() + failures = failures + 1 + error("native talk failure") + end) + local off = f.public.hooks:wrap("world.talk", function(next) + return next() + end) + local ok, why = pcall(f.interact) + T.eq(ok, false, "native failure propagates") + T.check(tostring(why):find("native talk failure", 1, true) ~= nil, "native failure retained") + T.eq(failures, 1, "native failure is never replayed") + off() + f.close() + end +end +Version.set(originalVersion) +T.finish("world.talk native routing") diff --git a/tests/world_talk/cases.lua b/tests/world_talk/cases.lua new file mode 100644 index 000000000..49898f92c --- /dev/null +++ b/tests/world_talk/cases.lua @@ -0,0 +1,118 @@ +local M = {} +function M.run(f) + local T, e, api = f.T, f.exports, f.public + local rows = {} + local function observe(label, claimed) + e.claim(claimed and f.npc.id or nil) + local before, eventCount = #f.calls, #f.events + f.interact() + local native = #f.calls - before + T.eq(native, claimed and 0 or 1, label .. " native continuation count") + T.eq(#f.events - eventCount, 1, label .. " native interaction event remains") + local event = f.events[#f.events] + T.eq(event.target, f.npc, label .. " event keeps resolved original actor") + T.eq(event.kind, "npc", label .. " event keeps NPC kind") + T.eq(event.mapId, "FIX_ROUTE", label .. " event keeps native map") + T.eq(e.seen.owner, f.owner, label .. " opaque native owner") + T.eq(e.seen.target, f.npc, label .. " original actor identity") + T.eq(e.seen.extra, 0, label .. " no extra arguments") + rows[#rows + 1] = { label = label, native = native } + end + if api.supports then + T.eq(api:supports("hooks", "world.talk"), true, "capability at mod entry") + end + observe("unclaimed", false) + observe("claimed", true) + T.eq(e.seen.claimed, 1, "same consumer can answer its NPC") + f.counter(true) + observe("counter claimed", true) + observe("counter default", false) + f.counter(false) + f.npc.def.runtime = true + observe("runtime claimed", true) + observe("runtime default", false) + f.npc.def.runtime = nil + -- Returning false does not mean fall through: calling next controls dispatch. + local off = api.hooks:wrap("world.talk", function() + return false + end, 100) + local before = #f.calls + f.interact() + T.eq(#f.calls, before, "false without next claims the interaction") + off() + local order = {} + local outer = api.hooks:wrap("world.talk", function(next) + order[#order + 1] = "outer" + local value = next() + order[#order + 1] = "after" + return value + end, 20) + local inner = api.hooks:wrap("world.talk", function(next, owner, target) + order[#order + 1] = "inner" + T.eq(owner, f.owner, "next() retains owner") + T.eq(target, f.npc, "next() retains actor") + return next() + end, 10) + before = #f.calls + f.interact() + T.eq(#f.calls, before + 1, "chained next reaches native once") + T.same(order, { "outer", "inner", "after" }, "descending priority") + outer() + inner() + for _, after in ipairs({ false, true }) do + off = api.hooks:wrap("world.talk", function(next) + if after then + next() + end + error("intentional talk hook failure") + end, 100) + before = #f.calls + f.interact() + T.eq(#f.calls, before + 1, "failure before/after next does not duplicate native talk") + off() + end + local replacement = { id = "REPLACEMENT" } + off = api.hooks:wrap("world.talk", function(next, owner) + return next(owner, replacement) + end, 100) + f.interact() + T.eq(e.seen.target, replacement, "explicit next arguments reach downstream opaque") + off() + if f.api >= 3 then + local savedNext + off = api.hooks:wrap("world.talk", function(next) + savedNext = next + local value = next() + T.eq(next(), value, "API 3 repeated continuation retains result") + return value + end, 100) + before = #f.calls + f.interact() + T.eq(#f.calls, before + 1, "API 3 next is cached") + T.eq(pcall(savedNext), f.backend == "gen1", "native continuation lifetime boundary") + T.eq(#f.calls, before + 1, "late continuation never repeats native talk") + off() + end + observe("restored", false) + e.stop() + e.stop() + local polls = e.seen.calls + before = #f.calls + f.interact() + T.eq(#f.calls, before + 1, "unsubscribe restores native route") + T.eq(e.seen.calls, polls, "unsubscribe idempotent") + api.hooks:wrap("world.talk", function() end) + local peerCalls = 0 + local peer = f.result.loader.hooks:wrap("world.talk", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) + end, 10, "other_talk_owner") + f.result.loader.hooks:removeOwner("portable_world_talk") + before = #f.calls + f.interact() + T.eq(#f.calls, before + 1, "owner cleanup restores native talk") + T.eq(peerCalls, 1, "another owner's callback survives cleanup") + peer() + return rows +end +return M diff --git a/tests/world_talk/fixture.lua b/tests/world_talk/fixture.lua new file mode 100644 index 000000000..010adea82 --- /dev/null +++ b/tests/world_talk/fixture.lua @@ -0,0 +1,193 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod, failing) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local gen = Version.generation() + local Json, files = require("src.link.Json"), {} + for _, name in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_world_talk/" .. name, "rb")) + files["mods/portable_world_talk/" .. name] = file:read("*a") + file:close() + end + local manifest = Json.decode(files["mods/portable_world_talk/manifest.json"]) + manifest.api, manifest.callback_format = api, format + files["mods/portable_world_talk/manifest.json"] = Json.encode(manifest) + if failing then + files["mods/portable_world_talk/main.lua"] = files["mods/portable_world_talk/main.lua"] + .. '\nerror("intentional talk entry failure")' + end + local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_world_talk" }, { + data = data, + generation = gen, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + if not failing then + assert(#result.errors == 0, table.concat(result.errors, "\n")) + end + local restores, calls, events, counter = {}, {}, {}, false + local function patch(owner, key, value) + local old = owner[key] + restores[#restores + 1] = function() + owner[key] = old + end + owner[key] = value + end + local npc = { + id = "GUIDE", + localId = 1, + cellX = 4, + cellY = 5, + targetX = 4, + targetY = 5, + moving = false, + visible = true, + facing = "down", + def = { scriptKey = "TALK", index = 0 }, + } + local player = { + cellX = 4, + cellY = 6, + facing = "up", + moving = false, + facingCell = function() + return 4, 5 + end, + } + local game = { data = data, save = { version = version, map = "FIX_ROUTE", options = {} } } + local world, owner, interact + if backend == "gen1" and gen == 3 then + local Objects = require("src.core.game3.objects") + local Field = require("src.core.game3.field") + local Player = require("src.core.game3.player") + local Collision = require("src.core.game3.collision") + local CollisionStd = require("src.core.game3.scripting.collision_std") + local Space = require("src.core.game3.scripting.space") + game.session = game.save + for key, value in pairs({ + cellX = 4, + cellY = 6, + targetX = 4, + targetY = 6, + facing = "up", + moving = false, + surfing = false, + }) do + patch(Player, key, value) + end + patch(Player, "boulderPush", nil) + patch(Objects, "_byId", { [1] = npc }) + patch(Objects, "_order", { 1 }) + patch(Objects, "_mapId", "FIX_ROUTE") + patch(Field, "running", true) + patch(Field, "_game", game) + patch(Field, "_session", game.save) + patch(Field, "_locks", {}) + patch(Space, "active", true) + patch(Space, "vm", { + ctx = {}, + isRunning = function() + return false + end, + }) + patch(Space, "startScript", function(key) + assert(key == "TALK") + calls[#calls + 1] = npc + return true + end) + patch(require("src.core.game3.runtime"), "uiBusy", function() + return false + end) + patch(Collision, "cell", function() + return counter and 1 or 0 + end) + patch(CollisionStd, "isCounter", function(cell) + return cell == 1 + end) + owner, world = game, { player = player, npcs = { npc } } + interact = function() + return Field.interact(game) + end + elseif backend == "gen1" and gen == 2 then + local World = require("src.world.gen2.World") + world = setmetatable({ + game = game, + player = player, + npcs = { npc }, + map = { id = "FIX_ROUTE" }, + vm = { + running = function() + return false + end, + start = function(_, key) + assert(key == "TALK") + calls[#calls + 1] = npc + return true + end, + }, + facingObjectCell = function() + return 4, counter and 4 or 5 + end, + }, { __index = World }) + owner = world + interact = function() + return world:interactBody() + end + else + local World = require("src.world.OverworldController") + world = setmetatable({ + game = game, + player = player, + npcs = { npc }, + map = { + id = "FIX_ROUTE", + def = {}, + isCounterCell = function() + return counter + end, + }, + talkTo = function(_, target) + calls[#calls + 1] = target + end, + }, { __index = World }) + owner = world + interact = function() + return world:interact() + end + end + local off = result.loader.events:on("world.interacted", function(event) + events[#events + 1] = event + end) + local exports = result.loader.exports.portable_world_talk + return { + T = T, + result = result, + exports = exports, + public = exports and exports.api, + game = game, + world = world, + owner = owner, + npc = npc, + calls = calls, + events = events, + api = api, + gen = gen, + backend = backend, + patch = patch, + counter = function(value) + counter = value + npc.cellY, npc.targetY = value and 4 or 5, value and 4 or 5 + end, + interact = interact, + close = function() + off() + for i = #restores, 1, -1 do + restores[i]() + end + result.release() + end, + } +end +return M diff --git a/tests/world_talk/worker.lua b/tests/world_talk/worker.lua new file mode 100644 index 000000000..2e421d3ce --- /dev/null +++ b/tests/world_talk/worker.lua @@ -0,0 +1,32 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local Fixture = dofile(root .. "/tests/world_talk/fixture.lua") +local f = Fixture.new(root, backend, version, api, format) +local rows +if backend == "gen1" and f.gen == 2 then + local before = #f.calls + f.interact() + f.T.eq(#f.calls, before + 1, "native Gold conversation runs") + f.T.eq(f.exports.seen.calls, 0, "native Gold has no world.talk producer despite catalog") + rows = { { label = "reference boundary", native = 1 } } +else + rows = dofile(root .. "/tests/world_talk/cases.lua").run(f) +end +f.close() +for _, failing in ipairs({ false, true }) do + local bare = Fixture.new(root, backend, version, api, format, not failing, failing) + bare.T.eq( + require("src.mods.Runtime").wantsHook("world.talk"), + false, + "no-mod / entry rollback unsubscribed" + ) + local before = #bare.calls + bare.interact() + bare.T.eq(#bare.calls, before + 1, "no-mod / entry rollback keeps native interaction") + if failing then + bare.T.check(#bare.result.errors > 0, "entry error reported") + end + bare.close() +end +print("WORLD_TALK_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable world.talk contract") diff --git a/tools/world-talk-contract-check/check.py b/tools/world-talk-contract-check/check.py new file mode 100644 index 000000000..cab9ec430 --- /dev/null +++ b/tools/world-talk-contract-check/check.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Run one unchanged NPC talk mod through both engines' native NPC interaction paths.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/world_talk/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("WORLD_TALK_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("WORLD_TALK_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + if version == "gold": + assert observations[0] == [{"label": "reference boundary", "native": 1}] + print("PASS gold: reference has no native producer; Gen2 runs the shared cases") + continue + assert observations[0] == observations[1], f"NPC talk mismatch: {version}" + print(f"PASS {version}: matching native NPC interaction paths across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_world_talk/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From ca7aed554ba3bb06a26365430358574dd69d2e87 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:39:42 +0200 Subject: [PATCH 029/138] Add portable trainer preparation and battle-local party selection --- .../trainer-preparation-contract.yml | 52 +++ docs/api3-portability.md | 2 +- docs/modding.md | 6 + docs/portable-trainer-preparation.md | 114 +++++++ .../portable_trainer_preparation/main.lua | 21 ++ .../manifest.json | 1 + src/battle/BattleState.lua | 42 ++- src/battle/Gen3AI.lua | 2 +- src/battle/Gen3MoveEffects.lua | 4 +- src/battle/Gen5MoveEffects.lua | 8 +- src/battle/Gen5MoveRules.lua | 2 +- src/battle/PlayerPartyScope.lua | 36 ++ src/battle/TrainerPreparation.lua | 124 +++++++ src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 3 +- src/script/Commands.lua | 32 +- src/script/ScriptRunner.lua | 25 ++ src/ui/Gen3PartyMenu.lua | 4 +- src/ui/Gen4PartyMenu.lua | 4 +- src/ui/PartyMenu.lua | 2 +- src/world/OverworldController.lua | 36 +- tests/engine/mod_supports.lua | 1 + tests/engine/trainer_party_scope.lua | 68 ++++ tests/engine/trainer_preparation.lua | 21 ++ tests/engine/trainer_preparation_native.lua | 245 ++++++++++++++ tests/trainer_preparation/cases.lua | 87 +++++ tests/trainer_preparation/fixture.lua | 320 ++++++++++++++++++ tests/trainer_preparation/worker.lua | 8 + .../check.py | 58 ++++ 29 files changed, 1294 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/trainer-preparation-contract.yml create mode 100644 docs/portable-trainer-preparation.md create mode 100644 examples/portable_trainer_preparation/main.lua create mode 100644 examples/portable_trainer_preparation/manifest.json create mode 100644 src/battle/PlayerPartyScope.lua create mode 100644 src/battle/TrainerPreparation.lua create mode 100644 tests/engine/trainer_party_scope.lua create mode 100644 tests/engine/trainer_preparation.lua create mode 100644 tests/engine/trainer_preparation_native.lua create mode 100644 tests/trainer_preparation/cases.lua create mode 100644 tests/trainer_preparation/fixture.lua create mode 100644 tests/trainer_preparation/worker.lua create mode 100644 tools/trainer-preparation-contract-check/check.py diff --git a/.github/workflows/trainer-preparation-contract.yml b/.github/workflows/trainer-preparation-contract.yml new file mode 100644 index 000000000..afab720ef --- /dev/null +++ b/.github/workflows/trainer-preparation-contract.yml @@ -0,0 +1,52 @@ +name: portable trainer.before_battle contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: trainer-preparation-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native trainer admission, scripts and party scope + run: | + luajit tests/engine/trainer_preparation.lua + luajit tests/engine/trainer_preparation_native.lua + luajit tests/engine/trainer_party_scope.lua + - name: Validate pinned native Gen1 trainer contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/trainer_before_battle.lua + luajit tests/modkit/cases/unified_gen2_hooks.lua + luajit tests/modkit/cases/unified_gen3_hooks.lua + - name: Compare native API 1 and 2 + run: | + mkdir -p /tmp/trainer-preparation-contract + python3 tools/trainer-preparation-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/trainer-preparation-contract/api1-native.json + python3 tools/trainer-preparation-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/trainer-preparation-contract/api2-native.json + - name: Compare unified callbacks and API 3 + run: | + python3 tools/trainer-preparation-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/trainer-preparation-contract/api2-unified.json + python3 tools/trainer-preparation-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/trainer-preparation-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: trainer-preparation-contract + path: /tmp/trainer-preparation-contract + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index bf6315cfd..d5c5f3f6b 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -95,7 +95,7 @@ emits nothing. Invalid save stores are refused without overwriting them. Gen1 API-3 callback names from the pinned reference are recognized even when this engine has no call site for them. Registration is allowed; availability still requires a native call site in the selected version. For example, -`trainer.before_battle` can be subscribed to but reports unavailable here. Unknown +`pokemon.level_visible` can be subscribed to but reports unavailable here. Unknown names remain errors. An accepted subscription is not proof that a feature runs. Source-specific engine handles in lifecycle/UI payloads remain native. diff --git a/docs/modding.md b/docs/modding.md index 455f2886f..7c051609b 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -76,6 +76,12 @@ update `src/mods/Capabilities.lua` and the capability tests in the same change. Additional read-only world summaries: [map overview and encounter queries](world-overview.md). +## Trainer battle preparation + +The [portable trainer preparation contract](portable-trainer-preparation.md) +documents `trainer.before_battle(next, game, context, continueBattle)`, deferred +native admission, cancellation and battle-local player-party selection. + ## NPC conversation hook The [portable NPC talk contract](portable-world-talk.md) documents diff --git a/docs/portable-trainer-preparation.md b/docs/portable-trainer-preparation.md new file mode 100644 index 000000000..090f7c581 --- /dev/null +++ b/docs/portable-trainer-preparation.md @@ -0,0 +1,114 @@ +# Portable trainer battle preparation + +`trainer.before_battle(next, game, context, continueBattle)` runs before the +native trainer battle is constructed. Return literal `true` to defer it; +returning `false`, `nil`, zero or another value lets native admission continue. +`next(game, context, continueBattle)` delegates to other wrappers/default false. +The retained `continueBattle` is a separate one-shot admission function, not +an asynchronous use of the hook bus's synchronous `next`. + +```lua +local mod = ... +mod.hooks:wrap("trainer.before_battle", function(next, game, context, continueBattle) + -- A preparation UI can retain this function until its decision is ready. + openPreparationUI(function(indices) + continueBattle(indices and {playerPartyIndices = indices} or {cancel = true}) + end) + return true +end) +``` + +`continueBattle()` starts normally; `{cancel=true}` releases the encounter +without constructing a battle or granting victory. A valid first invocation +returns true, including cancellation; duplicates and invalidated requests +return false. A callback can also continue immediately and return true. +Wrappers failing before/after forwarding fall through without duplicating +native admission. Omitting a deferred decision keeps the encounter pending; +mods must offer a decision or cancellation in their UI. + +## Scope and native behavior + +The context has native `trainerClass`, `partyIndex`, and optional `mapId` and +`npcId`. Identifiers are not a newly canonicalized cross-generation trainer +registry. Gen2 keeps Game opaque and exposes the context through its ordinary +unified context adapter. Pinned Gen1 API 3 also creates a shallow context view +for Game; table identity/arbitrary Game edits are not portable guarantees. +API 1/2 keep their callback defaults; Gen1 ignores API-2 `callback_format`. + +The Gen2 producer covers the existing OverworldController trainer challenge +completion and `Commands.start_battle` trainer scripts, including their GBA/DS +wrappers. Trainer sight uses these native routes after its approach. It does +not wrap constructors globally. Direct facility constructors or mod calls to +private constructors do not become new preparation producers. Wild, link, +first-battle and tutorial-marked script entries retain their original routes. +`mod:supports("hooks", "trainer.before_battle")` reports these native producers +on all 15 supported hosts, not interception of every private constructor. + +The ordinary challenge TextBox remains before preparation. Victory text, +flags, rewards, post-battle flows and transition ownership remain native. +Cancellation does not defeat the trainer and suppresses immediate same-tile +sight retriggering. Script preparation parks the real ScriptRunner before +construction; its continuation resumes that coroutine, then the normal battle +completion resumes it again. Cancelled scripts receive `lastCheck=false` and +`lastBattleResult="cancel"`, then retire their owning coroutine and release +native script locks/caller completion. Post-battle rows and deferred rewards +never run, including for immediate cancellation and strict mod scripts. Native per-engine event +placement/cancellation details are not promised identical. + +A deferred Gen2 request is tied to the original Game/save/data/stack/session, +party table/member order, hook provider/subscriptions and native actor/map or +script coroutine/context. Retained callbacks cannot start against a replaced +session, removed NPC or changed trainer. Native core.update retires invalid +requests; removal of a subscriber cancels a still-current encounter and frees +its pending guard. Stale contexts are retired without running an old completion +against a replacement session. Core-update watcher cleanup preserves other +subscribers. Registration rollback, unsubscribe and owner cleanup still apply. + +## Battle-local player party + +`{playerPartyIndices={3,2}}` selects save-party members three and two, in that +order, for this battle only. It copies the index list and keeps references to +the selected Pokémon. It never replaces/reorders `save.party` or clones the +Game. HP/PP/status/EXP changes to selected members remain real save changes. +The scope governs the lead, doubles partner, switch/replacement eligibility, +native party menus, EXP participation/EXP.ALL, party-based move/AI queries, +party-row rendering and scope exhaustion. Public battle snapshots already read +the battle-local party. Catch storage and tutorial party restoration continue +to use the actual save. + +An empty, sparse, duplicate, fractional, out-of-range or non-table selection +falls back to the full party, matching the pinned reference's bounded behavior. +Selected fainted/egg members are not silently replaced by unselected healthy +ones: native eligibility applies within the selected party. All selected mons +fainting can end the battle even if an excluded bench mon is healthy. + +## Verification + +```sh +luajit tests/engine/trainer_preparation.lua +luajit tests/engine/trainer_preparation_native.lua +luajit tests/engine/trainer_party_scope.lua +python3 tools/trainer-preparation-contract-check/check.py --gen1 /path/to/gen1 --api 1 +python3 tools/trainer-preparation-contract-check/check.py --gen1 /path/to/gen1 +python3 tools/trainer-preparation-contract-check/check.py --gen1 /path/to/gen1 --format unified +python3 tools/trainer-preparation-contract-check/check.py --gen1 /path/to/gen1 --api 3 --format unified +``` + +The clean pinned Gen1 reference is +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. One unchanged example consumer runs +on Red, Yellow, Gold, FireRed and Emerald through both real Loaders. Classic +Gen1 and Gen2 workers use their native prepareTrainerBattle and real battle +constructors; native Gen1 Gold uses startScriptedBattle and its own battle +engine; Gen1 GBA uses the real BattleBridge and native state. The worker compares +native construction, party order, deferral, cancellation, immediate continuation, +true-only claiming, wrapper failures, cleanup, rollback and invalid-scope fallback. +No reference sources are modified. + +Gen2-only tests exercise actual challenge TextBoxes, script coroutines, victory +flags/rewards, cancellation, stale references and one-shot admission on all 15 +hosts/four callback modes. Party-scope tests exercise actual doubles, native +party screens, EXP and defeat logic. Fixtures supply ROM-free data/asset seams; +the native producer, constructors and gameplay methods are not replaced. +The workflow additionally runs Gen1's unmodified trainer_before_battle, +unified_gen2_hooks and unified_gen3_hooks suites. These checks do not claim +an imported-ROM playthrough or GPU pixel equivalence. diff --git a/examples/portable_trainer_preparation/main.lua b/examples/portable_trainer_preparation/main.lua new file mode 100644 index 000000000..baaf869a3 --- /dev/null +++ b/examples/portable_trainer_preparation/main.lua @@ -0,0 +1,21 @@ +-- A preparation UI may retain continueBattle; next itself stays synchronous. +local mod = ... +local out = mod.exports +out.api, out.mode, out.calls = mod, "defer", 0 +out.stop = mod.hooks:wrap("trainer.before_battle", function(next, game, context, continueBattle, ...) + out.calls = out.calls + 1 + out.game, out.context, out.continue, out.extra = game, context, continueBattle, select("#", ...) + if out.mode == "error" or out.mode == "after error" then + if out.mode == "after error" then + next(game, context, continueBattle) + end + error("intentional trainer preparation failure") + end + if out.mode == "observe" then + return next(game, context, continueBattle) + end + if out.mode == "immediate" then + continueBattle(out.options) + end + return out.mode == "defer" or out.mode == "immediate" +end) diff --git a/examples/portable_trainer_preparation/manifest.json b/examples/portable_trainer_preparation/manifest.json new file mode 100644 index 000000000..fe5447bf1 --- /dev/null +++ b/examples/portable_trainer_preparation/manifest.json @@ -0,0 +1 @@ +{"id":"portable_trainer_preparation","name":"Portable Trainer Preparation","version":"1.0.0","api":3,"entry":"main.lua","games":["all"],"generations":[1,2,3,4,5],"affects_link":false} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index f3a59f20c..15f564b8c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -581,6 +581,10 @@ end BattleState.statusHUDVisible = StatusHUDVisibility.visible +function BattleState:playerPartyView() + return self.playerParty or self.game.save.party or {} +end + function BattleState:bottomUIVisible() return UIVisibility.bottomVisible(self, true) end @@ -1710,10 +1714,12 @@ function BattleState:trainerLabel_() return self.trainerLabel or (self.trainer and self.trainer.name) or "?" end -function BattleState.newTrainer(game, oppClass, partyIndex) +function BattleState.newTrainer(game, oppClass, partyIndex, opts) local self = newBattle(game) self.kind = "trainer" self.oppClass = oppClass + self.playerParty, self.playerPartyIndices = require("src.battle.PlayerPartyScope").select(game, + type(opts) == "table" and opts.playerPartyIndices or nil) -- wOtherTrainerID: which trainer inside the class. PlayBattleMusic reads -- it to tell the two RIVAL2 fights apart, so keep it on the battle. self.partyIndex = tonumber(partyIndex) or 1 @@ -1741,7 +1747,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex) end self.enemyParty = buildTrainerParty(game, oppClass, partyIndex or 1, partyDef) self.enemyIndex = 1 - local playerMon = Party.firstHealthy(game.save.party) + local playerMon = Party.firstHealthy(self:playerPartyView()) if not playerMon then Logger.warn("trainer battle with no healthy party; skipping") self.dead = true @@ -1848,7 +1854,7 @@ function BattleState:sendOutSecondPair() if self:battlerAt(BattleState.POS.PLAYER_RIGHT) == nil and not self.ally then -- the next healthy one that is not already standing in the left slot local lead = self.player and self.player.mon - local second = Party.firstHealthy(game.save.party, lead) + local second = Party.firstHealthy(self:playerPartyView(), lead) if second then self:placeBattler(BattleState.POS.PLAYER_RIGHT, makeBattler(game.data, second, true, game.save)) @@ -1984,7 +1990,7 @@ BattleState.EDITOR_TRAINER_CLASS = "OPP_EDITOR_TRAINER" -- `party` is an array of slots in newTrainer's own shape; `look` is the class -- to borrow a pic, palette and AI from (the NPC's own trainerClass, when it -- named one). -function BattleState.newEditorTrainer(game, party, look, name) +function BattleState.newEditorTrainer(game, party, look, name, opts) local trainers = game.data.trainers if type(party) ~= "table" or #party == 0 then return nil, "that team is empty" end local base = look and trainers[look] or nil @@ -2002,7 +2008,7 @@ function BattleState.newEditorTrainer(game, party, look, name) def.paletteSource, def.aiMods = base.paletteSource, base.aiMods end trainers[BattleState.EDITOR_TRAINER_CLASS] = def - return BattleState.newTrainer(game, BattleState.EDITOR_TRAINER_CLASS, 1) + return BattleState.newTrainer(game, BattleState.EDITOR_TRAINER_CLASS, 1, opts) end -- PLATINUM'S ESCORT WILD BATTLE (BATTLE_TYPE_AI_PARTNER, wild_encounters.c): @@ -4473,7 +4479,7 @@ function BattleState:update(dt) -- loops the party menu until a healthy mon is picked, so B and -- fainted picks land back here and reopen it if acting.mon.hp <= 0 then - if Party.firstHealthy(self.game.save.party) then + if Party.firstHealthy(self:playerPartyView()) then self:openReplacementMenu() end return @@ -4972,7 +4978,7 @@ function BattleState:openPokedudeTarget(step, switchMode) if switchMode and p.scriptTick == 48 then p.index = step.switchTo or 2 end if p.scriptTick > 96 and not p._pokedudePicked then p._pokedudePicked = true - local mon = self.game.save.party[p.index] + local mon = self:playerPartyView()[p.index] p:close() if switchMode then self:resolveSwitch(mon) @@ -8454,7 +8460,7 @@ function BattleState:benchFor(isPlayer, position) if isPlayer and self.ally and position == BattleState.POS.PLAYER_RIGHT then return self.ally.party or {} end - if isPlayer then return self.game.save.party or {} end + if isPlayer then return self:playerPartyView() or {} end if position == BattleState.POS.OPPONENT_RIGHT and self.enemyPartyB then return self.enemyPartyB end @@ -8642,7 +8648,7 @@ function BattleState:awardExp(fallen) -- (RemoveFaintedPlayerMon), so it drops out of the divisor and only -- the surviving participants are counted and paid local participants, alive = 0, {} - for _, mon in ipairs(self.game.save.party) do + for _, mon in ipairs(self:playerPartyView()) do if self.participants and self.participants[mon] then participants = participants + 1 if mon.hp > 0 then table.insert(alive, mon) end @@ -8763,7 +8769,7 @@ function BattleState:awardExp(fallen) local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0 if not expAll then local holders = {} - for _, mon in ipairs(self.game.save.party) do + for _, mon in ipairs(self:playerPartyView()) do if (mon.hp or 0) > 0 and holdsExpShare(mon) then holders[#holders + 1] = mon end @@ -8793,9 +8799,9 @@ function BattleState:awardExp(fallen) -- experience.asm:9-13); each mon gets its own GainedText with the -- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no -- summary line - for _, mon in ipairs(self.game.save.party) do + for _, mon in ipairs(self:playerPartyView()) do if mon.hp > 0 then - ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll") + ctx.applyShare(mon, math.max(1, ctx.participants) * #self:playerPartyView() * 2, "expAll") end end end @@ -8840,7 +8846,7 @@ function BattleState:enemyMonFainted() local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name local style = tostring((self.game.save.options or {}).battleStyle or "shift") :lower() - local partyCount = #self.game.save.party + local partyCount = #self:playerPartyView() -- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the -- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays -- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into @@ -9170,7 +9176,7 @@ function BattleState.isOaksLabStarterRival(self) end function BattleState:playerMonFainted() - local nextMon = Party.firstHealthy(self.game.save.party) + local nextMon = Party.firstHealthy(self:playerPartyView()) -- Being out of useable POKéMON blacks you out even when the battle was -- already decided in our favour. A double faint -- our last mon dying -- to residual damage on the turn it lands the KO -- used to hit the @@ -9258,7 +9264,7 @@ function BattleState:playerMonFainted() local ChoiceBox = require("src.ui.ChoiceBox") return ChoiceBox.new(game, function(yes) if yes then return end -- the menu-phase guard opens the party menu - local pSpd = (game.save.party[1].stats or { speed = 0 }).speed or 0 + local pSpd = (self:playerPartyView()[1].stats or { speed = 0 }).speed or 0 if self:runRoll(pSpd, TurnOrder.effectiveSpeed(self.enemy)) then require("src.core.Sound").play(self.data, "Run") self:say(self:romText("_GotAwayText", "Got away safely!")) @@ -9316,7 +9322,7 @@ function BattleState:openReplacementMenu() -- unasked (PlayerHandleChoosePokemon's arena arm returns the next slot) if self.palace or self.arena then self:act(function() - for _, mon in ipairs(game.save.party or {}) do + for _, mon in ipairs(self:playerPartyView() or {}) do if mon.hp > 0 and not Party.isEgg(mon) and mon ~= (self.player and self.player.mon) then onSwitch(mon) return @@ -10589,7 +10595,7 @@ function BattleState:finish() -- The old-man / PROF.OAK demo also skips it: the party never fought -- (Yellow's Pallet intro runs before the player owns a mon at all). if self.result ~= "lose" and not self.demo and not self.pokedudeDemo - and not Party.firstHealthy(self.game.save.party) then + and not Party.firstHealthy(self:playerPartyView()) then Logger.warn("battle finished %s with no healthy party; forcing blackout", tostring(self.result)) self.result = "lose" @@ -12337,7 +12343,7 @@ function BattleState:drawHUDs(slide) for i = 10, 17 do hudTile(0x76, i * 8, 88) end hudTile(0x6F, 72, 88) love.graphics.setColor(1, 1, 1, 1) - self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8) + self:drawBallRow(self.playerParty or self:playerPartyView(), 88, 80, 8) end local hidePlayer = self.safari or self:demoHidesPlayer() if showStatus and self.player and not hidePlayer and not self.showPlayerBack diff --git a/src/battle/Gen3AI.lua b/src/battle/Gen3AI.lua index 42fe0fd4c..520c44ecc 100644 --- a/src/battle/Gen3AI.lua +++ b/src/battle/Gen3AI.lua @@ -433,7 +433,7 @@ OPS["count_usable_party_mons"] = function(vm, args) local party if b.isPlayer then party = battle.playerParty or (battle.game and battle.game.save - and battle.game.save.party) + and (battle.playerParty or battle.game.save.party)) else party = battle.enemyParty end diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua index c69724dce..bed004a8c 100644 --- a/src/battle/Gen3MoveEffects.lua +++ b/src/battle/Gen3MoveEffects.lua @@ -256,7 +256,7 @@ P.HEAL_BELL_EFFECT = function(battle, user) user.mon.status = nil user.toxicCounter = nil local party = user.isPlayer and battle.game and battle.game.save - and battle.game.save.party + and (battle.playerParty or battle.game.save.party) for _, mon in ipairs(party or {}) do mon.status = nil end return { Strings("A bell chimed!") } end @@ -1006,7 +1006,7 @@ F.FUTURE_SIGHT_EFFECT = { F.BEAT_UP_EFFECT = { hitCount = function(ctx) local party = ctx.user.isPlayer and ctx.battle.game - and ctx.battle.game.save and ctx.battle.game.save.party + and ctx.battle.game.save and (ctx.battle.playerParty or ctx.battle.game.save.party) if not party then return 1 end local n = 0 for _, mon in ipairs(party) do diff --git a/src/battle/Gen5MoveEffects.lua b/src/battle/Gen5MoveEffects.lua index 64682dade..46abc5276 100644 --- a/src/battle/Gen5MoveEffects.lua +++ b/src/battle/Gen5MoveEffects.lua @@ -1545,7 +1545,7 @@ end }) DEF.BW_BEAT_UP = F({ hitCount = function(ctx) local party = ctx.user.isPlayer and ctx.battle.game and ctx.battle.game.save - and ctx.battle.game.save.party or ctx.battle.enemyParty + and (ctx.battle.playerParty or ctx.battle.game.save.party) or ctx.battle.enemyParty local n = 0 for _, mon in ipairs(party or {}) do if (mon.hp or 0) > 0 and not mon.status then n = n + 1 end @@ -1708,7 +1708,7 @@ end }) local function benchOf(battle, b) local out = {} if b.isPlayer then - local party = battle.game and battle.game.save and battle.game.save.party or {} + local party = battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or {} for _, mon in ipairs(party) do if mon ~= b.mon and (mon.hp or 0) > 0 and not mon.isEgg and not mon.egg then local onField = false @@ -2105,7 +2105,7 @@ DEF.BW_REFRESH = P({ run = enginePrimary("REFRESH_EFFECT") }) DEF.BW_HEAL_BELL = P({ run = function(ctx) local battle, user = ctx.battle, ctx.user user.mon.status, user.toxicCounter = nil, nil - local party = user.isPlayer and battle.game and battle.game.save and battle.game.save.party + local party = user.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or battle.enemyParty for _, mon in ipairs(party or {}) do mon.status = nil end return { T.std(battle, ctx.move.id == 312 and "aroma" or "bell") } @@ -2490,7 +2490,7 @@ DEF.BW_METRONOME = F({ callsMove = function(ctx) end }) DEF.BW_ASSIST = F({ callsMove = function(ctx) local battle, u = ctx.battle, ctx.user - local party = u.isPlayer and battle.game and battle.game.save and battle.game.save.party or battle.enemyParty + local party = u.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or battle.enemyParty local pool = {} for _, mon in ipairs(party or {}) do if mon ~= u.mon then diff --git a/src/battle/Gen5MoveRules.lua b/src/battle/Gen5MoveRules.lua index b6faa003d..c8afd08cf 100644 --- a/src/battle/Gen5MoveRules.lua +++ b/src/battle/Gen5MoveRules.lua @@ -609,7 +609,7 @@ end local function beatUp(G5) return F({ perform = function(ctx, rec) local battle, user, target, move = ctx.battle, ctx.user, ctx.target, ctx.move - local party = user.isPlayer and battle.game and battle.game.save and battle.game.save.party + local party = user.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or battle.enemyParty or {} local members = {} for _, mon in ipairs(party) do diff --git a/src/battle/PlayerPartyScope.lua b/src/battle/PlayerPartyScope.lua new file mode 100644 index 000000000..6706a2b4b --- /dev/null +++ b/src/battle/PlayerPartyScope.lua @@ -0,0 +1,36 @@ +-- Battle-local ordered references; never reorder or replace save.party. +local Logger = require("src.core.Logger") +local M = {} +function M.select(game, indices) + if indices == nil then + return nil, nil + end + local function fallback() + Logger.warn("trainer battle party scope is invalid; using full party") + return nil, nil + end + if type(indices) ~= "table" or #indices == 0 then + return fallback() + end + local count = #indices + local keys = 0 + for key in pairs(indices) do + keys = keys + 1 + if type(key) ~= "number" or key % 1 ~= 0 or key < 1 or key > count then + return fallback() + end + end + if keys ~= count then + return fallback() + end + local party, normalized, seen = {}, {}, {} + for i = 1, count do + local index = indices[i] + if type(index) ~= "number" or index % 1 ~= 0 or not game.save.party[index] or seen[index] then + return fallback() + end + seen[index], normalized[i], party[i] = true, index, game.save.party[index] + end + return party, normalized +end +return M diff --git a/src/battle/TrainerPreparation.lua b/src/battle/TrainerPreparation.lua new file mode 100644 index 000000000..bd387361b --- /dev/null +++ b/src/battle/TrainerPreparation.lua @@ -0,0 +1,124 @@ +-- A synchronous hook can retain this one-shot native admission continuation. +local Runtime = require("src.mods.Runtime") +local M = { CANCEL_SCRIPT = {} } +local pending = setmetatable({}, { __mode = "k" }) +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function defaultGate() + return false +end +function M.active(game) + return pending[game] ~= nil +end +function M.prepare(game, context, startBattle, cancelBattle, valid, retireBattle) + if not Runtime.wantsHook("trainer.before_battle") then + startBattle() + return false + end + if pending[game] then + return true + end + local hooks, events = Runtime.hooks, Runtime.events + local save, data, stack, session = game.save, game.data, game.stack, game.sessionStartedAt + local world, overworld = game.world, game.overworld + local party = save and save.party + local members = {} + for i, mon in ipairs(party or {}) do + members[i] = mon + end + local entries = {} + for _, entry in ipairs(hooks.chains["trainer.before_battle"] or {}) do + entries[#entries + 1] = entry + end + local token, finished, unsubscribe = {}, false + pending[game] = token + local function current() + if + game.save ~= save + or game.data ~= data + or game.stack ~= stack + or game.sessionStartedAt ~= session + or game.world ~= world + or game.overworld ~= overworld + or (save and save.party ~= party) + then + return false + end + if #(party or {}) ~= #members then + return false + end + for i, mon in ipairs(members) do + if party[i] ~= mon then + return false + end + end + return not valid or valid() + end + local function live() + if Runtime.hooks ~= hooks or Runtime.events ~= events then + return false + end + for _, original in ipairs(entries) do + local found = false + for _, entry in ipairs(hooks.chains["trainer.before_battle"] or {}) do + if entry == original then + found = true + break + end + end + if not found then + return false + end + end + return true + end + local function discard() + finished = true + if pending[game] == token then + pending[game] = nil + end + if unsubscribe then + unsubscribe() + unsubscribe = nil + end + end + local function continueBattle(options) + if finished or pending[game] ~= token then + return false + end + local eligible = current() + if not eligible or not live() then + discard() + if eligible and cancelBattle then + cancelBattle() + elseif retireBattle then + retireBattle() + end + return false + end + discard() + if type(options) == "table" and options.cancel == true then + if cancelBattle then + cancelBattle() + end + else + startBattle(options) + end + return true + end + unsubscribe = hooks:wrap("core.update", function(next, ...) + local results = pack(next(...)) + if not finished and (not current() or not live()) then + continueBattle({ cancel = true }) + end + return unpack(results, 1, results.n) + end, -1000, "base") + local deferred = Runtime.call("trainer.before_battle", defaultGate, game, context, continueBattle) + if deferred ~= true and not finished then + continueBattle() + end + return deferred == true +end +return M diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index d25eece43..c5b53d710 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -21,7 +21,7 @@ add("hooks", "12345", [[ fieldmove.eligibility input.key input.gamepad input.wheel input.step link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite render.compose render.hud render.letterbox render.zones save.new_game save.write screen.render_visible - script.command trainer.party transition.style ui.list_menu ui.options.rows + script.command trainer.before_battle trainer.party transition.style ui.list_menu ui.options.rows ui.title_menu.items warp.destination world.talk world.tod zoom.range ]]) -- Screens have distinct implementations: Screens.lua aliases the DS party, diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 7b6c34fb9..f5af41721 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -21,7 +21,7 @@ C.hooks = names([[ encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite render.compose render.hud - render.letterbox render.zones save.new_game save.write screen.render_visible script.command trainer.party transition.style + render.letterbox render.zones save.new_game save.write screen.render_visible script.command trainer.before_battle trainer.party transition.style ui.list_menu ui.naming.grid ui.options.rows ui.party.submenu ui.pc.items ui.start_menu.items ui.title_menu.items warp.destination world.talk world.tod zoom.range ]]) @@ -39,6 +39,7 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["trainer.before_battle"] = { false, "context", false }, ["battle.accuracy"] = { "context" }, ["battle.damage"] = { "context" }, ["battle.crit"] = { "context" }, diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 568ad5dfc..3032dc380 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -620,6 +620,36 @@ end -- start_battle "wild" species level | start_battle "trainer" OPP_CLASS partyIndex function Commands.start_battle(ctx, kind, a, b, opts) + local skipPreparation = opts and (opts.link or opts.firstBattle or opts.firstBattleKind + or opts.oldManTutorial or opts.tutorialKind) + if kind ~= "wild" and not skipPreparation + and require("src.mods.Runtime").wantsHook("trainer.before_battle") then + local runner, co, map = ctx.runner, ctx.runner.co, ctx.overworld and ctx.overworld.map + local ready, waiting, cancelled, selected = false, false, false, nil + local function finish(options) + selected, ready = options, true + if waiting then runner:resume() end + end + require("src.battle.TrainerPreparation").prepare(ctx.game, { + trainerClass = a, partyIndex = b or 1, + mapId = map and map.id, npcId = ctx.npc and ctx.npc.id, + }, finish, function() + cancelled, ready = true, true + ctx.lastCheck, ctx.lastBattleResult = false, "cancel" + ctx.trainerPreparationCancelled = true + if waiting then runner:cancelTrainerPreparation(ctx, co) end + end, function() + return runner.co == co and runner.ctx == ctx and runner:isRunning() + and (not ctx.overworld or ctx.overworld.map == map) + end, function() runner:cancelTrainerPreparation(ctx, co, true) end) + if not ready then waiting = true; runner:yield(); waiting = false end + if cancelled then error(require("src.battle.TrainerPreparation").CANCEL_SCRIPT, 0) end + local original = opts + opts = {} + for key, value in pairs(original or {}) do opts[key] = value end + if type(selected) == "table" then opts.playerPartyIndices = selected.playerPartyIndices end + end + local BattleState = require("src.battle.BattleState") local runner = ctx.runner local battle @@ -649,7 +679,7 @@ function Commands.start_battle(ctx, kind, a, b, opts) battle.enemy.mon.fatefulEncounter = true end else - battle = BattleState.newTrainer(ctx.game, a, b) + battle = BattleState.newTrainer(ctx.game, a, b, opts) -- the beaten trainer's own line, printed on the battle screen before -- MoneyForWinningText rather than by the script afterwards battle.endBattleText = opts and opts.endBattleText or nil diff --git a/src/script/ScriptRunner.lua b/src/script/ScriptRunner.lua index 595ca1fa9..65c7e83fa 100644 --- a/src/script/ScriptRunner.lua +++ b/src/script/ScriptRunner.lua @@ -125,6 +125,7 @@ function ScriptRunner:run(script, extra) end self.co = coroutine.create(function() self:exec(script, ctx) + if ctx.trainerPreparationCancelled then return end -- Commands can defer a game action until the script's own dialogue is -- done. start_battle uses this for win-path evolutions, which must not -- be covered by post-battle trainer text. @@ -251,6 +252,10 @@ function ScriptRunner:exec(script, ctx) if ok then jump = result else + if result == require("src.battle.TrainerPreparation").CANCEL_SCRIPT then + self:cancelTrainerPreparation(ctx, self.co) + return + end Logger.error("script: command '%s' failed at row %d (skipped): %s", tostring(name), pc, tostring(result)) jump = nil @@ -275,6 +280,22 @@ function ScriptRunner:exec(script, ctx) end end +-- Retire only the coroutine that owns this admission; cancellation is not a +-- native battle outcome and must never reach post-win script rows/callbacks. +function ScriptRunner:cancelTrainerPreparation(ctx, co, silent) + if self.ctx ~= ctx or self.co ~= co or not co then return false end + ctx.trainerPreparationCancelled = true + ctx.lastCheck, ctx.lastBattleResult = false, "cancel" + self.co, self.waitingFrames, self.waitingCheck = nil, nil, nil + if silent then return true end + if ctx.g3Locked and Commands.g3_release then Commands.g3_release(ctx) end + if ctx.g4Locked and Commands.g4_release_all then Commands.g4_release_all(ctx) end + if ctx.g3FadedOut then require("src.script.Gen3Commands").restoreFade(ctx) end + if ctx.onDone then ctx.onDone() end + Runtime.emit("script.ended", { ctx = ctx, completed = false }) + return true +end + -- Called by blocking commands from inside the coroutine. function ScriptRunner:yield() return coroutine.yield() @@ -294,6 +315,10 @@ function ScriptRunner:resume(...) end local ok, err = coroutine.resume(co, ...) if not ok then + if err == require("src.battle.TrainerPreparation").CANCEL_SCRIPT then + self:cancelTrainerPreparation(self.ctx, co) + return + end local source = self.ctx and self.ctx.source local where = source and Strings(" [%s %s.%s]", tostring(source.modId or "engine"), diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index 855f2c805..b6e997d82 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -168,7 +168,9 @@ function Gen3PartyMenu.new(game, opts) return self end -function Gen3PartyMenu:party() return self.game.save.party or {} end +function Gen3PartyMenu:party() + return (self.battle and self.battle.playerParty) or self.game.save.party or {} +end -- slots 1..6, then the CANCEL button as slot #party+1 function Gen3PartyMenu:slots() return #self:party() + 1 end diff --git a/src/ui/Gen4PartyMenu.lua b/src/ui/Gen4PartyMenu.lua index 851b8ec29..82a9929fe 100644 --- a/src/ui/Gen4PartyMenu.lua +++ b/src/ui/Gen4PartyMenu.lua @@ -261,7 +261,9 @@ function Gen4PartyMenu:drawAt(key, x, y) return true end -function Gen4PartyMenu:party() return self.game.save.party or {} end +function Gen4PartyMenu:party() + return (self.battle and self.battle.playerParty) or self.game.save.party or {} +end -- Slots 1..#party, then CANCEL. function Gen4PartyMenu:count() return #self:party() + (self.select and 2 or 1) end diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 4b487d623..7b907a617 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -438,7 +438,7 @@ function PartyMenu.new(game, opts) self.tmhm = opts.tmhm self.forceSwitch = opts.forceSwitch self.battle = opts.battle - self.party = opts.party -- link battles pass their clamped copies + self.party = opts.party or (opts.battle and opts.battle.playerParty) -- link copies or trainer selection self.swapFrom = nil self.submenu = nil self.subIndex = 1 diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 65cc1d75c..e143fb17a 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -11506,6 +11506,8 @@ function OverworldState:trainerDefeated(npc) return false end +OverworldState.prepareTrainerBattle = require("src.battle.TrainerPreparation").prepare + -- Run the pre-battle text -> battle -> won text -> flags sequence. function OverworldState:engageTrainer(npc, onDone) local d = npc.def @@ -11520,7 +11522,7 @@ function OverworldState:engageTrainer(npc, onDone) local wonText = header and header.won and Game.data.text[header.won] local BattleState = require("src.battle.BattleState") - Game.stack:push(TextBox.new(Game, battleText, function() + local function startBattle(options) -- A TEAM THE AUTHOR BUILT WINS OVER AN INDEX INTO SOMEBODY ELSE'S. -- -- `trainerParty` selects one of the parties the cartridge shipped for @@ -11531,10 +11533,10 @@ function OverworldState:engageTrainer(npc, onDone) local battle if type(d.trainerTeam) == "table" and #d.trainerTeam > 0 then battle = BattleState.newEditorTrainer(Game, d.trainerTeam, - d.trainerClass, d.trainerName) + d.trainerClass, d.trainerName, options) end battle = battle or BattleState.newTrainer(Game, d.trainerClass, - d.trainerParty) + d.trainerParty, options) -- PrintEndBattleText (home/trainers.asm:341) is called from -- TrainerBattleVictory (engine/battle/core.asm:942), i.e. ON the battle -- screen once ScrollTrainerPicAfterBattle has brought the beaten trainer @@ -11563,6 +11565,34 @@ function OverworldState:engageTrainer(npc, onDone) end end self:pushBattle(battle) + end + Game.stack:push(TextBox.new(Game, battleText, function() + local liveGame, liveSave, liveData, liveStack, liveSession = Game, Game.save, Game.data, Game.stack, Game.sessionStartedAt + local map, def = self.map, npc.def + local class, party, npcId, team = d.trainerClass, d.trainerParty, npc.id, d.trainerTeam + local wasEngaging = self.engaging + self.engaging = true + OverworldState.prepareTrainerBattle(Game, { + trainerClass = class, partyIndex = party or 1, mapId = map.id, npcId = npc.id, + }, function(options) + self.engaging = wasEngaging + startBattle(options) + end, function() + self.engaging = wasEngaging + self.spotHush = { cellX = self.player.cellX, cellY = self.player.cellY, [npc] = true } + if onDone then onDone() end + end, function() + if Game ~= liveGame or self.map ~= map or npc.id ~= npcId or npc.def ~= def + or d.trainerTeam ~= team or def.trainerClass ~= class + or def.trainerParty ~= party then return false end + for _, actor in ipairs(self.npcs or {}) do if actor == npc then return true end end + return false + end, function() + if Game == liveGame and Game.save == liveSave and Game.data == liveData + and Game.stack == liveStack and Game.sessionStartedAt == liveSession and self.map == map then + self.engaging, npc.frozen = false, false + end + end) end)) end diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua index 8837ecca4..3fb17e290 100644 --- a/tests/engine/mod_supports.lua +++ b/tests/engine/mod_supports.lua @@ -170,6 +170,7 @@ T.check(result == nil and reason == 'no overworld' and yes(api, 'world', 'warpTo for _, version in ipairs(GameVersion.ORDER) do local mod = probe(version) local gen = GameVersion.generation(version) + T.eq(mod:supports('hooks', 'trainer.before_battle'), true, version .. ': native trainer preparation seam') T.eq(mod:supports('hooks', 'world.talk'), true, version .. ': native NPC talk seam') T.eq(mod:supports('hooks', 'battle.status_hud_visible'), true, version .. ': native GB/GBA/DS HUD seam') diff --git a/tests/engine/trainer_party_scope.lua b/tests/engine/trainer_party_scope.lua new file mode 100644 index 000000000..f7765beb7 --- /dev/null +++ b/tests/engine/trainer_party_scope.lua @@ -0,0 +1,68 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/trainer_preparation/fixture.lua") +local Version = require("src.core.GameVersion") +local Battle = require("src.battle.BattleState") +local Scope = require("src.battle.PlayerPartyScope") +local Runtime = require("src.mods.Runtime") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + local f = Fixture.new(".", "gen2", version, 3, "unified") + local game = f.game + local original, members = game.save.party, { game.save.party[1], game.save.party[2], game.save.party[3] } + f.start() + f.exports.continue({ playerPartyIndices = { 3, 2 } }) + local b = f.battle() + T.eq(b.game, game, "battle keeps original Game") + T.eq(b:playerPartyView()[1], members[3], "scope holds original first reference") + T.eq(b:benchFor(true)[2], members[2], "bench only contains scope") + T.eq(#b:benchFor(true), 2, "excluded mon absent from switch eligibility") + b.double = true + b:sendOutSecondPair() + T.eq(b:battlerAt(Battle.POS.PLAYER_RIGHT).mon, members[2], "double partner uses scoped eligible mon") + local id = require("src.battle.Gen4Battle").partyScreenId(b) or "PartyMenu" + local menu = b:buildScreen(id, { battle = b, onSwitch = function() end }) + local party = type(menu.party) == "function" and menu:party() or menu.party + T.eq(party, b.playerParty, "actual native party menu uses scope") + T.eq(#party, 2, "native menu cannot select excluded mon") + -- Native EXP eligibility ignores an excluded mon even if old participant + -- bookkeeping contains it. EXP.ALL cannot pay the excluded bench either. + b.queue = {} + b.participants = { [members[1]] = true, [members[2]] = true, [members[3]] = true } + game.save.inventory.EXP_ALL = 1 + local excludedExp = members[1].exp + local seen + local off = f.result.loader.hooks:wrap("battle.exp_award", function(next, ctx) + seen = ctx + return next(ctx) + end) + b:awardExp(b.enemy) + T.eq(seen.participants, 2, "native EXP counts selected participants") + T.eq(#seen.alive, 2, "native EXP contains selected living mons") + T.eq(members[1].exp, excludedExp, "excluded mon receives no EXP") + off() + members[2].hp, members[3].hp = 0, 0 + T.check(members[1].hp > 0, "excluded bench stays healthy") + b.result = nil + b:playerMonFainted() + T.eq(b.result, "lose", "scope exhaustion loses despite healthy excluded bench") + T.eq(game.save.party, original, "native loss never swaps save party") + T.same(game.save.party, members, "native loss never reorders save party") + for _, invalid in ipairs({ + {}, + { 1, 1 }, + { 0 }, + { 4 }, + { 1.5 }, + { [1] = 1, [3] = 2 }, + { 1, unexpected = true }, + "bad", + }) do + local selected, indices = Scope.select(game, invalid) + T.eq(selected, nil, "invalid scope falls back to full party") + T.eq(indices, nil, "invalid scope keeps no remap") + end + f.close() +end +Version.set(originalVersion) +T.finish("trainer battle-local player party scope") diff --git a/tests/engine/trainer_preparation.lua b/tests/engine/trainer_preparation.lua new file mode 100644 index 000000000..e25727c2f --- /dev/null +++ b/tests/engine/trainer_preparation.lua @@ -0,0 +1,21 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local Fixture = dofile("tests/trainer_preparation/fixture.lua") +local _, T = dofile("tests/trainer_preparation/cases.lua").run(function(noMod, failing) + return Fixture.new(".", "gen2", "red", 3, "unified", noMod, failing) +end) +-- Keep an executable public-API proof in the engine corpus as well as the +-- shared worker helpers (the coverage gate scans only its explicit tiers). +local f = Fixture.new(".", "gen2", "red", 3, "unified") +local mod, calls = f.exports.api, 0 +local off = mod.hooks:wrap("trainer.before_battle", function(next, ...) + calls = calls + 1 + return next(...) +end, 100) +f.start() +T.eq(calls, 1, "public API callback reached from native preparation") +T.eq(f.starts(), 0, "downstream preparation defers") +T.eq(f.exports.continue(), true, "public continuation starts native battle") +T.eq(f.starts(), 1, "public continuation constructs once") +off() +f.close() +T.finish("portable trainer.before_battle") diff --git a/tests/engine/trainer_preparation_native.lua b/tests/engine/trainer_preparation_native.lua new file mode 100644 index 000000000..ec25820d2 --- /dev/null +++ b/tests/engine/trainer_preparation_native.lua @@ -0,0 +1,245 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/trainer_preparation/fixture.lua") +local Version = require("src.core.GameVersion") +local World = require("src.world.OverworldController") +local Runner = require("src.script.ScriptRunner") +local Preparation = require("src.battle.TrainerPreparation") +local originalVersion = Version.get() +local function bind(fn, name, value) + for i = 1, 100 do + local key, old = debug.getupvalue(fn, i) + if not key then + break + end + if key == name then + debug.setupvalue(fn, i, value) + return function() + debug.setupvalue(fn, i, old) + end + end + end + error("missing native fixture binding " .. name) +end +local function setup(version, api, format) + local f = Fixture.new(".", "gen2", version, api, format) + local npc = { id = "TRAINER_1", def = { index = 1, trainerClass = "OPP_FIX_YOUNGSTER", trainerParty = 1 } } + local world = setmetatable({ + game = f.game, + npcs = { npc }, + map = { id = "FIX_ROUTE", def = { label = "FIX_ROUTE" } }, + player = { cellX = 4, cellY = 5 }, + engaging = false, + }, { __index = World }) + f.game.world, f.game.overworld = world, world + f.game.stack.states = { world } + local restore = bind(World.enter, "Game", f.game) + f.patch(f.game.data, "trainerHeader", function() + return { event = "TRAINER_WON", battle = "CHALLENGE", won = "WON" } + end) + f.game.data.text.CHALLENGE, f.game.data.text.WON = "A trainer challenge!", "You won!" + local battles, rewards, after = {}, 0, 0 + f.patch(world, "pushBattle", function(_, b) + battles[#battles + 1] = b + end) + f.patch(world, "checkVictoryRewards", function() + rewards = rewards + 1 + end) + f.patch(world, "afterBattle", function() + after = after + 1 + end) + f.patch(world, "runGen2AfterBattle", function() end) + f.world, f.npc, f.battles = world, npc, battles + f.finish = function() + restore() + f.close() + end + f.outcomes = function() + return rewards, after + end + return f +end +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + for _, cancel in ipairs({ false, true }) do + local f = setup(version, mode[1], mode[2]) + local done = 0 + f.world:engageTrainer(f.npc, function() + done = done + 1 + end) + T.eq(f.exports.calls, 0, "pre-battle challenge precedes preparation") + local box = f.game.stack:top() + box.done = true + box:advance() + T.eq(f.exports.calls, 1, "native challenge completion dispatches once") + T.eq(#f.battles, 0, "deferral constructs/pushes no battle") + T.eq(f.world.engaging, true, "native field stays busy while deciding") + T.eq( + f.exports.continue(cancel and { cancel = true } or { playerPartyIndices = { 3, 2 } }), + true, + "native decision accepted" + ) + T.eq(f.exports.continue(), false, "native decision one-shot") + T.eq(f.world.engaging, false, "preparation releases its engagement guard") + T.eq(#f.battles, cancel and 0 or 1, "native construction only on resume") + if cancel then + T.eq(done, 1, "cancel releases encounter caller") + T.eq(f.game.save.flags.TRAINER_WON, nil, "cancel sets no defeat flag") + T.eq(f.world.spotHush.cellX, 4, "cancel suppresses same-tile sight retrigger") + T.eq(f.world.spotHush[f.npc], true, "sight scan suppresses this cancelled trainer") + else + local b = f.battles[1] + T.eq(b.player.mon, f.game.save.party[3], "native lead uses scoped party") + T.same(b.playerPartyIndices, { 3, 2 }, "native ordered scope") + b.onFinish("win") + T.eq(f.game.save.flags.TRAINER_WON, true, "native win sets its flag") + T.eq(f.game.save.defeatedTrainers.TRAINER_1, true, "native win records trainer defeat") + local rewards, after = f.outcomes() + T.eq(rewards, 1, "native win keeps rewards") + T.eq(after, 1, "native win keeps post-battle flow") + T.eq(done, 1, "native win releases encounter caller") + end + T.eq(Preparation.active(f.game), false, "native pending request discarded") + f.finish() + end + local f = setup(version, mode[1], mode[2]) + local runner = Runner.new(f.game, f.world) + runner:run({ { "start_battle", "trainer", "OPP_FIX_YOUNGSTER", 1 }, { "set_flag", "AFTER_BATTLE" } }) + T.eq(f.exports.calls, 1, "actual script dispatches trainer gate") + T.eq(runner:isRunning(), true, "native script parks while preparing") + T.eq(#f.battles, 0, "script defers construction") + T.eq(f.exports.continue({ playerPartyIndices = { 2, 3 } }), true, "script continuation accepted") + T.eq(#f.battles, 1, "script constructs native battle once") + T.eq(f.exports.calls, 1, "resumed script never reintercepts itself") + T.eq(f.battles[1].player.mon, f.game.save.party[2], "script scope selects native lead") + T.eq(f.game.save.flags.AFTER_BATTLE, nil, "script still awaits actual battle outcome") + f.battles[1].onFinish("lose") + T.eq(runner:isRunning(), false, "native finish resumes script once") + T.eq(f.game.save.flags.AFTER_BATTLE, true, "native script continues after outcome") + f.finish() + for _, immediate in ipairs({ false, true }) do + for _, strict in ipairs({ false, true }) do + local cancelled = setup(version, mode[1], mode[2]) + local r = Runner.new(cancelled.game, cancelled.world) + local completed = 0 + if immediate then + cancelled.exports.mode, cancelled.exports.options = "immediate", { cancel = true } + end + r:run({ { "start_battle", "trainer", "OPP_FIX_YOUNGSTER", 1 }, { "set_flag", "UNSAFE_WIN" } }, { + source = strict and { modId = "strict_cancel_probe", strict = true } or nil, + onDone = function() + completed = completed + 1 + end, + afterScript = { + function() + cancelled.game.save.flags.UNSAFE_REWARD = true + end, + }, + }) + if not immediate then + cancelled.exports.continue({ cancel = true }) + end + T.eq(#cancelled.battles, 0, "cancelled script constructs no battle") + T.eq(r:isRunning(), false, "cancelled script releases coroutine") + T.eq(cancelled.game.save.flags.UNSAFE_WIN, nil, "cancel never runs win rows") + T.eq(cancelled.game.save.flags.UNSAFE_REWARD, nil, "cancel never runs deferred rewards") + T.eq(completed, 1, "cancel releases native caller once") + T.eq(r.ctx.lastCheck, false, "cancel has no successful battle result") + T.eq(#cancelled.result.errors, 0, "native cancellation is not a mod failure") + cancelled.finish() + end + end + for _, skip in ipairs({ "link", "firstBattle", "tutorialKind" }) do + local direct = setup(version, mode[1], mode[2]) + local r = Runner.new(direct.game, direct.world) + r:run({ { "start_battle", "trainer", "OPP_FIX_YOUNGSTER", 1, { [skip] = true } } }) + T.eq(direct.exports.calls, 0, skip .. " keeps native non-preparation route") + T.eq(#direct.battles, 1, skip .. " constructs native battle directly") + direct.finish() + end + end +end +-- Retained continuations cannot cross session or actor/script identities. +for _, change in ipairs({ + "save", + "stack", + "data", + "party", + "map", + "npc", + "npc id", + "trainer", + "team", + "session", + "owner", + "provider", +}) do + local f = setup("red", 3, "unified") + local done = 0 + f.world:engageTrainer(f.npc, function() + done = done + 1 + end) + local box = f.game.stack:top() + box.done = true + box:advance() + local resume = f.exports.continue + if change == "save" then + f.game.save = {} + elseif change == "stack" then + f.game.stack = {} + elseif change == "data" then + f.game.data = {} + elseif change == "party" then + f.game.save.party = {} + elseif change == "map" then + f.world.map = {} + elseif change == "npc" then + f.world.npcs = {} + elseif change == "npc id" then + f.npc.id = "OTHER" + elseif change == "team" then + f.npc.def.trainerTeam = {} + elseif change == "trainer" then + f.npc.def.trainerClass = "OTHER" + elseif change == "session" then + f.game.sessionStartedAt = 2 + elseif change == "owner" then + f.result.loader.hooks:removeOwner("portable_trainer_preparation") + elseif change == "provider" then + require("src.mods.Runtime").hooks = require("src.mods.Hooks").new() + end + T.eq(resume(), false, change .. " invalidates native continuation") + T.eq(#f.battles, 0, change .. " cannot construct battle") + T.eq(resume(), false, change .. " stale continuation stays retired") + T.eq(Preparation.active(f.game), false, change .. " releases pending request") + if change == "npc" or change == "npc id" or change == "team" or change == "trainer" then + T.eq(f.world.engaging, false, "retired actor releases its field guard") + end + f.finish() +end +local f = setup("red", 3, "unified") +f.world:engageTrainer(f.npc, function() end) +local box = f.game.stack:top() +box.done = true +box:advance() +f.result.loader.hooks:removeOwner("portable_trainer_preparation") +local peerCalls = 0 +local peer = f.result.loader.hooks:wrap("core.update", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) +end, 10, "other_core_owner") +local value, extra = require("src.mods.Runtime").call("core.update", function() + return "native", 7 +end) +T.eq(value, "native", "watcher preserves native update result") +T.eq(extra, 7, "watcher preserves native update tuple") +T.eq(peerCalls, 1, "watcher preserves another mod's callback") +require("src.mods.Runtime").call("core.update", function() end) +T.eq(peerCalls, 2, "other callback survives watcher removal") +peer() +T.eq(Preparation.active(f.game), false, "native update retires removed owner without resume") +T.eq(f.world.engaging, false, "native update releases removed owner's field guard") +T.eq(f.exports.continue(), false, "removed owner's continuation remains retired") +f.finish() +Version.set(originalVersion) +T.finish("trainer.before_battle native dialogue, script and lifetime") diff --git a/tests/trainer_preparation/cases.lua b/tests/trainer_preparation/cases.lua new file mode 100644 index 000000000..bff3a297c --- /dev/null +++ b/tests/trainer_preparation/cases.lua @@ -0,0 +1,87 @@ +local M = {} +function M.run(new) + local rows, T = {}, nil + for _, mode in ipairs({ + "no-mod", + "observe", + "false", + "truthy", + "nil", + "defer", + "cancel", + "immediate", + "error", + "after error", + "unsubscribe", + "owner cleanup", + "rollback", + "invalid scope", + }) do + local f = new(mode == "no-mod", mode == "rollback") + T = f.T + local e, original = f.exports, f.game.save.party + local members = { original[1], original[2], original[3] } + if e then + e.mode = (mode == "cancel" or mode == "invalid scope") and "defer" or mode + e.options = { playerPartyIndices = { 3, 2 } } + if mode == "unsubscribe" then + e.stop() + e.stop() + end + if mode == "owner cleanup" then + f.result.loader.hooks:removeOwner("portable_trainer_preparation") + end + end + local off + if e and (mode == "truthy" or mode == "nil") then + local api = e.api + off = api.hooks:wrap("trainer.before_battle", function() + if mode == "truthy" then + return 1 + end + end, 100) + end + f.start() + local selected = mode == "defer" or mode == "immediate" + if mode == "defer" or mode == "cancel" or mode == "invalid scope" then + T.eq(f.starts(), 0, mode .. " constructs no battle before decision") + T.eq(e.calls, 1, mode .. " native hook fires once") + T.eq(type(e.context), "table", "data-only trainer context") + T.eq(e.context.partyIndex, 1, "native party index") + T.eq(e.extra, 0, "native hook arity") + if f.backend == "gen2" or f.api < 3 then + T.eq(e.game, f.game, "opaque original Game") + else + T.eq(e.game.sessionStartedAt, 1, "Gen1 API3 Game context boundary") + end + local options = mode == "cancel" and { cancel = true } + or { playerPartyIndices = mode == "invalid scope" and { 1, 1 } or { 3, 2 } } + T.eq(e.continue(options), true, "retained native continuation accepted") + T.eq(e.continue(), false, "retained native continuation one-shot") + end + local cancelled = mode == "cancel" + T.eq(f.starts(), cancelled and 0 or 1, mode .. " starts exactly once or cancels") + T.eq(f.cancels(), cancelled and 1 or 0, mode .. " cancellation callback") + T.eq(f.game.save.party, original, "save party table is never swapped") + T.same(f.game.save.party, members, "save party order and references unchanged") + for i, mon in ipairs(members) do + T.eq(f.game.save.party[i], mon, "save mon identity retained") + end + if not cancelled then + local party = assert(f.party(), "real native battle party") + T.eq(#party, selected and 2 or 3, mode .. " native battle party size") + T.eq(party[1].nickname, selected and "THREE" or "ONE", mode .. " native lead/order") + if selected then + T.eq(party[2].nickname, "TWO", "second selected member") + end + end + rows[#rows + 1] = + { mode = mode, starts = f.starts(), cancels = f.cancels(), size = f.party() and #f.party() or 0 } + if off then + off() + end + f.close() + end + return rows, T +end +return M diff --git a/tests/trainer_preparation/fixture.lua b/tests/trainer_preparation/fixture.lua new file mode 100644 index 000000000..80b3c7987 --- /dev/null +++ b/tests/trainer_preparation/fixture.lua @@ -0,0 +1,320 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod, failing) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local gen = Version.generation() + local Json, files = require("src.link.Json"), {} + for _, name in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_trainer_preparation/" .. name, "rb")) + files["mods/portable_trainer_preparation/" .. name] = file:read("*a") + file:close() + end + local manifest = Json.decode(files["mods/portable_trainer_preparation/manifest.json"]) + manifest.api, manifest.callback_format = api, format + files["mods/portable_trainer_preparation/manifest.json"] = Json.encode(manifest) + if failing then + files["mods/portable_trainer_preparation/main.lua"] = files["mods/portable_trainer_preparation/main.lua"] + .. '\nerror("intentional entry failure")' + end + local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + if backend == "gen1" and gen == 2 then + data = { + version = version, + pokemon = { + growthRates = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0 }, + }, + MAGIKARP = { + id = "MAGIKARP", + name = "MAGIKARP", + index = 129, + dex = 129, + types = { "WATER", "WATER" }, + genderRatio = 127, + catchRate = 255, + baseExp = 20, + growthRate = "GROWTH_MEDIUM_FAST", + baseStats = { + hp = 80, + attack = 10, + defense = 55, + speed = 80, + specialAttack = 15, + specialDefense = 20, + }, + levelMoves = { { level = 1, move = "SPLASH" } }, + evolutions = {}, + }, + }, + moves = { + SPLASH = { + id = "SPLASH", + name = "SPLASH", + type = "NORMAL", + power = 0, + accuracy = 100, + pp = 40, + effect = "EFFECT_SPLASH", + }, + }, + type_chart = { + types = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + WATER = { id = "WATER", index = 21, category = "special" }, + }, + matchups = {}, + }, + items = {}, + } + end + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_trainer_preparation" }, { + data = data, + generation = gen, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + if not failing then + assert(#result.errors == 0, table.concat(result.errors, "\n")) + end + local restores = {} + local function patch(owner, key, value) + local old = owner[key] + restores[#restores + 1] = function() + owner[key] = old + end + owner[key] = value + end + local stack = setmetatable({}, { __index = require("src.core.StateStack") }) + stack:init() + local input = setmetatable({}, { __index = require("src.core.Input") }) + input:init() + local game = { + data = data, + stack = stack, + input = input, + sessionStartedAt = 1, + save = { + version = version, + party = {}, + player = { name = "PROBE" }, + options = {}, + flags = {}, + defeatedTrainers = {}, + engineFlags = {}, + inventory = {}, + money = 1000, + pokedex = { seen = {}, caught = {} }, + }, + } + local context = + { trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1, mapId = "FIX_ROUTE", npcId = "TRAINER_1" } + local battle, starts, cancels, start, closeNative = nil, 0, 0 + if backend == "gen1" and gen == 3 then + require("src.import.gba.versions").select(version) + local P = require("src.core.game3.pokemon") + for key, value in pairs(data.gen3Pokemon) do + patch(P, key, value) + end + patch(P, "_front", {}) + patch(P, "_back", {}) + patch(P, "_national", { toNational = { [4] = 4, [16] = 16 } }) + patch(P, "_battleMoves", data.gen3Moves._rom) + patch(P, "_romMoveNames", data.gen3Pokemon._moveNames) + local function mon(species, nickname) + return { + species = species, + nickname = nickname, + level = 20, + hp = 60, + maxHp = 60, + attack = 30, + defense = 30, + speed = 30, + spAtk = 30, + spDef = 30, + moves = { 33 }, + pp = { 35 }, + maxPp = { 35 }, + ability = 0, + personality = 1, + growthRate = 0, + exp = 8000, + item = 0, + otId = 1, + otSecretId = 2, + } + end + local session = { + version = version, + party = { mon(4, "ONE"), mon(4, "TWO"), mon(4, "THREE") }, + name = "RED", + trainerId = 1, + secretId = 2, + flags = {}, + vars = {}, + options = {}, + } + game.session = session + game.save.party = session.party + local R = require("src.core.game3.runtime") + patch(R, "active", true) + patch(R, "session", session) + patch(R, "_game", game) + local Field = require("src.core.game3.field") + patch(Field, "running", false) + patch(Field, "_session", session) + patch(Field, "_game", game) + patch(Field, "_locks", {}) + patch(require("src.core.game3.map"), "current", nil) + local Space = require("src.core.game3.scripting.space") + patch(Space, "vm", nil) + patch(Space, "bundle", { + text = setmetatable({}, { + __index = function(t, key) + local ir = { { t = "text", s = tostring(key), v = tostring(key), text = tostring(key) } } + rawset(t, key, ir) + return ir + end, + }), + maps = {}, + events = {}, + }) + local B = require("src.core.game3.battle") + local Bridge = require("src.core.game3.battle_bridge") + B.reset() + Bridge.reset() + local foe = mon(16, "FOE") + foe.party = { mon(16, "FOE") } + foe.trainerClass, foe.trainerId = 1, 1 + patch(require("src.core.game3.scripting.trainers"), "_pack", { + classNames = { [0] = "TRAINER", [1] = "PROBE" }, + trainers = { + [0] = { + class = 0, + name = "TRAINER", + pic = 0, + party = { { species = 16, level = 5, moves = { 33 } } }, + }, + [1] = { class = 1, name = "PROBE", pic = 0, party = { { species = 16, level = 5, moves = { 33 } } } }, + }, + }) + local cache = require("src.core.game3.dataset").cache() + local read = cache.read + patch(cache, "read", function(self, path, ...) + if path:match("/battle_moves%.lua$") then + return "return {moves={[33]={effect=0,power=35,type=0,accuracy=100,pp=35,target=0,priority=0}}}" + end + return read(self, path, ...) + end) + result.loader.events:on("battle.started", function() + starts = starts + 1 + battle = B.getState() + end) + start = function() + return Bridge.start(nil, game, foe, { + headless = true, + fade = false, + autoFight = false, + cache = cache, + song = 1, + mapBehavior = 0, + noWhiteout = true, + cancel = function() + cancels = cancels + 1 + end, + failed = function(reason) + error("native trainer start: " .. tostring(reason)) + end, + }) + end + closeNative = function() + B.reset() + Bridge.reset() + R.drainDeferred() + end + elseif backend == "gen1" and gen == 2 then + local Mon = require("src.battle.gen2.Mon") + for i, name in ipairs({ "ONE", "TWO", "THREE" }) do + local m = Mon.new(data, "MAGIKARP", 20) + m.nickname = name + game.save.party[i] = m + end + local World = require("src.world.gen2.World") + local world = World.new(game) + game.world = world + local record = { + class = 1, + member = 1, + event = 700, + name = "PROBE", + className = "YOUNGSTER", + roster = { { species = "MAGIKARP", level = 5 } }, + } + world.vm = require("src.script.gen2.Vm").new({}, {}, world.events) + result.loader.events:on("battle.started", function(payload) + local native = payload.battle.battle or payload.battle + if native ~= battle then + battle = native + starts = starts + 1 + end + end) + start = function() + return world:startScriptedBattle(record, nil, function() + cancels = cancels + 1 + end) + end + else + local Save = require("src.core.SaveData") + game.save = Save.newGame() + local P = require("src.pokemon.Pokemon") + for i, name in ipairs({ "ONE", "TWO", "THREE" }) do + game.save.party[i] = P.new(data, "FIXMON_A", 20) + game.save.party[i].nickname = name + end + local World = require("src.world.OverworldController") + start = function() + return World.prepareTrainerBattle(game, context, function(options) + battle = require("src.battle.BattleState").newTrainer(game, "OPP_FIX_YOUNGSTER", 1, options) + starts = starts + 1 + end, function() + cancels = cancels + 1 + end) + end + end + result.loader.game = game + return { + T = T, + api = api, + backend = backend, + generation = gen, + result = result, + game = game, + exports = result.loader.exports.portable_trainer_preparation, + patch = patch, + start = start, + starts = function() + return starts + end, + cancels = function() + return cancels + end, + party = function() + local b = battle + return b and (b.playerParty or b.party or (b.player and b.player.party) or game.save.party) or nil + end, + battle = function() + return battle + end, + close = function() + if closeNative then + closeNative() + end + for i = #restores, 1, -1 do + restores[i]() + end + result.release() + end, + } +end +return M diff --git a/tests/trainer_preparation/worker.lua b/tests/trainer_preparation/worker.lua new file mode 100644 index 000000000..dbfe78eb0 --- /dev/null +++ b/tests/trainer_preparation/worker.lua @@ -0,0 +1,8 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local Fixture = dofile(root .. "/tests/trainer_preparation/fixture.lua") +local rows, T = dofile(root .. "/tests/trainer_preparation/cases.lua").run(function(noMod, failing) + return Fixture.new(root, backend, version, api, format, noMod, failing) +end) +print("TRAINER_PREPARATION_CONTRACT " .. require("src.link.Json").encode(rows)) +T.finish("portable trainer.before_battle contract") diff --git a/tools/trainer-preparation-contract-check/check.py b/tools/trainer-preparation-contract-check/check.py new file mode 100644 index 000000000..059bd545f --- /dev/null +++ b/tools/trainer-preparation-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged trainer preparation mod through both engines' native trainer admission paths.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/trainer_preparation/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("TRAINER_PREPARATION_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("TRAINER_PREPARATION_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"trainer preparation mismatch: {version}" + print(f"PASS {version}: matching native trainer admission paths across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_trainer_preparation/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 6e61c01ebe6ccaca1c2b9588d6e9fe5cedbe24cc Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:01:19 +0200 Subject: [PATCH 030/138] Add portable GB and GBA Pokemon level visibility hooks --- .github/workflows/level-visible-contract.yml | 49 +++ docs/api3-portability.md | 2 +- docs/portable-level-visibility.md | 73 +++++ examples/portable_level_visible/main.lua | 16 + examples/portable_level_visible/manifest.json | 10 + src/battle/BattleState.lua | 5 +- src/battle/Gen3Battle.lua | 10 +- src/battle/WideBattle.lua | 5 +- src/mods/Capabilities.lua | 1 + src/mods/UnifiedCallbacks.lua | 3 +- src/ui/Gen3PartyMenu.lua | 5 +- src/ui/Gen3SummaryMenu.lua | 17 +- src/ui/LevelDisplay.lua | 16 + src/ui/PartyMenu.lua | 3 +- src/ui/SummaryMenu.lua | 21 +- tests/engine/level_visible.lua | 11 + tests/engine/level_visible_contract.lua | 39 +++ tests/engine/level_visible_renderers.lua | 289 ++++++++++++++++++ tests/level_visible/cases.lua | 104 +++++++ tests/level_visible/fixture.lua | 57 ++++ tests/level_visible/native.lua | 191 ++++++++++++ tests/level_visible/worker.lua | 15 + tools/level-visible-contract-check/check.py | 60 ++++ 23 files changed, 982 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/level-visible-contract.yml create mode 100644 docs/portable-level-visibility.md create mode 100644 examples/portable_level_visible/main.lua create mode 100644 examples/portable_level_visible/manifest.json create mode 100644 src/ui/LevelDisplay.lua create mode 100644 tests/engine/level_visible.lua create mode 100644 tests/engine/level_visible_contract.lua create mode 100644 tests/engine/level_visible_renderers.lua create mode 100644 tests/level_visible/cases.lua create mode 100644 tests/level_visible/fixture.lua create mode 100644 tests/level_visible/native.lua create mode 100644 tests/level_visible/worker.lua create mode 100644 tools/level-visible-contract-check/check.py diff --git a/.github/workflows/level-visible-contract.yml b/.github/workflows/level-visible-contract.yml new file mode 100644 index 000000000..0c9d7e72a --- /dev/null +++ b/.github/workflows/level-visible-contract.yml @@ -0,0 +1,49 @@ +name: portable pokemon.level_visible contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: level-visible-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native level rendering and SDK contracts + run: | + luajit tests/engine/level_visible.lua + luajit tests/engine/level_visible_contract.lua + luajit tests/engine/level_visible_renderers.lua + - name: Validate pinned native Gen1 level contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/unified_gen2_hooks.lua + luajit tests/modkit/cases/unified_gen3_hooks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/level-visible-contract + python3 tools/level-visible-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/level-visible-contract/api1-native.json + python3 tools/level-visible-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/level-visible-contract/api2-native.json + python3 tools/level-visible-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/level-visible-contract/api2-unified.json + python3 tools/level-visible-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/level-visible-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: level-visible-contract + path: /tmp/level-visible-contract + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index d5c5f3f6b..8cf430692 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -95,7 +95,7 @@ emits nothing. Invalid save stores are refused without overwriting them. Gen1 API-3 callback names from the pinned reference are recognized even when this engine has no call site for them. Registration is allowed; availability still requires a native call site in the selected version. For example, -`pokemon.level_visible` can be subscribed to but reports unavailable here. Unknown +`battle.menu_auxiliary` can be subscribed to but reports unavailable here. Unknown names remain errors. An accepted subscription is not proof that a feature runs. Source-specific engine handles in lifecycle/UI payloads remain native. diff --git a/docs/portable-level-visibility.md b/docs/portable-level-visibility.md new file mode 100644 index 000000000..1dce40ad1 --- /dev/null +++ b/docs/portable-level-visibility.md @@ -0,0 +1,73 @@ +# Portable Pokémon level visibility + +`pokemon.level_visible` lets a mod hide the native level label and digits +without replacing a battle, party or summary screen. It uses Gen1Recomp's +existing GB/GBA contract: + +```lua +local mod = ... +if mod:supports("hooks", "pokemon.level_visible") then + mod.hooks:wrap("pokemon.level_visible", function(next, mon, context) + if context.where == "battle.enemy" then return false end + return next(mon, context) + end) +end +``` + +The default is `true`; only a first return value of literal `false` hides the +label. `nil`, `0` and other values leave it visible. A caller only asks where +the native screen would otherwise print a level. The decision can be queried +several times per frame and should be deterministic and free of side effects. +Unsubscribing or unloading the mod restores the native display. + +`context.where` is one of `battle.enemy`, `battle.player`, `party`, or `summary`. +The native callback receives the actual Pokémon and `{where, game}`. Gen2's unified +API 2 and the common API 3 use their existing Pokémon/context record adapters, including +normal ID conversion, `next` forwarding, validation and failure recovery. API 3 +is recommended for portable consumers. The Game value is an engine handle; +Gen1's unified context can expose a forwarding view while Gen2 retains the +native handle. Consumers should not inspect engine-private Game fields. + +The native call sites cover Gen2Recomped's Gen1/2 games (including Prism and +Polished Crystal) and FireRed/Emerald. Classic and wide GB healthboxes, GBA +single/double healthboxes, party rows and summary variants use the same hook. +GB summaries also omit the printed next-level number and its `TO` word/arrow. +Status/faint labels, egg eligibility, names, HP, EXP values/bars, and historical +met-level text retain their native behavior. GBA move-detail pages retain their +existing level eligibility. The level, stats and progression are not changed by +hiding their display. + +The DS renderers are outside this change. `supports` returns `false` for +Gen4/5; accepting the API-3 hook name alone is not a support claim. + +## Validation + +The unchanged example in `examples/portable_level_visible` is loaded by both +engines' actual SDKs. The checker pins a clean Gen1 reference at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36` and compares Red, Yellow, Gold, +FireRed and Emerald in native API 1/2 and API 3. Gen2's additional unified API 2 +mode is compared against Gen1's native API 2: Gen1 does not implement the +`callback_format` manifest extension. API 3 is the common unified contract. +Workers compare +native battle level omission/restoration and all four predicate contexts. The +Gen1 GBA worker uses Healthbox's intact private level producer with a native +session context; GB and Gen2 GBA workers invoke native HUD methods. Only +font/tile leaves are recorded. + +```sh +luajit tests/engine/level_visible.lua +luajit tests/engine/level_visible_contract.lua +luajit tests/engine/level_visible_renderers.lua +python3 tools/level-visible-contract-check/check.py --gen1 /path/to/gen1 \ + --api 3 --format unified --report /tmp/level-visible.json +``` + +Gen2 renderer tests exercise actual party/summary draw methods and native +healthbox variants across every supported version and SDK mode. They check +labels and digits together, status/egg eligibility, per-side hiding, level 100, +next-level arrows, native context, restoration, and a distinct doubles partner. +Leaf-spy tests disable JIT compilation only for the HUD function whose captured +leaf is replaced through debug upvalues. Predicate and SDK behavior remain +covered separately. CI also runs the pinned Gen1 Gen2/Gen3 native hook suites +unchanged. These are ROM-free draw-command and behavior tests, not GPU pixel +goldens or full cartridge playthroughs. diff --git a/examples/portable_level_visible/main.lua b/examples/portable_level_visible/main.lua new file mode 100644 index 000000000..f026590af --- /dev/null +++ b/examples/portable_level_visible/main.lua @@ -0,0 +1,16 @@ +-- Hide levels per surface while leaving the native screen and input active. +-- Rendering can query a predicate repeatedly; keep the decision deterministic. +local mod = ... +local hidden, seen = {}, { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.hide(where, value) + hidden[where] = value == true +end +mod.exports.stop = mod.hooks:wrap("pokemon.level_visible", function(next, mon, context) + seen.calls = seen.calls + 1 + seen.mon, seen.context = mon, context + if hidden[context.where] then + return false + end + return next(mon, context) +end) diff --git a/examples/portable_level_visible/manifest.json b/examples/portable_level_visible/manifest.json new file mode 100644 index 000000000..60aef31ee --- /dev/null +++ b/examples/portable_level_visible/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_level_visible", + "name": "Portable Level Visibility", + "version": "1.0.0", + "api": 3, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3], + "affects_link": false +} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 15f564b8c..a3031115d 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -19,6 +19,7 @@ local HoldItems = require("src.battle.HoldItems") local Experience = require("src.battle.Experience") local HeldItems = require("src.battle.HeldItems") local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") local Logger = require("src.core.Logger") local MoveEffects = require("src.battle.MoveEffects") local Party = require("src.pokemon.Party") @@ -12273,7 +12274,7 @@ function BattleState:drawHUDs(slide) Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0) if self.enemy.shownStatus then Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8) - else + elseif LevelDisplay.visible(self.enemy.mon, "battle.enemy", self.game) then hudTile(0x6E, 32, 8) -- Font.draw(tostring(self.enemy.mon.level), 40, 8) end @@ -12355,7 +12356,7 @@ function BattleState:drawHUDs(slide) Font.draw(self.player.name, nameX(10, self.player.name), 56) if self.player.shownStatus then Font.draw(self:statusLabel({ status = self.player.shownStatus }), 120, 64) - else + elseif LevelDisplay.visible(self.player.mon, "battle.player", self.game) then hudTile(0x6E, 112, 64) -- Font.draw(tostring(self.player.mon.level), 120, 64) end diff --git a/src/battle/Gen3Battle.lua b/src/battle/Gen3Battle.lua index d7d19462b..155df7754 100644 --- a/src/battle/Gen3Battle.lua +++ b/src/battle/Gen3Battle.lua @@ -39,6 +39,7 @@ local Assets = require("src.render.Assets") local Logger = require("src.core.Logger") local Theme = require("src.ui.Theme") local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") local HudTiles = require("src.render.HudTiles") local PaletteFX = require("src.render.PaletteFX") local Runtime = require("src.mods.Runtime") @@ -891,7 +892,11 @@ end local HUD_TEXT_STYLE = { text = { 0.16, 0.16, 0.16, 1 } } Gen3Battle.HUD_TEXT_STYLE = HUD_TEXT_STYLE -local function levelAt(battle, battler, x, y, text) +local function levelAt(battle, battler, x, y, text, isStatus) + if not (isStatus or (not text and battler.shownStatus)) + and not LevelDisplay.visible(battler.mon, + (battler.isPlayer == true or battler == battle.player) and "battle.player" or "battle.enemy", + battle.game) then return end if text then Font.draw(text, x, y) elseif battler.shownStatus then @@ -1127,7 +1132,8 @@ local function drawStatusPanel(battle, battler, x, y, player) Font.draw(symbol, ix + 1 + Font.width(shown), nameY) Font.popStyle() end - levelAt(battle, battler, ix + inner.w - levelW - 1, nameY, level) + levelAt(battle, battler, ix + inner.w - levelW - 1, nameY, level, + not badge and battler.shownStatus) -- the foe's exact HP is never shown, as on every cartridge if player then diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index e280d8f1f..59bd18163 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -12,6 +12,7 @@ -- prompt/command window, and a 2x2 move menu with an attached PP/type panel. local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") local HudTiles = require("src.render.HudTiles") local PaletteFX = require("src.render.PaletteFX") local Runtime = require("src.mods.Runtime") @@ -88,7 +89,9 @@ end local function levelAt(battle, battler, x, y) if battler.shownStatus then Font.draw(battle:statusLabel({ status = battler.shownStatus }), x, y) - else + elseif LevelDisplay.visible(battler.mon, + (battler.isPlayer == true or battler == battle.player) and "battle.player" or "battle.enemy", + battle.game) then HudTiles.tile(0x6E, x, y) -- '' Font.draw(tostring(battler.mon.level), x + 8, y) end diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index c5b53d710..76d17a339 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -32,6 +32,7 @@ add("hooks", "12345", [[ add("hooks", "123", "battle.overlay ui.naming.grid ui.party.submenu ui.start_menu.items") add("hooks", "12", "ui.pc.items") add("hooks", "12345", "battle.status_hud_visible") +add("hooks", "123", "pokemon.level_visible") add("hooks", "12", "intro.oak_speech.build") add("events", "12345", [[ diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index f5af41721..b3107e963 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -20,7 +20,7 @@ C.hooks = names([[ battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed - music.select music.volume player.sprite pokemon.icon pokemon.sprite render.compose render.hud + music.select music.volume player.sprite pokemon.icon pokemon.level_visible pokemon.sprite render.compose render.hud render.letterbox render.zones save.new_game save.write screen.render_visible script.command trainer.before_battle trainer.party transition.style ui.list_menu ui.naming.grid ui.options.rows ui.party.submenu ui.pc.items ui.start_menu.items ui.title_menu.items warp.destination world.talk world.tod zoom.range @@ -39,6 +39,7 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["pokemon.level_visible"] = { "mon", "context" }, ["trainer.before_battle"] = { false, "context", false }, ["battle.accuracy"] = { "context" }, ["battle.damage"] = { "context" }, diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index b6e997d82..f9c62320d 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -50,6 +50,7 @@ local Assets = require("src.render.Assets") local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") local Logger = require("src.core.Logger") local Screens = require("src.ui.Screens") local Strings = require("src.core.Strings") @@ -1464,7 +1465,9 @@ function Gen3PartyMenu:drawMember(mon, panel, selected, inset) Font.draw(Font.fit(name, rects.name.width + 16), nx, ny) local lx, ly = at("level") - Font.draw(Strings("Lv%d", mon.level or 1), lx, ly) + if LevelDisplay.visible(mon, "party", self.game) then + Font.draw(Strings("Lv%d", mon.level or 1), lx, ly) + end -- Gen 3 derives gender from species ratio + personality when the save does -- not carry an explicit field. Use the engine's common derivation instead of diff --git a/src/ui/Gen3SummaryMenu.lua b/src/ui/Gen3SummaryMenu.lua index 9d54ee41b..8e4678f3f 100644 --- a/src/ui/Gen3SummaryMenu.lua +++ b/src/ui/Gen3SummaryMenu.lua @@ -33,6 +33,7 @@ -- screenshot. local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") local Logger = require("src.core.Logger") local Stats = require("src.pokemon.Stats") local Strings = require("src.core.Strings") @@ -962,8 +963,10 @@ function Gen3SummaryMenu:drawHeader() if bandWin then local species = (def and def.name) or tostring(mon.species) putIn(bandWin, species, 0, 1, PLAIN) - putIn(bandWin, ("Lv%d"):format(mon.level or 1), PANEL_LEVEL_X, 17, - PLAIN) + if LevelDisplay.visible(mon, "summary", self.game) then + putIn(bandWin, ("Lv%d"):format(mon.level or 1), PANEL_LEVEL_X, 17, + PLAIN) + end local mark = self:genderMark() if mark then putIn(bandWin, mark, PANEL_GENDER_X, 17, @@ -971,7 +974,9 @@ function Gen3SummaryMenu:drawHeader() end else local _, levelY = self:panelRows() - put(("Lv%d"):format(mon.level or 1), PANEL_TEXT_X + 24, levelY, PLAIN) + if LevelDisplay.visible(mon, "summary", self.game) then + put(("Lv%d"):format(mon.level or 1), PANEL_TEXT_X + 24, levelY, PLAIN) + end end end put(title, COL_RIGHT - Font.width(title), 4, PLAIN) @@ -989,7 +994,9 @@ function Gen3SummaryMenu:drawHeader() return end put(name, 40, 10, PLAIN) - put(("Lv%d"):format(mon.level or 1), 40, 24, PLAIN) + if LevelDisplay.visible(mon, "summary", self.game) then + put(("Lv%d"):format(mon.level or 1), 40, 24, PLAIN) + end -- the page name sits on the right of the header, where the cartridge's -- page indicator is put(title, GBA_W - 8 - Font.width(title), 10, PLAIN) @@ -1780,7 +1787,7 @@ function Gen3SummaryMenu:drawFireRed(r) frlgText(self.pages[self.page], 4, 1, header) if not mon then return end local t = r.text or {} - if not detail then + if not detail and LevelDisplay.visible(mon, "summary", self.game) then frlgText("Lv" .. tostring(mon.level or 1), 4, 18, header, true) end frlgText(mon.nickname or def.name or tostring(mon.species), 40, 18, header) diff --git a/src/ui/LevelDisplay.lua b/src/ui/LevelDisplay.lua new file mode 100644 index 000000000..0092e81c8 --- /dev/null +++ b/src/ui/LevelDisplay.lua @@ -0,0 +1,16 @@ +-- Shared level-display predicate for native GB/GBA screens. Only literal +-- false hides the complete level label; callers retain status/egg eligibility. +local Runtime = require("src.mods.Runtime") +local LevelDisplay = {} +local function visibleByDefault() + return true +end + +-- where: "battle.enemy" | "battle.player" | "party" | "summary" +function LevelDisplay.visible(mon, where, game) + if not Runtime.wantsHook("pokemon.level_visible") then + return true + end + return Runtime.call("pokemon.level_visible", visibleByDefault, mon, { where = where, game = game }) ~= false +end +return LevelDisplay diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 7b907a617..cc22df2fa 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -12,6 +12,7 @@ local Assets = require("src.render.Assets") local Badges = require("src.inventory.Badges") local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") @@ -981,7 +982,7 @@ function PartyMenu:draw() -- level at column 13 ( tile + digits, PrintLevel) AND the -- status/FNT text at column 17 (PrintStatusCondition), like the -- original rows -- statused mons keep their level display - if isEgg then -- no level row + if isEgg or not LevelDisplay.visible(mon, "party", self.game) then -- no level row elseif mon.level < 100 then HudTiles.tile(0x6E, 104, y) -- Font.draw(tostring(mon.level), 112, y) diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index 090401b37..69941f5f6 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -13,6 +13,7 @@ -- go, and one of four lines about how close it is to hatching. local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") -- status_screen.asm PrintMonType prints the type's DISPLAY name from the -- TypeNames table, not the constant: species types are stored as pokered -- constants (RomExtractor:typesById) and PSYCHIC's is "PSYCHIC_TYPE" (so it @@ -359,7 +360,9 @@ function SummaryMenu:drawGen2Header() HudTiles.statusTile(0x74, 64, 0) -- № Font.drawCode(0xF2, 72, 0) -- Font.draw(("%03d"):format(def.dex or 0), 80, 0) - printLevel(14, 0, mon.level) + if LevelDisplay.visible(mon, "summary", self.game) then + printLevel(14, 0, mon.level) + end local gender = require("src.pokemon.DayCare").gender(data, mon) if gender == "male" or gender == "female" then Font.draw(gender == "male" and "♂" or "♀", 144, 0) @@ -445,8 +448,10 @@ function SummaryMenu:drawGen2PinkPage() local nextExp = (mon.level or 1) < 100 and (Growth.expForLevel(def.growthRate, mon.level + 1) - (mon.exp or 0)) or 0 rightAlign(("%d"):format(math.max(0, nextExp)), 160, 104) - Font.draw(Strings("TO"), 112, 112) - printLevel(17, 14, math.min(100, (mon.level or 1) + 1)) + if LevelDisplay.visible(mon, "summary", self.game) then + Font.draw(Strings("TO"), 112, 112) + printLevel(17, 14, math.min(100, (mon.level or 1) + 1)) + end -- FillInExpBar between the $40/$41 end caps, its RIGHT edge pinned at -- column 18 and the bar growing leftward from there: eight tiles from @@ -583,7 +588,9 @@ function SummaryMenu:draw() -- bracket around the name/HP block, and PrintLevel at (14,2). The -- level belongs to page 1 ONLY: StatusScreen2 opens with ClearScreenArea -- over (9,2) 5x10 (status_screen.asm:303-305), which wipes it. #280 - printLevel(14, 2, mon.level) + if LevelDisplay.visible(mon, "summary", self.game) then + printLevel(14, 2, mon.level) + end drawLineBox(19, 1, 6, 10) -- same as the pink page above: the HP palette covers the whole screen, so -- the fill stays grey and the zone pass colours it @@ -641,8 +648,10 @@ function SummaryMenu:draw() local nextExp = mon.level < 100 and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0 Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48) - HudTiles.statusTile(0x70, 112, 48) -- '' at (14,6), was missing (#280) - printLevel(16, 6, math.min(100, mon.level + 1)) + if LevelDisplay.visible(mon, "summary", self.game) then + HudTiles.statusTile(0x70, 112, 48) -- '' at (14,6), was missing (#280) + printLevel(16, 6, math.min(100, mon.level + 1)) + end -- Gen2's stats screen carries the same exp bar the battle HUD does -- (stats_screen.asm calls FillInExpBar too). It lands on DrawLineBox's -- bottom run, which is exactly the eight tiles PlaceExpBar writes. diff --git a/tests/engine/level_visible.lua b/tests/engine/level_visible.lua new file mode 100644 index 000000000..c575768b0 --- /dev/null +++ b/tests/engine/level_visible.lua @@ -0,0 +1,11 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local Fixture = dofile("tests/level_visible/fixture.lua") +local f = Fixture.new(".", "gen2", "red", 3, "unified") +-- Public registration is executable proof for the hook coverage audit. +local off = f.exports.api.hooks:wrap("pokemon.level_visible", function(next) + return next() +end) +dofile("tests/level_visible/cases.lua").run(f) +off() +f.close() +f.T.finish("portable pokemon.level_visible") diff --git a/tests/engine/level_visible_contract.lua b/tests/engine/level_visible_contract.lua new file mode 100644 index 000000000..dc6dd0a54 --- /dev/null +++ b/tests/engine/level_visible_contract.lua @@ -0,0 +1,39 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/level_visible/fixture.lua") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Display = require("src.ui.LevelDisplay") +local Capabilities = require("src.mods.Capabilities") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + local supported = Version.generation(version) <= 3 + T.eq( + Capabilities.supports("hooks", "pokemon.level_visible", version), + supported, + version .. " native capability" + ) + if supported then + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + dofile("tests/level_visible/native.lua").run(f) + dofile("tests/level_visible/cases.lua").run(f) + f.close() + end + local f = Fixture.new(".", "gen2", version, 3, "unified", true) + local calls = 0 + f.patch(Runtime, "call", function() + calls = calls + 1 + end) + local opaque = setmetatable({}, { + __index = function() + error("no-mod path touched mon") + end, + }) + T.eq(Display.visible(opaque, "party", opaque), true, "unsubscribed default") + T.eq(calls, 0, "no-mod hot path skips dispatch and record adaptation") + f.close() + end +end +Version.set(originalVersion) +T.finish("pokemon.level_visible capability and SDK modes") diff --git a/tests/engine/level_visible_renderers.lua b/tests/engine/level_visible_renderers.lua new file mode 100644 index 000000000..e2c010418 --- /dev/null +++ b/tests/engine/level_visible_renderers.lua @@ -0,0 +1,289 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/level_visible/fixture.lua") +local Version = require("src.core.GameVersion") +local Font, Hud = require("src.render.Font"), require("src.render.HudTiles") +local Battle = require("src.battle.BattleState") +local G3 = require("src.battle.Gen3Battle") +-- Upvalue leaf spies require interpreted calls: LuaJIT can retain old leaf closures. +if jit then + jit.off(Battle.drawHUDs, true) +end +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + if Version.generation(version) <= 3 then + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local game, mon, e = f.game, f.mon, f.exports + game.save = require("src.core.SaveData").newGame() + game.save.party = { mon } + mon.nickname, mon.hp, mon.stats.hp = "SCOUT", 60, 60 + f.result.loader.game = game + f.patch(love.graphics, "polygon", function() end) + f.patch(love.graphics, "getScissor", function() end) + Font.load(game.data) + local rows = {} + local function text(s, x, y) + rows[#rows + 1] = { text = tostring(s), x = x, y = y } + return Font.width(s) + end + f.patch(Font, "draw", text) + f.patch(Font, "drawCode", function(code, x, y) + rows[#rows + 1] = { code = code, x = x, y = y } + end) + for _, key in ipairs({ "tile", "statusTile" }) do + f.patch(Hud, key, function(code, x, y) + rows[#rows + 1] = { code = code, x = x, y = y } + end) + end + local function capture(draw) + rows = {} + draw() + return rows + end + local function has(list, needle) + for _, row in ipairs(list) do + if row.text and row.text:find(needle, 1, true) then + return true + end + end + return false + end + local function levelCount(list) + local count = 0 + for _, row in ipairs(list) do + if + row.text + and ( + row.text == "42" + or row.text == "43" + or row.text == "100" + or row.text == "Lv42" + or row.text == "Lv100" + ) + then + count = count + 1 + end + end + return count + end + local b = Battle.newWild(game, "FIXMON_C", 42) + b.player.isPlayer, b.enemy.isPlayer = true, false + b:syncSides() + b.phase, b.frame, b.shown, b.fx = "contract", 0, {}, {} + b.showPlayerBack, b.showEnemyTrainer, b.enemySendingOut, b.introBalls = false, false, false, false + b.growInScale = function() + return nil + end + b.colorMode = function() + return false + end + b.demoHidesPlayer = function() + return false + end + -- The classic compositor captures its tile leaf during module load. + for i = 1, 100 do + local name, value = debug.getupvalue(Battle.drawHUDs, i) + if not name then + break + end + if name == "hudTile" then + debug.setupvalue(Battle.drawHUDs, i, Hud.tile) + local close = f.close + f.close = function() + debug.setupvalue(Battle.drawHUDs, i, value) + close() + end + break + end + end + local nativeSeen = {} + local off = f.result.loader.hooks:wrap("pokemon.level_visible", function(next, m, ctx) + T.eq(ctx.game, game, "native producer supplies live Game") + T.check(m == mon or m == b.enemy.mon or m == b.player.mon, "native producer supplies actual mon") + if ctx.where == "battle.player" or ctx.where == "battle.enemy" then + T.eq( + ctx.where, + m == b.enemy.mon and "battle.enemy" or "battle.player", + "native side, including partners" + ) + end + nativeSeen[ctx.where] = (nativeSeen[ctx.where] or 0) + 1 + return next(m, ctx) + end, 1000) + local function cycle(label, surfaces, draw, expected) + for _, where in ipairs(surfaces) do + e.hide(where, false) + end + local stock = capture(draw) + T.eq(levelCount(stock), expected, version .. " " .. label .. " native level count") + nativeSeen = {} + for _, where in ipairs(surfaces) do + e.hide(where, true) + end + local hidden = capture(draw) + T.eq(levelCount(hidden), 0, label .. " omits level digits") + for _, row in ipairs(hidden) do + T.check(row.code ~= 0x6E and row.code ~= 0x70, label .. " no orphan level/TO glyph") + T.check(row.text ~= "TO" and not (row.text or ""):find("Lv42", 1, true), "no orphan label") + end + for _, where in ipairs(surfaces) do + T.check((nativeSeen[where] or 0) > 0, label .. " producer identifies " .. where) + e.hide(where, false) + end + T.check(has(hidden, "SCOUT") or has(hidden, b.player.name), label .. " nickname remains") + local restored = capture(draw) + T.same(restored, stock, label .. " restores exact printed rows") + end + if Version.generation() <= 2 then + cycle("classic battle", { "battle.player", "battle.enemy" }, function() + b:drawHUDs(0) + end, 2) + cycle("wide battle", { "battle.player", "battle.enemy" }, function() + local wide = require("src.battle.WideBattle") + wide.gen2Panel(b, b.player, 9 * 8, 7 * 8, true, false) + wide.gen2Panel(b, b.enemy, 0, 0, false, false) + end, 2) + -- One surface can hide without hiding the other side. + e.hide("battle.enemy", true) + T.eq( + levelCount(capture(function() + b:drawHUDs(0) + end)), + 1, + "per-side native battle omission" + ) + e.hide("battle.player", true) + b.player.shownStatus, b.enemy.shownStatus = "PSN", "PSN" + T.check( + has( + capture(function() + b:drawHUDs(0) + end), + "PSN" + ), + "hidden levels retain battle status" + ) + b.player.shownStatus, b.enemy.shownStatus = nil, nil + local party = require("src.ui.PartyMenu").new(game) + cycle("party", { "party" }, function() + party:draw() + end, 1) + mon.level = 100 + cycle("party level 100", { "party" }, function() + party:draw() + end, 1) + mon.level = 42 + local summary = require("src.ui.SummaryMenu").new(game, mon) + for page = 1, Version.isGen2() and 3 or 2 do + summary.page = page + local count = Version.isGen2() and (page == 1 and 2 or 1) or 1 + cycle("summary page " .. page, { "summary" }, function() + summary:draw() + end, count) + end + mon.isEgg = true + local calls = e.seen.calls + capture(function() + party:draw() + end) + T.eq(e.seen.calls, calls, "egg does not query or invent a level") + mon.isEgg = nil + else + cycle("GBA no-art battle", { "battle.player", "battle.enemy" }, function() + G3.drawHUDs(b, 0) + end, 2) + e.hide("battle.player", true) + e.hide("battle.enemy", true) + b.player.shownStatus, b.enemy.shownStatus = "PSN", "PSN" + T.check( + has( + capture(function() + G3.drawHUDs(b, 0) + end), + "PSN" + ), + "GBA fallback preserves status text" + ) + b.player.shownStatus, b.enemy.shownStatus = nil, nil + local image = love.graphics.newCanvas(128, 64) + f.patch(require("src.render.Assets"), "image", function() + return image + end) + local record = { images = { player = "panel", opponent = "panel" } } + game.data.constants.gen3BattleHud = record + cycle("GBA panel battle", { "battle.player", "battle.enemy" }, function() + G3.drawHUDs(b, 0) + end, 2) + local w = { + name = { x = 8, y = 3, w = 56 }, + level = { x = 64, y = 3 }, + bar = { x = 40, y = 16 }, + label = { x = 24, y = 16 }, + } + record.windows = { player = w, opponent = w } + cycle("GBA native windows", { "battle.player", "battle.enemy" }, function() + G3.drawHUDs(b, 0) + end, 2) + record.windows = nil + game.data.constants.gen3BattlerCoords = { + healthbox = { + playerLeft = { x = 159, y = 76 }, + playerRight = { x = 171, y = 101 }, + opponentLeft = { x = 44, y = 19 }, + opponentRight = { x = 32, y = 44 }, + }, + } + b.isDouble = function() + return true + end + local partner = { name = "SCOUT", mon = b.player.mon, isPlayer = true, shownHP = mon.hp } + b.battlerAt = function(self, pos) + return pos == 2 and partner or (pos % 2 == 0 and self.player or self.enemy) + end + cycle("GBA doubles", { "battle.player", "battle.enemy" }, function() + G3.drawHUDs(b, 0) + end, 4) + local party = require("src.ui.Gen3PartyMenu").new(game) + local panel = { x = 0, y = 0, wide = true, width = 100, index = 1 } + cycle("GBA party slot", { "party" }, function() + party:drawMember(mon, panel, false) + end, 1) + local summary = require("src.ui.Gen3SummaryMenu").new(game, { mon = mon }) + cycle("GBA summary fallback", { "summary" }, function() + summary:drawHeader() + end, 1) + summary.hasArt = true + cycle("GBA summary art", { "summary" }, function() + summary:drawHeader() + end, 1) + local labels = {} + for i = 1, 20 do + labels[i] = { x = 0, y = 0 } + end + game.data.constants.gen3SummaryWindows = { labels = labels } + cycle("GBA summary windows", { "summary" }, function() + summary:drawHeader() + end, 1) + if version == "firered" then + summary.page = 3 + cycle("FireRed summary", { "summary" }, function() + summary:drawFireRed({}) + end, 1) + summary.moveSelect = true + e.hide("summary", true) + local calls = e.seen.calls + local detailRows = capture(function() + summary:drawFireRed({}) + end) + T.eq(has(detailRows, "Lv42"), false, "move detail native omission") + T.eq(e.seen.calls, calls, "move detail cannot invent a level") + end + end + off() + f.close() + end + end +end +Version.set(originalVersion) +T.finish("pokemon.level_visible native renderers") diff --git a/tests/level_visible/cases.lua b/tests/level_visible/cases.lua new file mode 100644 index 000000000..ad20a8643 --- /dev/null +++ b/tests/level_visible/cases.lua @@ -0,0 +1,104 @@ +local M = {} +function M.run(f) + local T, e, mon = f.T, f.exports, f.mon + local api, rows = e.api, {} + if api.supports then + T.eq(api:supports("hooks", "pokemon.level_visible"), true, "native capability") + end + for _, where in ipairs({ "battle.enemy", "battle.player", "party", "summary" }) do + local function observe(label, value) + T.eq(f.visible(where), value, where .. " " .. label) + rows[#rows + 1] = { where = where, label = label, visible = value } + end + observe("native", true) + T.eq(e.seen.context.where, where, "surface context") + T.eq(e.seen.mon.level, 42, "level in every callback format") + if f.api < 3 and f.format ~= "unified" then + T.eq(e.seen.mon, mon, "native mon identity") + T.eq(e.seen.context.game, f.game, "native Game identity") + else + T.eq(e.seen.mon.species, "FIXMON_A", "normalized species") + T.check(e.seen.mon ~= mon, "unified mon record") + end + e.hide(where, true) + observe("hidden", false) + local other = where == "party" and "summary" or "party" + T.eq(f.visible(other), true, "per-surface decision") + e.hide(where, false) + observe("restored", true) + end + for _, case in ipairs({ + { name = "nil" }, + { name = "true", value = true }, + { name = "zero", value = 0 }, + { name = "string", value = "" }, + { name = "table", value = {} }, + }) do + local off = api.hooks:wrap("pokemon.level_visible", function() + return case.value + end, 100) + T.eq(f.visible("party"), true, case.name .. " remains visible") + off() + end + local off = api.hooks:wrap("pokemon.level_visible", function() + return false, true + end, 100) + T.eq(f.visible("party"), false, "only first return controls visibility") + off() + local order = {} + local outer = api.hooks:wrap("pokemon.level_visible", function(next, m, ctx) + order[#order + 1] = "outer" + T.eq(ctx.where, "party", "outer context") + local result = next(m, ctx) + order[#order + 1] = "after" + return result + end, 20) + local inner = api.hooks:wrap("pokemon.level_visible", function(next) + order[#order + 1] = "inner" + return next() + end, 10) + T.eq(f.visible("party"), true, "next() retains arguments") + T.same(order, { "outer", "inner", "after" }, "priority and delegation") + outer() + inner() + e.hide("party", true) + for _, after in ipairs({ false, true }) do + off = api.hooks:wrap("pokemon.level_visible", function(next) + if after then + next() + end + error("intentional level predicate failure") + end, 100) + T.eq(f.visible("party"), false, "error preserves downstream false") + off() + end + if f.api >= 3 then + off = api.hooks:wrap("pokemon.level_visible", function(next) + next() + return next() + end, 100) + local calls = e.seen.calls + T.eq(f.visible("party"), false, "API 3 caches false") + T.eq(e.seen.calls, calls + 1, "API 3 does not replay downstream") + off() + end + e.stop() + e.stop() + local calls = e.seen.calls + T.eq(f.visible("party"), true, "unsubscribe restores default") + T.eq(e.seen.calls, calls, "idempotent unsubscribe") + api.hooks:wrap("pokemon.level_visible", function() + return false + end) + local peerCalls = 0 + local peer = f.result.loader.hooks:wrap("pokemon.level_visible", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) + end, 10, "level_peer") + f.result.loader.hooks:removeOwner("portable_level_visible") + T.eq(f.visible("party"), true, "owner cleanup restores default") + T.eq(peerCalls, 1, "owner cleanup preserves peer") + peer() + return rows +end +return M diff --git a/tests/level_visible/fixture.lua b/tests/level_visible/fixture.lua new file mode 100644 index 000000000..aaecf5211 --- /dev/null +++ b/tests/level_visible/fixture.lua @@ -0,0 +1,57 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod) + local T = require("tests.modkit") + local Version, Json = require("src.core.GameVersion"), require("src.link.Json") + Version.set(version) + local files = {} + for _, name in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_level_visible/" .. name, "rb")) + files["mods/portable_level_visible/" .. name] = file:read("*a") + file:close() + end + local actualFormat = api >= 3 and "unified" or (backend == "gen1" and "native" or format) + local manifest = Json.decode(files["mods/portable_level_visible/manifest.json"]) + manifest.api, manifest.callback_format = api, backend == "gen2" and format or nil + files["mods/portable_level_visible/manifest.json"] = Json.encode(manifest) + local data = T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_level_visible" }, { + data = data, + generation = Version.generation(), + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local restores = {} + local function patch(owner, key, value) + local old = owner[key] + restores[#restores + 1] = function() + owner[key] = old + end + owner[key] = value + end + local game = { data = data, save = { options = {} } } + local mon = require("src.pokemon.Pokemon").new(data, "FIXMON_A", 42) + local e = result.loader.exports.portable_level_visible + return { + T = T, + game = game, + mon = mon, + data = data, + result = result, + exports = e, + api = api, + format = actualFormat, + backend = backend, + patch = patch, + visible = function(where, value) + return require("src.ui.LevelDisplay").visible(value or mon, where, game) + end, + close = function() + for i = #restores, 1, -1 do + restores[i]() + end + result.release() + end, + } +end +return M diff --git a/tests/level_visible/native.lua b/tests/level_visible/native.lua new file mode 100644 index 000000000..d73318787 --- /dev/null +++ b/tests/level_visible/native.lua @@ -0,0 +1,191 @@ +-- Exercise the actual battle producers, spying only at font/tile leaves. +local M = {} +local function upvalue(owner, wanted) + local visited = {} + local function search(fn) + if type(fn) ~= "function" or visited[fn] then + return + end + visited[fn] = true + for i = 1, 100 do + local name, value = debug.getupvalue(fn, i) + if not name then + break + end + if name == wanted then + return value + end + local found = search(value) + if found then + return found + end + end + end + for _, fn in pairs(owner) do + local found = search(fn) + if found then + return found + end + end + error("native producer missing: " .. wanted) +end +function M.run(f) + local T, Version = f.T, require("src.core.GameVersion") + local e, mon, game = f.exports, f.mon, f.game + local levels, labels, draw, b = 0, 0 + local restoreLeaf + local function ink(text) + text = tostring(text) + if text == "42" or text == "Lv42" or text == "42" then + levels = levels + 1 + end + if text:find("Lv42", 1, true) or text:find("42", 1, true) then + labels = labels + 1 + end + return 16 + end + if f.backend == "gen1" and Version.generation() == 3 then + require("src.import.gba.versions").select(Version.get()) + local Font = require("src.ui.game3.frlg_font") + f.patch(Font, "draw", ink) + f.patch(Font, "drawGlyph", function(code) + if code == Font.CHAR_LV_2 then + labels = labels + 1 + end + end) + f.patch(Font, "advance", function() + return 6 + end) + -- This is the private routine Healthbox.draw uses for singles and doubles; + -- keep it intact, with a real native state/session context. + local level = upvalue(require("src.core.game3.battle.healthbox"), "draw_level") + draw = function() + for _, side in ipairs({ "enemy", "player" }) do + level(mon.level, 0, 0, 0, mon, side, { session = { game = game } }) + end + end + else + local Font = require("src.render.Font") + Font.load(game.data) + f.patch(Font, "draw", ink) + f.patch(Font, "drawCode", function() end) + f.patch(Font, "drawBox", function() end) + local Hud = require("src.render.HudTiles") + f.patch(Hud, "tile", function(code) + if code == 0x6E then + labels = labels + 1 + end + end) + local class = require( + f.backend == "gen1" and Version.generation() == 2 and "src.ui.gen2.BattleState" + or "src.battle.BattleState" + ) + if jit and class.drawHUDs then + jit.off(class.drawHUDs, true) + end + -- BattleState captures this font/tile leaf when its module is loaded. + -- The SDK may already have loaded it before we install the recorder. + for i = 1, 100 do + local name, value = debug.getupvalue(class.drawHUDs or function() end, i) + if not name then + break + end + if name == "hudTile" then + debug.setupvalue(class.drawHUDs, i, Hud.tile) + restoreLeaf = function() + debug.setupvalue(class.drawHUDs, i, value) + end + break + end + end + b = setmetatable({ + game = game, + data = game.data, + phase = "contract", + frame = 0, + fx = {}, + ballRows = {}, + shown = {}, + showEnemyHud = true, + showPlayerHud = true, + player = { name = "SCOUT", mon = mon, shownHP = mon.hp, isPlayer = true }, + enemy = { name = "FOE", mon = mon, shownHP = mon.hp, isPlayer = false }, + }, { __index = class }) + if f.backend == "gen1" and Version.generation() == 2 then + local Chrome = require("src.ui.gen2.Chrome") + f.patch(Chrome, "printThrough", ink) + f.patch(Chrome, "printRightThrough", ink) + b.hud = { + available = function() + return true + end, + drawEnemyFrame = function() end, + drawPlayerFrame = function() end, + drawExpBar = function() end, + } + b.activeMon = function() + return mon + end + b.name = function() + return "SCOUT" + end + b.hudCleared = function() + return false + end + b.statusTag = function() end + b.genderSymbol = function() end + b.caughtMarkerVisible = function() + return false + end + b.hudHp = function() + return mon.hp + end + b.drawHpBar = function() end + draw = function() + b:drawEnemyHud() + b:drawPlayerHud() + end + else + b.growInScale = function() end + b.colorMode = function() + return false + end + b.demoHidesPlayer = function() + return false + end + draw = function() + b:drawHUDs(0) + end + end + end + local seen = {} + local off = f.result.loader.hooks:wrap("pokemon.level_visible", function(next, m, ctx) + T.eq(m, mon, "native battle supplies actual Pokemon") + T.eq(ctx.game, game, "native battle supplies current Game") + seen[ctx.where] = true + return next(m, ctx) + end, 1000) + local rows = {} + for _, phase in ipairs({ + { enemy = false, player = false, count = 2 }, + { enemy = true, player = false, count = 1 }, + { enemy = false, player = true, count = 1 }, + { enemy = true, player = true, count = 0 }, + { enemy = false, player = false, count = 2 }, + }) do + e.hide("battle.enemy", phase.enemy) + e.hide("battle.player", phase.player) + levels, labels, seen = 0, 0, {} + draw() + T.eq(levels, phase.count, "native battle level digits") + T.eq(labels, phase.count, "native battle level labels") + T.check(seen["battle.enemy"] and seen["battle.player"], "native battle surfaces") + rows[#rows + 1] = { enemy = phase.enemy, player = phase.player, levels = levels, labels = labels } + end + off() + if restoreLeaf then + restoreLeaf() + end + return rows +end +return M diff --git a/tests/level_visible/worker.lua b/tests/level_visible/worker.lua new file mode 100644 index 000000000..6ddd39985 --- /dev/null +++ b/tests/level_visible/worker.lua @@ -0,0 +1,15 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local fixture = dofile(root .. "/tests/level_visible/fixture.lua") +local f = fixture.new(root, backend, version, api, format) +local rows = { + native = dofile(root .. "/tests/level_visible/native.lua").run(f), + predicate = dofile(root .. "/tests/level_visible/cases.lua").run(f), +} +f.close() +local bare = fixture.new(root, backend, version, api, format, true) +bare.T.eq(require("src.mods.Runtime").wantsHook("pokemon.level_visible"), false, "bare Runtime") +bare.T.eq(bare.visible("party"), true, "no-mod default") +bare.close() +print("LEVEL_VISIBLE_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable pokemon.level_visible") diff --git a/tools/level-visible-contract-check/check.py b/tools/level-visible-contract-check/check.py new file mode 100644 index 000000000..c1d37b10a --- /dev/null +++ b/tools/level-visible-contract-check/check.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Run one unchanged level visibility mod through both engines' real SDK and display predicate.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/level_visible/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("LEVEL_VISIBLE_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("LEVEL_VISIBLE_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "callback_format": "unified" if a.api >= 3 else + ("native" if backend == "gen1" else a.format), + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"level visibility mismatch: {version}" + print(f"PASS {version}: matching native level display predicates across {len(observations[0]['native']) + len(observations[0]['predicate'])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_level_visible/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 97eac5593cbab7e3ff537879a148fb084046982d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:59:50 +0200 Subject: [PATCH 031/138] Add portable auxiliary battle menu actions with native admission guards --- .../workflows/battle-auxiliary-contract.yml | 50 ++++++ docs/api3-portability.md | 2 +- docs/portable-battle-auxiliary.md | 65 +++++++ examples/portable_battle_auxiliary/main.lua | 18 ++ .../portable_battle_auxiliary/manifest.json | 1 + src/battle/BattleState.lua | 5 + src/battle/MenuAuxiliary.lua | 163 ++++++++++++++++++ src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 2 + tests/battle_auxiliary/fixture.lua | 54 ++++++ tests/battle_auxiliary/shared.lua | 97 +++++++++++ tests/battle_auxiliary/worker.lua | 27 +++ tests/engine/battle_auxiliary.lua | 131 ++++++++++++++ tests/trainer_preparation/fixture.lua | 17 +- .../battle-auxiliary-contract-check/check.py | 60 +++++++ 15 files changed, 684 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/battle-auxiliary-contract.yml create mode 100644 docs/portable-battle-auxiliary.md create mode 100644 examples/portable_battle_auxiliary/main.lua create mode 100644 examples/portable_battle_auxiliary/manifest.json create mode 100644 src/battle/MenuAuxiliary.lua create mode 100644 tests/battle_auxiliary/fixture.lua create mode 100644 tests/battle_auxiliary/shared.lua create mode 100644 tests/battle_auxiliary/worker.lua create mode 100644 tests/engine/battle_auxiliary.lua create mode 100644 tools/battle-auxiliary-contract-check/check.py diff --git a/.github/workflows/battle-auxiliary-contract.yml b/.github/workflows/battle-auxiliary-contract.yml new file mode 100644 index 000000000..612f4ced7 --- /dev/null +++ b/.github/workflows/battle-auxiliary-contract.yml @@ -0,0 +1,50 @@ +name: portable battle.menu_auxiliary contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: battle-auxiliary-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native auxiliary battle dispatch and SDK contracts + run: | + luajit tests/engine/battle_auxiliary.lua + luajit tests/engine/trainer_preparation.lua + luajit tests/engine/trainer_preparation_native.lua + luajit tests/engine/trainer_party_scope.lua + - name: Validate pinned native Gen1 auxiliary battle contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/unified_gen2_hooks.lua + luajit tests/modkit/cases/unified_gen3_hooks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/battle-auxiliary-contract + python3 tools/battle-auxiliary-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/battle-auxiliary-contract/api1-native.json + python3 tools/battle-auxiliary-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/battle-auxiliary-contract/api2-native.json + python3 tools/battle-auxiliary-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/battle-auxiliary-contract/api2-unified.json + python3 tools/battle-auxiliary-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/battle-auxiliary-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: battle-auxiliary-contract + path: /tmp/battle-auxiliary-contract + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 8cf430692..e324d5c36 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -95,7 +95,7 @@ emits nothing. Invalid save stores are refused without overwriting them. Gen1 API-3 callback names from the pinned reference are recognized even when this engine has no call site for them. Registration is allowed; availability still requires a native call site in the selected version. For example, -`battle.menu_auxiliary` can be subscribed to but reports unavailable here. Unknown +`battle.caught_marker_visible` can be subscribed to but reports unavailable here. Unknown names remain errors. An accepted subscription is not proof that a feature runs. Source-specific engine handles in lifecycle/UI payloads remain native. diff --git a/docs/portable-battle-auxiliary.md b/docs/portable-battle-auxiliary.md new file mode 100644 index 000000000..f84b49cf2 --- /dev/null +++ b/docs/portable-battle-auxiliary.md @@ -0,0 +1,65 @@ +# Portable auxiliary battle action + +`battle.menu_auxiliary` lets a mod handle START at a settled native player +command menu. It avoids replacing a generation-specific battle controller just +to open an auxiliary tool. Native menu navigation continues when no subscriber +exists or the first hook result is anything other than literal `true`. + +```lua +local mod = ... +mod.hooks:wrap("battle.menu_auxiliary", function(next, game, context) + if myToolIsAvailable(context.kind) then + openMyTool(game) + return true + end + return next(game, context) +end) +``` + +Arguments are the Game handle and `{kind = "wild" | "trainer"}`. Gen2 retains +the opaque Game handle in all SDK modes; Gen1 API 3 can provide its existing +forwarding context view. The small context uses the normal unified record +adapter. Consumers should use public APIs and supported UI methods rather than +inspect private Game/controller fields. + +Native dispatch covers Gen2Recomped's GB, GBA and DS command menu paths. +Availability does not promise every battle variant. The boundary excludes +covered screens, link/safari/tutorial/automatic/facility variants, forced +moves, pending double choices, faint/replacement states, queued work, and +unfinished HP/status/animation presentation. Numerical counters at zero are +idle in Gen2's controller. Eligible double battles use the current player +actor and allow absent slots while requiring a live opponent. + +Eligibility is sampled before native presentation catch-up. A START press +cannot become eligible halfway through an update just because HP/status was +snapped to its final value. Dispatch rechecks the current owner/session. If a +hook changes the session, opens an overlay or invalidates the menu, that +update stops using the old menu input even if the wrapper returns false or +raises. A normal false/default result retains native navigation exactly once. +This is an input boundary, not a checkpoint/reconstruction API. + +## Validation + +The unchanged `examples/portable_battle_auxiliary` consumer runs through both +real SDKs and native command-menu update methods for Red, Yellow, Gold, +FireRed and Emerald. Native API 1/2 and API 3 are compared directly. Gen2's +additional unified API 2 mode is compared against Gen1's native API 2; Gen1 +does not implement the `callback_format` manifest extension. The reference is +pinned to clean Gen1 commit `79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. + +The shared fixture reuses native trainer construction from the previous +trainer contract with a different consumer; it constructs native battle +objects and settles their command menus. GBA uses the real owned battle +runtime and presentation state, not a replacement hook producer. Native Gen1 +safety regressions also run unchanged. The Gen2 suite tests every game and +SDK mode, including native fallthrough, wrapper failures, stale sessions, +covered/unsafe states and rejection before presentation catch-up. + +```sh +luajit tests/engine/battle_auxiliary.lua +python3 tools/battle-auxiliary-contract-check/check.py --gen1 /path/to/gen1 \ + --api 3 --format unified --report /tmp/battle-auxiliary.json +``` + +These are ROM-free behavior tests. Native cartridge boot and GPU frame checks +are separate from the shared command-menu contract. diff --git a/examples/portable_battle_auxiliary/main.lua b/examples/portable_battle_auxiliary/main.lua new file mode 100644 index 000000000..d509dd253 --- /dev/null +++ b/examples/portable_battle_auxiliary/main.lua @@ -0,0 +1,18 @@ +-- Set claim(true) when a custom auxiliary battle UI is available. Its opener +-- belongs in onOpen; return true only when taking ownership of this input. +local mod = ... +local claimed, seen = false, { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.claim(value) + claimed = value == true +end +mod.exports.stop = mod.hooks:wrap("battle.menu_auxiliary", function(next, game, context) + seen.calls, seen.game, seen.context = seen.calls + 1, game, context + if claimed then + if mod.exports.onOpen then + mod.exports.onOpen(game, context) + end + return true + end + return next(game, context) +end) diff --git a/examples/portable_battle_auxiliary/manifest.json b/examples/portable_battle_auxiliary/manifest.json new file mode 100644 index 000000000..ae5e702db --- /dev/null +++ b/examples/portable_battle_auxiliary/manifest.json @@ -0,0 +1 @@ +{"id":"portable_battle_auxiliary","name":"Portable Battle Auxiliary Action","version":"1.0.0","api":3,"entry":"main.lua","games":["all"],"generations":[1,2,3,4,5],"affects_link":false} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index a3031115d..e316853d8 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -20,6 +20,7 @@ local Experience = require("src.battle.Experience") local HeldItems = require("src.battle.HeldItems") local Font = require("src.render.Font") local LevelDisplay = require("src.ui.LevelDisplay") +local MenuAuxiliary = require("src.battle.MenuAuxiliary") local Logger = require("src.core.Logger") local MoveEffects = require("src.battle.MoveEffects") local Party = require("src.pokemon.Party") @@ -4275,6 +4276,7 @@ function BattleState:touchpressed(_, px, py) return true end function BattleState:update(dt) + local auxiliary = MenuAuxiliary.request(self) self:tickFx() -- one sample a second into the fight, while it is still on screen self._stadiumReportTicks = (self._stadiumReportTicks or 0) + 1 @@ -4493,6 +4495,9 @@ function BattleState:update(dt) self:resolveTurn(locked) return end + -- Capture eligibility before presentation catch-up can hide an unfinished + -- HP/status drain. Dispatch only at the ordinary player-decision boundary. + if auxiliary and MenuAuxiliary.dispatch(self, auxiliary) then return end -- PLATINUM'S ACTION MENU IS NOT A 2x2, and walking it as one is what a -- player feels as the cursor jumping about. -- diff --git a/src/battle/MenuAuxiliary.lua b/src/battle/MenuAuxiliary.lua new file mode 100644 index 000000000..2364a1c7d --- /dev/null +++ b/src/battle/MenuAuxiliary.lua @@ -0,0 +1,163 @@ +-- Public START action at an idle player command menu, before native navigation. +local Runtime = require("src.mods.Runtime") +local M = {} +local BUSY = { + "current", + "nextInsert", + "pendingHit", + "waitingUI", + "waitingSound", + "draining", + "animPlaying", + "growIn", + "shrinkOut", + "ghostReveal", + "mimicCtx", + "result", + "gen3BallPlaying", + "gen4TouchPress", +} +local COUNTERS = { "waitFrames", "hitSfxWait", "introSlide" } +local VARIANTS = { + "safari", + "ghost", + "scopeReveal", + "demo", + "pokedudeDemo", + "noCatch", + "palace", + "arena", + "ally", + "factory", + "pyramid", + "pike", + "battleTower", + "trainerTower", + "secretBase", + "eReader", +} +local function nonempty(value) + return type(value) == "table" and next(value) ~= nil +end +local function defaultAction() + return false +end +function M.ready(battle) + local game = battle and battle.game + if + not game + or not game.save + or not game.data + or battle.phase ~= "menu" + or (battle.kind ~= "wild" and battle.kind ~= "trainer") + then + return false + end + local stack = game.stack + if not stack or not stack.top or stack:top() ~= battle then + return false + end + if + nonempty(battle.queue) + or nonempty(battle.pendingActions) + or nonempty(battle.chosenTargets) + or (battle.afterQueue and battle.afterQueue ~= "menu") + then + return false + end + for _, field in ipairs(VARIANTS) do + if battle[field] then + return false + end + end + for _, field in ipairs(BUSY) do + if battle[field] then + return false + end + end + for _, field in ipairs(COUNTERS) do + local value = battle[field] + if value and value ~= 0 then + return false + end + end + if battle.showEnemyTrainer or battle.showPlayerBack or battle.enemySendingOut then + return false + end + local actor = battle.choosingBattler and battle:choosingBattler() or battle.player + if + not actor + or actor.ally + or not actor.mon + or (actor.mon.hp or 0) <= 0 + or (battle.menuLockedAction and battle:menuLockedAction(actor)) + or actor.bwSkyDropped + then + return false + end + local count = battle.isDouble and battle:isDouble() and 4 or 2 + local enemyStanding = false + for position = 0, count - 1 do + local battler = battle.battlerAt and battle:battlerAt(position) + or (position == 0 and battle.player or position == 1 and battle.enemy) + if battler and battler.mon then + local mon = battler.mon + if + (mon.hp or 0) <= 0 + or battler.fainted + or battler.faintQueued + or battler.draining + or battler.drainFloor ~= nil + or battler.drainHold ~= nil + or battler.shownHP ~= mon.hp + or battler.shownStatus ~= mon.status + then + return false + end + if position % 2 == 1 then + enemyStanding = true + end + elseif count == 2 then + return false + end + end + return enemyStanding +end +function M.request(battle) + if not Runtime.wantsHook("battle.menu_auxiliary") then + return + end + local game = battle.game + if not game or not game.input or not game.input:wasPressed("start") or not M.ready(battle) then + return + end + return { + game = game, + save = game.save, + data = game.data, + stack = game.stack, + session = game.sessionStartedAt, + kind = battle.kind, + } +end +local function current(battle, token) + local game = token.game + return battle.game == game + and game.save == token.save + and game.data == token.data + and game.stack == token.stack + and game.sessionStartedAt == token.session + and battle.kind == token.kind + and M.ready(battle) +end +function M.dispatch(battle, token) + -- Retire input captured for an owner/session that changed during native FX. + if not current(battle, token) then + return true + end + local result = Runtime.call("battle.menu_auxiliary", defaultAction, token.game, { kind = token.kind }) + -- A pushed overlay or changed session must never receive the same input as + -- the retired battle menu, even if the wrapper returned false or raised. + return result == true or not current(battle, token) +end +return M diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 76d17a339..b2ad7c7af 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -16,7 +16,7 @@ end -- need not fire on every action (e.g. an always-hit move skips accuracy). add("hooks", "12345", [[ battle.accuracy battle.bottom_ui_visible battle.catch_exp battle.crit battle.damage battle.enemy_action - battle.exp_award battle.low_health_alarm battle.run battle.turn_order catch.rate core.logic_speed core.update + battle.exp_award battle.low_health_alarm battle.menu_auxiliary battle.run battle.turn_order catch.rate core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index b3107e963..c3a078874 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -16,6 +16,7 @@ end -- This is an audited boundary catalog, not a claim that every game executes -- every seam. The source-catalog test requires a decision for each new seam. C.hooks = names([[ + battle.menu_auxiliary battle.accuracy battle.bottom_ui_visible battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility @@ -39,6 +40,7 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["battle.menu_auxiliary"] = { false, "context" }, ["pokemon.level_visible"] = { "mon", "context" }, ["trainer.before_battle"] = { false, "context", false }, ["battle.accuracy"] = { "context" }, diff --git a/tests/battle_auxiliary/fixture.lua b/tests/battle_auxiliary/fixture.lua new file mode 100644 index 000000000..267ec0a8e --- /dev/null +++ b/tests/battle_auxiliary/fixture.lua @@ -0,0 +1,54 @@ +local M = {} +function M.new(root, version, api, format, noMod) + local T = require("tests.modkit") + require("src.core.GameVersion").set(version) + local Json, files = require("src.link.Json"), {} + for _, name in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_battle_auxiliary/" .. name, "rb")) + files["mods/portable_battle_auxiliary/" .. name] = file:read("*a") + file:close() + end + local manifest = Json.decode(files["mods/portable_battle_auxiliary/manifest.json"]) + manifest.api, manifest.callback_format = api, format + files["mods/portable_battle_auxiliary/manifest.json"] = Json.encode(manifest) + local data = T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_battle_auxiliary" }, { + data = data, + generation = require("src.core.GameVersion").generation(), + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local game = { data = data, save = require("src.core.SaveData").newGame(), sessionStartedAt = 1 } + game.input = setmetatable({}, { __index = require("src.core.Input") }) + game.input:init() + game.stack = setmetatable({}, { __index = require("src.core.StateStack") }) + game.stack:init() + game.save.party = { require("src.pokemon.Pokemon").new(data, "FIXMON_A", 20) } + result.loader.game = game + require("src.render.Font").load(data) + local b = require("src.battle.BattleState").newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase, b.frame, b.queue = "menu", 0, {} + b.gen4CursorActive = true + b.menuIndex, b.moveIndex = 1, 1 + game.stack.states = { b } + local exports = result.loader.exports.portable_battle_auxiliary + return { + T = T, + game = game, + battle = b, + exports = exports, + result = result, + api = api, + press = function(start, right) + local direction = require("src.core.GameVersion").generation() >= 4 and "down" or "right" + game.input.pressed = { start = start, [direction] = right } + b:update(1 / 60) + end, + close = function() + result.release() + end, + } +end +return M diff --git a/tests/battle_auxiliary/shared.lua b/tests/battle_auxiliary/shared.lua new file mode 100644 index 000000000..ae233d56d --- /dev/null +++ b/tests/battle_auxiliary/shared.lua @@ -0,0 +1,97 @@ +-- Reuse native backend construction from trainer admission; use a different +-- unchanged consumer and no trainer hook, then settle the actual native menu. +local M = {} +function M.new(root, backend, version, api, format) + local f = dofile(root .. "/tests/trainer_preparation/fixture.lua").new( + root, + backend, + version, + api, + format, + false, + false, + "portable_battle_auxiliary" + ) + f.start() + local battle, game = assert(f.battle()), f.game + local update, index, ready + if backend == "gen1" and f.generation == 3 then + local B = require("src.core.game3.battle") + local Ui = require("src.core.game3.battle.ui") + local Anim = require("src.core.game3.battle.anim") + local State = require("src.core.game3.battle.state") + B.reset() + B._active, B._st = true, battle + B._adapter = require("src.core.game3.battle.adapter").new(battle) + B._phase, B._auto, B._headless, B._fade = "command", false, false, false + Ui.reset({ headless = false }) + Ui.bindState(battle, game.session) + Ui._headless = false + Ui.openMenu() + Anim.reset({ headless = true }) + for id = 0, 1 do + local b, p = State.battler(battle, id), Anim.present(id) + Anim.setShown(id, b) + p.displayHp, p.displayMaxHp = b.mon.hp, b.mon.maxHp + p.displayStatus = b.status or b.mon.status or b.mon.status1 + end + update = function() + B.update(0, game) + end + index = function(value) + if value then + Ui._menuIndex = value + end + return Ui._menuIndex + end + ready = function() + return B._phase == "command" + end + elseif backend == "gen1" and f.generation == 2 then + local screen = game.stack:top() + assert(screen and screen.battle == battle, "actual Gen1 Gold battle screen missing") + screen.phase, screen.slideFrame, screen.queue = "menu", 1000, {} + screen.showPlayerHud, screen.showEnemyHud = true, true + screen.ballRows = { player = false, enemy = false } + screen.shownMon = { player = battle.player, enemy = battle.enemy } + screen.shownHp = { player = battle.player.hp, enemy = battle.enemy.hp } + screen.shownStatus = { player = false, enemy = false } + screen.shownExp = 0 + update = function() + screen:update(0) + end + index = function(value) + if value then + screen.menuIndex = value + end + return screen.menuIndex + end + ready = function() + return screen:menuAuxiliarySafe() + end + else + battle:syncSides() + battle.phase, battle.frame, battle.queue = "menu", 0, {} + battle.gen4CursorActive = true + battle.checkpointOrigin = { kind = "trainer_encounter" } + game.stack.states = { battle } + update = function() + battle:update(0) + end + index = function(value) + if value then + battle.menuIndex = value + end + return battle.menuIndex + end + ready = function() + if backend == "gen2" then + return require("src.battle.MenuAuxiliary").ready(battle) + end + return require("src.battle.BattleSafety").inspect(game, battle) == true + end + end + f.update, f.index, f.ready = update, index, ready + return f +end +return M diff --git a/tests/battle_auxiliary/worker.lua b/tests/battle_auxiliary/worker.lua new file mode 100644 index 000000000..2f5ef22f3 --- /dev/null +++ b/tests/battle_auxiliary/worker.lua @@ -0,0 +1,27 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local f = dofile(root .. "/tests/battle_auxiliary/shared.lua").new(root, backend, version, api, format) +local e, T = f.exports, f.T +T.eq(f.ready(), true, "actual native menu settled") +local rows = {} +for _, claim in ipairs({ false, true, false }) do + e.claim(claim) + f.index(1) + f.game.input.pressed = { start = true, right = true } + local calls = e.seen.calls + f.update() + T.eq(e.seen.calls, calls + 1, "one native dispatch") + T.eq(f.index(), claim and 1 or 2, "native claim/fallthrough") + T.eq(e.seen.context.kind, "trainer", "shared trainer context") + rows[#rows + 1] = { claim = claim, index = f.index(), kind = e.seen.context.kind } +end +e.stop() +f.index(1) +f.game.input.pressed = { start = true, right = true } +local calls = e.seen.calls +f.update() +T.eq(e.seen.calls, calls, "unsubscribe stops native hook") +T.eq(f.index(), 2, "unsubscribe preserves native navigation") +f.close() +print("BATTLE_AUXILIARY_CONTRACT " .. require("src.link.Json").encode(rows)) +T.finish("portable battle.menu_auxiliary shared native contract") diff --git a/tests/engine/battle_auxiliary.lua b/tests/engine/battle_auxiliary.lua new file mode 100644 index 000000000..2502dc60e --- /dev/null +++ b/tests/engine/battle_auxiliary.lua @@ -0,0 +1,131 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local F = dofile("tests/battle_auxiliary/fixture.lua") +local Version, Auxiliary = require("src.core.GameVersion"), require("src.battle.MenuAuxiliary") +local oldVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = F.new(".", version, mode[1], mode[2]) + local b, game, e = f.battle, f.game, f.exports + T.eq(e.api:supports("hooks", "battle.menu_auxiliary"), true, version .. " capability") + T.eq(Auxiliary.ready(b), true, "native command menu ready") + f.press(false, true) + T.eq(e.seen.calls, 0, "no START leaves hook idle") + T.eq(b.menuIndex, 2, "ordinary native navigation") + b.menuIndex = 1 + f.press(true, true) + T.eq(e.seen.calls, 1, "START invokes native hook once") + T.eq(b.menuIndex, 2, "unclaimed START preserves native navigation") + T.eq(e.seen.game, game, "opaque native Game") + T.same(e.seen.context, { kind = "wild" }, "data-only native context") + e.claim(true) + b.menuIndex = 1 + f.press(true, true) + T.eq(b.menuIndex, 1, "claimed input skips native menu navigation") + for _, row in ipairs({ {}, { value = false }, { value = 0 }, { value = "" }, { value = {} } }) do + local off = e.api.hooks:wrap("battle.menu_auxiliary", function() + return row.value + end, 100) + b.menuIndex = 1 + f.press(true, true) + T.eq(b.menuIndex, 2, "only literal true claims START") + off() + end + for _, after in ipairs({ false, true }) do + local off = e.api.hooks:wrap("battle.menu_auxiliary", function(next) + if after then + next() + end + error("intentional auxiliary failure") + end, 100) + b.menuIndex = 1 + local calls = e.seen.calls + f.press(true, true) + T.eq(b.menuIndex, 1, "wrapper failure preserves downstream claim") + T.eq(e.seen.calls, calls + 1, "failure does not repeat downstream") + off() + end + -- Presentation is caught up during native update; an unsettled frame must + -- not turn into an eligible START action halfway through that same update. + local calls = e.seen.calls + b.player.shownHP = b.player.mon.hp - 1 + b.menuIndex = 1 + f.press(true, true) + T.eq(e.seen.calls, calls, "unsettled HP rejected before catch-up") + T.eq(b.menuIndex, 2, "native catch-up/navigation still runs") + b.menuIndex = 1 + f.press(true, true) + T.eq(e.seen.calls, calls + 1, "fresh START after settling works") + local bad = { + { owner = b, key = "kind", value = "link" }, + { owner = b, key = "safari", value = {} }, + { owner = b, key = "pokedudeDemo", value = true }, + { owner = b, key = "palace", value = true }, + { owner = b, key = "phase", value = "moveSelect" }, + { owner = b, key = "queue", value = { {} } }, + { owner = b, key = "pendingActions", value = { [0] = { kind = "move" } } }, + { owner = b, key = "animPlaying", value = true }, + { owner = b, key = "waitFrames", value = 1 }, + { owner = b, key = "introSlide", value = 1 }, + { owner = b, key = "waitingUI", value = true }, + { owner = b.player, key = "mustRecharge", value = true }, + { owner = b.player, key = "shownStatus", value = "PSN" }, + { owner = b.player, key = "drainFloor", value = 0 }, + { owner = game.stack, key = "states", value = { b, {} } }, + } + for _, change in ipairs(bad) do + local old = change.owner[change.key] + change.owner[change.key] = change.value + T.eq(Auxiliary.ready(b), false, "unsafe " .. change.key) + game.input.pressed = { start = true } + T.eq(Auxiliary.request(b), nil, "unsafe START does not allocate request") + change.owner[change.key] = old + end + game.input.pressed = { start = true } + local token = assert(Auxiliary.request(b)) + calls = e.seen.calls + game.sessionStartedAt = 2 + T.eq(Auxiliary.dispatch(b, token), true, "retired session input consumed") + T.eq(e.seen.calls, calls, "retired session never dispatches hook") + game.sessionStartedAt = 1 + -- Even a faulty wrapper that opens an overlay and returns false cannot + -- feed that same input through the old native menu. + local overlay = {} + local off = e.api.hooks:wrap("battle.menu_auxiliary", function() + game.stack.states = { b, overlay } + return false + end, 100) + b.menuIndex = 1 + f.press(true, true) + T.eq(game.stack:top(), overlay, "custom overlay stays on stack") + T.eq(b.menuIndex, 1, "overlay invalidation prevents stale native fallthrough") + off() + game.stack.states = { b } + e.stop() + e.stop() + calls = e.seen.calls + b.menuIndex = 1 + f.press(true, true) + T.eq(e.seen.calls, calls, "unsubscribe is idempotent") + T.eq(b.menuIndex, 2, "unsubscribe restores native menu") + local player, enemy = b.player, b.enemy + b.double = true + b:placeBattler(2, player) + b:placeBattler(0, nil) + b.choosingSlot = 2 + T.eq(Auxiliary.ready(b), true, "double partner can own an otherwise empty player side") + b:placeBattler(1, nil) + T.eq(Auxiliary.ready(b), false, "no live opponent cannot open an auxiliary menu") + b:placeBattler(1, enemy) + T.eq(Auxiliary.ready(b), true, "restored opponent returns to a live decision") + f.close() + local unreadable = setmetatable({}, { + __index = function() + error("no-hook path touched battle") + end, + }) + T.eq(Auxiliary.request(unreadable), nil, "no-mod hot path skips native traversal") + end +end +Version.set(oldVersion) +T.finish("portable battle.menu_auxiliary native dispatch") diff --git a/tests/trainer_preparation/fixture.lua b/tests/trainer_preparation/fixture.lua index 80b3c7987..59047ca47 100644 --- a/tests/trainer_preparation/fixture.lua +++ b/tests/trainer_preparation/fixture.lua @@ -1,20 +1,21 @@ local M = {} -function M.new(root, backend, version, api, format, noMod, failing) +function M.new(root, backend, version, api, format, noMod, failing, consumer) + consumer = consumer or "portable_trainer_preparation" local T = require("tests.modkit") local Version = require("src.core.GameVersion") Version.set(version) local gen = Version.generation() local Json, files = require("src.link.Json"), {} for _, name in ipairs({ "main.lua", "manifest.json" }) do - local file = assert(io.open(root .. "/examples/portable_trainer_preparation/" .. name, "rb")) - files["mods/portable_trainer_preparation/" .. name] = file:read("*a") + local file = assert(io.open(root .. "/examples/" .. consumer .. "/" .. name, "rb")) + files["mods/" .. consumer .. "/" .. name] = file:read("*a") file:close() end - local manifest = Json.decode(files["mods/portable_trainer_preparation/manifest.json"]) + local manifest = Json.decode(files["mods/" .. consumer .. "/manifest.json"]) manifest.api, manifest.callback_format = api, format - files["mods/portable_trainer_preparation/manifest.json"] = Json.encode(manifest) + files["mods/" .. consumer .. "/manifest.json"] = Json.encode(manifest) if failing then - files["mods/portable_trainer_preparation/main.lua"] = files["mods/portable_trainer_preparation/main.lua"] + files["mods/" .. consumer .. "/main.lua"] = files["mods/" .. consumer .. "/main.lua"] .. '\nerror("intentional entry failure")' end local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or T.fixtures.fresh() @@ -68,7 +69,7 @@ function M.new(root, backend, version, api, format, noMod, failing) items = {}, } end - local result = T.sdk.loadMods(noMod and {} or { "mods/portable_trainer_preparation" }, { + local result = T.sdk.loadMods(noMod and {} or { "mods/" .. consumer }, { data = data, generation = gen, fs = T.sdk.memfs(noMod and {} or files), @@ -290,7 +291,7 @@ function M.new(root, backend, version, api, format, noMod, failing) generation = gen, result = result, game = game, - exports = result.loader.exports.portable_trainer_preparation, + exports = result.loader.exports[consumer], patch = patch, start = start, starts = function() diff --git a/tools/battle-auxiliary-contract-check/check.py b/tools/battle-auxiliary-contract-check/check.py new file mode 100644 index 000000000..f8c2fbefd --- /dev/null +++ b/tools/battle-auxiliary-contract-check/check.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Run one unchanged auxiliary battle mod through both engines' native command menus.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/battle_auxiliary/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("BATTLE_AUXILIARY_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("BATTLE_AUXILIARY_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "callback_format": "unified" if a.api >= 3 else + ("native" if backend == "gen1" else a.format), + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"auxiliary menu mismatch: {version}" + print(f"PASS {version}: matching native command-menu behavior across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_battle_auxiliary/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 3800b634d9f362bde275b63985f82ac49a9c759b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:15:12 +0200 Subject: [PATCH 032/138] Add portable battle move-grid navigation and native controller contracts --- .github/workflows/battle-grid-contract.yml | 51 +++++++++++ docs/portable-battle-navigation.md | 61 +++++++++++++ examples/portable_battle_grid/main.lua | 16 ++++ examples/portable_battle_grid/manifest.json | 1 + src/battle/BattleState.lua | 11 +-- src/battle/MoveNavigation.lua | 52 +++++++++++ src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 3 +- tests/battle_auxiliary/shared.lua | 4 +- tests/battle_grid/cases.lua | 95 ++++++++++++++++++++ tests/battle_grid/fixture.lua | 96 +++++++++++++++++++++ tests/battle_grid/worker.lua | 7 ++ tests/engine/battle_grid_navigation.lua | 69 +++++++++++++++ tools/battle-grid-contract-check/check.py | 60 +++++++++++++ 14 files changed, 519 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/battle-grid-contract.yml create mode 100644 docs/portable-battle-navigation.md create mode 100644 examples/portable_battle_grid/main.lua create mode 100644 examples/portable_battle_grid/manifest.json create mode 100644 src/battle/MoveNavigation.lua create mode 100644 tests/battle_grid/cases.lua create mode 100644 tests/battle_grid/fixture.lua create mode 100644 tests/battle_grid/worker.lua create mode 100644 tests/engine/battle_grid_navigation.lua create mode 100644 tools/battle-grid-contract-check/check.py diff --git a/.github/workflows/battle-grid-contract.yml b/.github/workflows/battle-grid-contract.yml new file mode 100644 index 000000000..8ff91ed2b --- /dev/null +++ b/.github/workflows/battle-grid-contract.yml @@ -0,0 +1,51 @@ +name: portable battle.move_grid_navigation contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: battle-grid-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native move navigation dispatch and SDK contracts + run: | + luajit tests/engine/battle_grid_navigation.lua + luajit tests/engine/battle_auxiliary.lua + luajit tests/engine/trainer_preparation.lua + luajit tests/engine/trainer_preparation_native.lua + luajit tests/engine/trainer_party_scope.lua + - name: Validate pinned native Gen1 move navigation contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/unified_gen2_hooks.lua + luajit tests/modkit/cases/unified_gen3_hooks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/battle-grid-contract + python3 tools/battle-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/battle-grid-contract/api1-native.json + python3 tools/battle-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/battle-grid-contract/api2-native.json + python3 tools/battle-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/battle-grid-contract/api2-unified.json + python3 tools/battle-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/battle-grid-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: battle-grid-contract + path: /tmp/battle-grid-contract + if-no-files-found: ignore diff --git a/docs/portable-battle-navigation.md b/docs/portable-battle-navigation.md new file mode 100644 index 000000000..2de4a3ef2 --- /dev/null +++ b/docs/portable-battle-navigation.md @@ -0,0 +1,61 @@ +# Portable battle move navigation + +`battle.move_grid_navigation` selects native move-menu navigation without +replacing a battle update method. The callback receives the actual opaque +battle/screen handle. Only a first result of literal `true` enables the grid; +`false`, `nil`, zero and other values select the list. `next(battle)` asks for +the host layout's normal mode. Unsubscribing restores that mode. + +```lua +local mod = ... +mod.hooks:wrap("battle.move_grid_navigation", function(next, battle) + if myOptions.useMoveGrid then return true end + return next(battle) +end) +``` + +The native default is a list for classic GB and a grid for GBA/DS and Gen2's +wide layout. As in Gen1Recomp's original GB battle, the Gen1 wide layout is a +fixed grid and bypasses the predicate. Gen2's Gold-family wide screen allows +an explicit list override; its unwrapped native default remains a grid, while +Gen1's distinct Gold screen defaults to a list. Portable custom UIs should +choose their desired mode explicitly rather than infer host-private fields. + +Grid slots are ordered `1 2 / 3 4`. Missing cells retain the current slot. +Single-battle grids wrap between row/column peers. GBA doubles use the native +clamped player controller: left at the left edge and up at the top edge stay +put. This corrects Gen2's previous generic wrapping at GBA doubles edges and +matches Gen1's native GBA controller. DS retains its existing grid behavior. +The list uses native up/down wrapping. A/B/SELECT stay with their normal +handlers; the same decision also applies to Gen2's Mimic copy menu. + +Both native and unified callback formats keep the battle handle opaque. +Subscribing does not normalize engine-private controller fields. Use public +snapshots/actions for gameplay data and input actions. Availability is reported +for the actual shared native producer in Gen1–5. + +## Validation + +The unchanged `portable_battle_grid` example is loaded through both real SDKs. +Shared cases drive native move-menu updates for Red, Yellow, Gold, FireRed and +Emerald: all four directions, every occupied slot and every move count from +one to four, in explicit list/grid modes. They also drive the native GBA +doubles input controller and compare its edge and missing-cell behavior. +The Gen1 reference is pinned to clean +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. + +Native API 1/2 and API 3 are compared directly. Gen2's optional unified API 2 +is compared with Gen1's native API 2, since Gen1 has no `callback_format` +extension. Gen2 additionally tests all fifteen games, native defaults, wide +layout rules, Mimic, wrapper failure recovery, API-3 next caching, unsubscribe +and empty move grids. + +```sh +luajit tests/engine/battle_grid_navigation.lua +python3 tools/battle-grid-contract-check/check.py --gen1 /path/to/gen1 \ + --api 3 --format unified --report /tmp/battle-grid.json +``` + +These are ROM-free native-controller and SDK tests, not cartridge playthroughs +or GPU pixel goldens. This predicate changes navigation; custom grid/list art +remains the mod's responsibility. diff --git a/examples/portable_battle_grid/main.lua b/examples/portable_battle_grid/main.lua new file mode 100644 index 000000000..2a3fc7f21 --- /dev/null +++ b/examples/portable_battle_grid/main.lua @@ -0,0 +1,16 @@ +local mod = ... +local mode, seen = "native", { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.mode(value) + mode = value +end +mod.exports.stop = mod.hooks:wrap("battle.move_grid_navigation", function(next, battle) + seen.calls, seen.battle = seen.calls + 1, battle + if mode == "grid" then + return true + end + if mode == "list" then + return false + end + return next(battle) +end) diff --git a/examples/portable_battle_grid/manifest.json b/examples/portable_battle_grid/manifest.json new file mode 100644 index 000000000..c1ce41463 --- /dev/null +++ b/examples/portable_battle_grid/manifest.json @@ -0,0 +1 @@ +{"id":"portable_battle_grid","name":"Portable Battle Navigation","version":"1.0.0","api":3,"entry":"main.lua","games":["all"],"generations":[1,2,3,4,5],"affects_link":false} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index e316853d8..996a86d68 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -21,6 +21,7 @@ local HeldItems = require("src.battle.HeldItems") local Font = require("src.render.Font") local LevelDisplay = require("src.ui.LevelDisplay") local MenuAuxiliary = require("src.battle.MenuAuxiliary") +local MoveNavigation = require("src.battle.MoveNavigation") local Logger = require("src.core.Logger") local MoveEffects = require("src.battle.MoveEffects") local Party = require("src.pokemon.Party") @@ -365,12 +366,12 @@ end -- its RIGHT. Left and right then did nothing at all, because a vertical list -- has no use for them. The fix is one clause, not new maths: the grid this -- layout already draws is the grid it should navigate. +function BattleState:moveGridNavigation() + return MoveNavigation.grid(self) +end + function BattleState:moveGridNavigate(index, count, input) - if not (self:isWideBattleLayout() or self:gen3Layout() - or self:gen4Layout()) then - return nil - end - return WideBattle.navigate(index, count, input) + return MoveNavigation.navigate(self, index, count, input) end -- The four actions, in the order this layout's grid reads them. Gen 1 goes diff --git a/src/battle/MoveNavigation.lua b/src/battle/MoveNavigation.lua new file mode 100644 index 000000000..6b757a473 --- /dev/null +++ b/src/battle/MoveNavigation.lua @@ -0,0 +1,52 @@ +-- Public choice of navigation mode; battle handles stay opaque to callbacks. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Wide = require("src.battle.WideBattle") +local M = {} +local function nativeGrid(battle) + return battle:isWideBattleLayout() or battle:gen3Layout() or battle:gen4Layout() +end +function M.grid(battle) + -- Gen1's wide GB layout has a fixed grid. Gold's distinct native screen + -- allows wrappers to choose a list even when composed on a wider surface. + if battle:isWideBattleLayout() and not Version.isGen2() then + return true + end + if not Runtime.wantsHook("battle.move_grid_navigation") then + return nativeGrid(battle) + end + return Runtime.call("battle.move_grid_navigation", nativeGrid, battle) == true +end +function M.navigate(battle, index, count, input) + if not M.grid(battle) or count < 1 then + return nil + end + if not (battle:gen3Layout() and battle:isDouble()) then + return Wide.navigate(index, count, input) + end + -- GBA's doubles controller clamps to its current row/column. Singles and + -- the GB mod grid wrap; missing cells retain the current occupied slot. + local target = index + local column, row = (index - 1) % 2, math.floor((index - 1) / 2) + if input:wasPressed("left") then + if column == 1 then + target = index - 1 + end + elseif input:wasPressed("right") then + if column == 0 then + target = index + 1 + end + elseif input:wasPressed("up") then + if row == 1 then + target = index - 2 + end + elseif input:wasPressed("down") then + if row == 0 then + target = index + 2 + end + else + return nil + end + return target >= 1 and target <= count and target or index +end +return M diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index b2ad7c7af..b1b319721 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -16,7 +16,7 @@ end -- need not fire on every action (e.g. an always-hit move skips accuracy). add("hooks", "12345", [[ battle.accuracy battle.bottom_ui_visible battle.catch_exp battle.crit battle.damage battle.enemy_action - battle.exp_award battle.low_health_alarm battle.menu_auxiliary battle.run battle.turn_order catch.rate core.logic_speed core.update + battle.exp_award battle.low_health_alarm battle.menu_auxiliary battle.move_grid_navigation battle.run battle.turn_order catch.rate core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility input.key input.gamepad input.wheel input.step link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index c3a078874..a3836bd8a 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -16,7 +16,7 @@ end -- This is an audited boundary catalog, not a claim that every game executes -- every seam. The source-catalog test requires a decision for each new seam. C.hooks = names([[ - battle.menu_auxiliary + battle.menu_auxiliary battle.move_grid_navigation battle.accuracy battle.bottom_ui_visible battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility @@ -40,6 +40,7 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["battle.move_grid_navigation"] = { false }, ["battle.menu_auxiliary"] = { false, "context" }, ["pokemon.level_visible"] = { "mon", "context" }, ["trainer.before_battle"] = { false, "context", false }, diff --git a/tests/battle_auxiliary/shared.lua b/tests/battle_auxiliary/shared.lua index ae233d56d..675edc2df 100644 --- a/tests/battle_auxiliary/shared.lua +++ b/tests/battle_auxiliary/shared.lua @@ -1,7 +1,7 @@ -- Reuse native backend construction from trainer admission; use a different -- unchanged consumer and no trainer hook, then settle the actual native menu. local M = {} -function M.new(root, backend, version, api, format) +function M.new(root, backend, version, api, format, consumer) local f = dofile(root .. "/tests/trainer_preparation/fixture.lua").new( root, backend, @@ -10,7 +10,7 @@ function M.new(root, backend, version, api, format) format, false, false, - "portable_battle_auxiliary" + consumer or "portable_battle_auxiliary" ) f.start() local battle, game = assert(f.battle()), f.game diff --git a/tests/battle_grid/cases.lua b/tests/battle_grid/cases.lua new file mode 100644 index 000000000..8ec5ba52d --- /dev/null +++ b/tests/battle_grid/cases.lua @@ -0,0 +1,95 @@ +local M = {} +function M.run(f) + local T, e = f.T, f.exports + local rows = {} + if e.api.supports then + T.eq(e.api:supports("hooks", "battle.move_grid_navigation"), true, "native capability") + end + -- The exact same consumer drives every slot, direction and missing-cell + -- shape through the native controller. Expected values are contract data. + for _, mode in ipairs({ "grid", "list" }) do + e.mode(mode) + T.eq(f.grid(), mode == "grid", "public mode selection") + T.eq(e.seen.battle, f.handle, "original opaque battle/screen handle") + for count = 1, 4 do + f.count(count) + for index = 1, count do + for _, dir in ipairs({ "left", "right", "up", "down" }) do + f.index(index) + local expected = index + if mode == "list" then + if dir == "up" then + expected = index > 1 and index - 1 or count + end + if dir == "down" then + expected = index < count and index + 1 or 1 + end + else + if dir == "left" or dir == "right" then + expected = index % 2 == 1 and index + 1 or index - 1 + else + expected = index <= 2 and index + 2 or index - 2 + end + if expected > count then + expected = index + end + end + T.eq(f.press(dir), expected, "native " .. mode .. " " .. count .. "/" .. index .. " " .. dir) + rows[#rows + 1] = { mode = mode, count = count, index = index, direction = dir, target = f.index() } + end + end + end + end + f.count(4) + for _, row in ipairs({ {}, { value = false }, { value = 0 }, { value = "" }, { value = {} } }) do + local off = e.api.hooks:wrap("battle.move_grid_navigation", function() + return row.value + end, 100) + T.eq(f.grid(), false, "only literal true enables grid") + off() + end + e.mode("grid") + for _, after in ipairs({ false, true }) do + local off = e.api.hooks:wrap("battle.move_grid_navigation", function(next) + if after then + next() + end + error("intentional navigation predicate failure") + end, 100) + local calls = e.seen.calls + T.eq(f.grid(), true, "failure preserves downstream grid") + T.eq(e.seen.calls, calls + 1, "failure does not replay downstream") + off() + end + if f.generation == 3 then + f.double(true) + e.mode("grid") + for count = 1, 4 do + f.count(count) + for index = 1, count do + for _, dir in ipairs({ "left", "right", "up", "down" }) do + f.index(index) + local target = index + if dir == "left" and index % 2 == 0 then + target = index - 1 + elseif dir == "right" and index % 2 == 1 then + target = index + 1 + elseif dir == "up" and index >= 3 then + target = index - 2 + elseif dir == "down" and index <= 2 then + target = index + 2 + end + if target > count then + target = index + end + T.eq(f.press(dir), target, "native GBA doubles edge and sparse row") + rows[#rows + 1] = + { mode = "double", count = count, index = index, direction = dir, target = f.index() } + end + end + end + f.double(false) + end + return rows +end +return M diff --git a/tests/battle_grid/fixture.lua b/tests/battle_grid/fixture.lua new file mode 100644 index 000000000..395d9c1ab --- /dev/null +++ b/tests/battle_grid/fixture.lua @@ -0,0 +1,96 @@ +local M = {} +function M.new(root, backend, version, api, format) + local f = dofile(root .. "/tests/battle_auxiliary/shared.lua").new( + root, + backend, + version, + api, + format, + "portable_battle_grid" + ) + local b, game = f.battle(), f.game + local Ui, screen + if backend == "gen1" and f.generation == 3 then + Ui = require("src.core.game3.battle.ui") + Ui._mode, Ui._moveIndex = "moves", 1 + f.handle = b + f.grid = function() + return Ui.moveGridNavigation() + end + f.index = function(value) + if value then + Ui._moveIndex = value + end + return Ui._moveIndex + end + f.count = function(n) + b.player.mon.moves, b.player.mon.pp, b.player.mon.maxPp = {}, {}, {} + for i = 1, n do + b.player.mon.moves[i] = 33 + b.player.mon.pp[i] = 35 + b.player.mon.maxPp[i] = 35 + end + end + f.double = function(value) + b.double = value + if value then + f.update = function() + Ui.handleInput(game.input) + end + else + f.update = function() + require("src.core.game3.battle").update(0, game) + end + end + end + elseif backend == "gen1" and f.generation == 2 then + screen = game.stack:top() + screen.phase, screen.moveIndex = "moves", 1 + f.handle = screen + f.grid = function() + return screen:moveGridNavigation() + end + f.index = function(value) + if value then + screen.moveIndex = value + end + return screen.moveIndex + end + f.count = function(n) + b.player.moves = {} + for i = 1, n do + b.player.moves[i] = "SPLASH" + end + end + else + b.phase, b.moveIndex = "moveSelect", 1 + f.handle = b + f.grid = function() + return b:moveGridNavigation() + end + f.index = function(value) + if value then + b.moveIndex = value + end + return b.moveIndex + end + f.count = function(n) + local moves = {} + for i = 1, n do + moves[i] = { id = "FIX_TACKLE", pp = 35 } + end + b.player.curMoves, b.player.mon.moves = moves, moves + end + f.double = function(value) + b.double = value + end + end + f.count(4) + f.press = function(direction) + game.input.pressed = direction and { [direction] = true } or {} + f.update() + return f.index() + end + return f +end +return M diff --git a/tests/battle_grid/worker.lua b/tests/battle_grid/worker.lua new file mode 100644 index 000000000..f2dbeae4c --- /dev/null +++ b/tests/battle_grid/worker.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local f = dofile(root .. "/tests/battle_grid/fixture.lua").new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/battle_grid/cases.lua").run(f) +f.close() +print("BATTLE_GRID_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable battle.move_grid_navigation native contract") diff --git a/tests/engine/battle_grid_navigation.lua b/tests/engine/battle_grid_navigation.lua new file mode 100644 index 000000000..7818c1e63 --- /dev/null +++ b/tests/engine/battle_grid_navigation.lua @@ -0,0 +1,69 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/battle_grid/fixture.lua") +local Cases = dofile("tests/battle_grid/cases.lua") +local Version = require("src.core.GameVersion") +local Navigation = require("src.battle.MoveNavigation") +local old = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local b, e, game = f.battle(), f.exports, f.game + local native = Version.generation() >= 3 + T.eq(f.grid(), native, "native default is unchanged") + Cases.run(f) + if mode[1] >= 3 then + local off = e.api.hooks:wrap("battle.move_grid_navigation", function(next) + next() + return next() + end, 100) + local calls = e.seen.calls + T.eq(f.grid(), true, "API 3 retains downstream grid") + T.eq(e.seen.calls, calls + 1, "API 3 repeated next is cached") + off() + end + e.mode("grid") + b.phase, b.mimicMoves, b.mimicIndex = "mimicSelect", b.player.curMoves, 1 + f.press("down") + T.eq(b.mimicIndex, 3, "native Mimic copy menu uses shared grid") + e.mode("list") + b.mimicIndex = 1 + f.press("down") + T.eq(b.mimicIndex, 2, "native Mimic copy menu can use list") + b.phase = "moveSelect" + if Version.generation() <= 2 then + game.save.options.battleLayout = "wide" + e.mode("list") + local calls = e.seen.calls + T.eq(f.grid(), Version.generation() == 1, "Gen1 wide stays fixed; Gen2 wide can choose list") + if Version.generation() == 1 then + T.eq(e.seen.calls, calls, "Gen1 wide bypasses predicate") + end + game.save.options.battleLayout = "classic" + end + local calls = e.seen.calls + e.stop() + e.stop() + T.eq(f.grid(), native, "unsubscribe restores native mode") + T.eq(e.seen.calls, calls, "unsubscribe stops callbacks") + if Version.isGen3() then + f.double(true) + f.count(4) + f.index(1) + T.eq(f.press("left"), 1, "native GBA double clamps left without wrappers") + T.eq(f.press("up"), 1, "native GBA double clamps top without wrappers") + T.eq(f.press("right"), 2, "native GBA double moves to existing neighbour") + T.eq(f.press("down"), 4, "native GBA double moves to lower row") + f.double(false) + end + local input = setmetatable({}, { + __index = function() + error("empty grid read input") + end, + }) + T.eq(Navigation.navigate(b, 1, 0, input), nil, "empty grid does not read input") + f.close() + end +end +Version.set(old) +T.finish("portable battle.move_grid_navigation native controllers") diff --git a/tools/battle-grid-contract-check/check.py b/tools/battle-grid-contract-check/check.py new file mode 100644 index 000000000..194340128 --- /dev/null +++ b/tools/battle-grid-contract-check/check.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Run one unchanged battle-grid mod through both engines' native command menus.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/battle_grid/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("BATTLE_GRID_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("BATTLE_GRID_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "callback_format": "unified" if a.api >= 3 else + ("native" if backend == "gen1" else a.format), + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"battle navigation mismatch: {version}" + print(f"PASS {version}: matching native move-navigation behavior across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_battle_grid/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 5ed6353372be5f70fb7812780bfcea338c1815b9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:26:35 +0200 Subject: [PATCH 033/138] Add portable battle-party grid navigation and native controller contracts --- .github/workflows/party-grid-contract.yml | 51 ++++++++++ docs/portable-party-navigation.md | 68 +++++++++++++ examples/portable_party_grid/main.lua | 16 +++ examples/portable_party_grid/manifest.json | 1 + src/mods/Capabilities.lua | 2 +- src/mods/UnifiedCallbacks.lua | 3 +- src/ui/Gen3PartyMenu.lua | 14 +++ src/ui/PartyMenu.lua | 11 ++- src/ui/PartyNavigation.lua | 86 ++++++++++++++++ tests/engine/party_grid_navigation.lua | 101 +++++++++++++++++++ tests/party_grid/cases.lua | 65 ++++++++++++ tests/party_grid/fixture.lua | 109 +++++++++++++++++++++ tests/party_grid/worker.lua | 7 ++ tools/party-grid-contract-check/check.py | 60 ++++++++++++ 14 files changed, 591 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/party-grid-contract.yml create mode 100644 docs/portable-party-navigation.md create mode 100644 examples/portable_party_grid/main.lua create mode 100644 examples/portable_party_grid/manifest.json create mode 100644 src/ui/PartyNavigation.lua create mode 100644 tests/engine/party_grid_navigation.lua create mode 100644 tests/party_grid/cases.lua create mode 100644 tests/party_grid/fixture.lua create mode 100644 tests/party_grid/worker.lua create mode 100644 tools/party-grid-contract-check/check.py diff --git a/.github/workflows/party-grid-contract.yml b/.github/workflows/party-grid-contract.yml new file mode 100644 index 000000000..d686447ce --- /dev/null +++ b/.github/workflows/party-grid-contract.yml @@ -0,0 +1,51 @@ +name: portable ui.party.grid_navigation contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: party-grid-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native party navigation dispatch and SDK contracts + run: | + luajit tests/engine/party_grid_navigation.lua + luajit tests/engine/battle_auxiliary.lua + luajit tests/engine/trainer_preparation.lua + luajit tests/engine/trainer_preparation_native.lua + luajit tests/engine/trainer_party_scope.lua + - name: Validate pinned native Gen1 party navigation contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/unified_gen2_hooks.lua + luajit tests/modkit/cases/unified_gen3_hooks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/party-grid-contract + python3 tools/party-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/party-grid-contract/api1-native.json + python3 tools/party-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/party-grid-contract/api2-native.json + python3 tools/party-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/party-grid-contract/api2-unified.json + python3 tools/party-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/party-grid-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: party-grid-contract + path: /tmp/party-grid-contract + if-no-files-found: ignore diff --git a/docs/portable-party-navigation.md b/docs/portable-party-navigation.md new file mode 100644 index 000000000..e2014cbc5 --- /dev/null +++ b/docs/portable-party-navigation.md @@ -0,0 +1,68 @@ +# Portable battle-party navigation + +`ui.party.grid_navigation` lets a mod select the battle party controller's +native grid policy without replacing a screen's update method. The hook +receives the original opaque party-menu handle in every SDK format. Only +literal `true` opts in; `next(menu)` returns the normal `false` decision. +Removing the wrapper restores the host's existing navigation. + +```lua +local mod = ... +if mod:supports("hooks", "ui.party.grid_navigation") then + mod.hooks:wrap("ui.party.grid_navigation", function(next, menu) + if myOptions.usePartyGrid then return true end + return next(menu) + end) +end +``` + +The predicate is battle-only. Field menus, submenus, healing animations, +scripted no-input screens and swap/HP-transfer selection retain their existing +input ownership. Confirm and cancel use the native handlers. GBA also retains +its separate confirm/cancel processing when direction and A/B arrive together. +This hook changes navigation, not the menu art or battle legality checks. + +## Native policies + +GB/Gold-family opt-in navigation treats occupied party entries as a two-column, +row-major grid (`1 2 / 3 4 / 5 6`). Horizontal input chooses the peer in its +row; a missing peer retains the current slot. Vertical input wraps in the same +column, skipping missing cells. Native host menus still differ when the hook +returns false, including whether a Cancel row is present. + +GBA uses spatial navigation from the imported slot icon centers. Horizontal +input chooses the closest vertical peer in the requested column; vertical +input chooses the nearest entry in the same column. No vertical neighbor +selects Cancel. From Cancel, up selects the last occupied entry and down the +first. No horizontal neighbor retains the cursor. On old caches without +imported icon positions, the rendered panel positions provide the fallback. +The active battle's party scope determines occupied entries and Cancel; +internal cursor numbering remains engine-specific and opaque. + +The common single-layout fixture matches Gen1's actual GB, Gold and GBA +controllers. This does not add missing GBA double-layout artwork or require +identical internal screen objects. Other imported GBA geometries are navigated +according to their own positions. DS party screens retain their existing +controller and report this hook unsupported until a native seam is supplied. + +## Validation + +The unchanged `portable_party_grid` example is loaded through both real SDKs. +The checker drives native menu input for Red, Yellow, Gold, FireRed and Emerald, +covering one to six party members, every occupied slot, every direction and +GBA Cancel. Gen1 is pinned clean at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. API 1/2 native, Gen2 unified API 2 +and API 3 are compared; Gen1 API 2 has no optional unified format. + +The Gen2 suite additionally checks every GB/GBA host version in all four SDK +modes, native defaults, wrapper errors and removal, repeated API-3 `next`, +field/submenu/heal/no-input exclusions, imported and old-cache geometry, +empty parties, battle-local party scope and native input-handler routing. +These are ROM-free SDK/controller checks, not GPU pixel or full-playthrough +validation. + +```sh +luajit tests/engine/party_grid_navigation.lua +python3 tools/party-grid-contract-check/check.py --gen1 /path/to/gen1 \ + --api 3 --format unified --report /tmp/party-grid.json +``` diff --git a/examples/portable_party_grid/main.lua b/examples/portable_party_grid/main.lua new file mode 100644 index 000000000..9fc580cb6 --- /dev/null +++ b/examples/portable_party_grid/main.lua @@ -0,0 +1,16 @@ +local mod = ... +local enabled = false +local seen = { calls = 0 } +local stop = mod.hooks:wrap("ui.party.grid_navigation", function(next, menu) + seen.calls, seen.menu = seen.calls + 1, menu + if enabled then + return true + end + return next(menu) +end) +mod.exports.api = mod +mod.exports.seen = seen +mod.exports.grid = function(value) + enabled = value +end +mod.exports.stop = stop diff --git a/examples/portable_party_grid/manifest.json b/examples/portable_party_grid/manifest.json new file mode 100644 index 000000000..ae978eb57 --- /dev/null +++ b/examples/portable_party_grid/manifest.json @@ -0,0 +1 @@ +{"id": "portable_party_grid", "name": "Portable party navigation", "version": "1.0.0", "api": 3, "generations": [1, 2, 3], "entry": "main.lua", "games": ["all"], "affects_link": false} diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index b1b319721..52f2f930f 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -32,7 +32,7 @@ add("hooks", "12345", [[ add("hooks", "123", "battle.overlay ui.naming.grid ui.party.submenu ui.start_menu.items") add("hooks", "12", "ui.pc.items") add("hooks", "12345", "battle.status_hud_visible") -add("hooks", "123", "pokemon.level_visible") +add("hooks", "123", "pokemon.level_visible ui.party.grid_navigation") add("hooks", "12", "intro.oak_speech.build") add("events", "12345", [[ diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index a3836bd8a..c28a85dc8 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -16,7 +16,7 @@ end -- This is an audited boundary catalog, not a claim that every game executes -- every seam. The source-catalog test requires a decision for each new seam. C.hooks = names([[ - battle.menu_auxiliary battle.move_grid_navigation + battle.menu_auxiliary battle.move_grid_navigation ui.party.grid_navigation battle.accuracy battle.bottom_ui_visible battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate core.logic_speed core.update encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility @@ -40,6 +40,7 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["ui.party.grid_navigation"] = { false }, ["battle.move_grid_navigation"] = { false }, ["battle.menu_auxiliary"] = { false, "context" }, ["pokemon.level_visible"] = { "mon", "context" }, diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index f9c62320d..710527d08 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -48,6 +48,7 @@ -- bounce below is this port's, since the cartridge's is a sprite-callback -- rather than data. +local PartyNavigation = require("src.ui.PartyNavigation") local Assets = require("src.render.Assets") local Font = require("src.render.Font") local LevelDisplay = require("src.ui.LevelDisplay") @@ -1050,6 +1051,10 @@ function Gen3PartyMenu:cancel() self:close() end +function Gen3PartyMenu:gridNavigation() + return PartyNavigation.enabled(self) +end + function Gen3PartyMenu:update(dt) -- RECONSTRUCTED: every icon bounces, all the time, on the same beat. On -- the cartridge each one is an OBJ with its own callback; here it is one @@ -1094,6 +1099,15 @@ function Gen3PartyMenu:update(dt) return end + local grid = not (self.switchFrom or self.hpTransferFrom) + and self:gridNavigation() and PartyNavigation.spatial(self, input) + if grid then + self.index = grid + -- GBA handles confirm/cancel after navigation, including combined input. + if input:wasPressed("a") then self:confirm() + elseif input:wasPressed("b") then self:cancel() end + return + end local n = self:slots() if input:wasPressed("down") then self.index = self.index % n + 1 elseif input:wasPressed("up") then self.index = (self.index - 2) % n + 1 diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index cc22df2fa..268b2156a 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -9,6 +9,7 @@ -- opts.onCancel: fired when the menu closes without a pick (B) -- Pops itself on B. +local PartyNavigation = require("src.ui.PartyNavigation") local Assets = require("src.render.Assets") local Badges = require("src.inventory.Badges") local Font = require("src.render.Font") @@ -866,6 +867,10 @@ function PartyMenu:cancel() if self.onCancel then self.onCancel() end end +function PartyMenu:gridNavigation() + return PartyNavigation.enabled(self) +end + function PartyMenu:update(dt) -- icon animation counter; 320 = a whole cycle at every HP speed self.blink = ((self.blink or 0) + 1) % 320 @@ -906,7 +911,11 @@ function PartyMenu:update(dt) return end - if input:wasPressed("up") then + local grid = not (self.swapFrom or self.softboiledFrom) + and self:gridNavigation() and PartyNavigation.rows(self.index, #party, input) + if grid then + self.index = grid + elseif input:wasPressed("up") then self.index = self.index > 1 and self.index - 1 or math.max(1, #party) elseif input:wasPressed("down") then self.index = self.index < #party and self.index + 1 or 1 diff --git a/src/ui/PartyNavigation.lua b/src/ui/PartyNavigation.lua new file mode 100644 index 000000000..50faa39d1 --- /dev/null +++ b/src/ui/PartyNavigation.lua @@ -0,0 +1,86 @@ +-- The party handle remains native; only the navigation decision is shared. +local Runtime = require("src.mods.Runtime") +local M = {} +function M.enabled(menu) + if not menu.battle or not Runtime.wantsHook("ui.party.grid_navigation") then + return false + end + return Runtime.call("ui.party.grid_navigation", function() + return false + end, menu) == true +end +function M.rows(index, count, input) + if count < 1 then + return nil + end + local row, col = math.floor((index - 1) / 2), (index - 1) % 2 + if input:wasPressed("left") or input:wasPressed("right") then + local other = row * 2 + (1 - col) + 1 + return other <= count and other or index + end + local step = input:wasPressed("up") and -1 or input:wasPressed("down") and 1 + if not step then + return nil + end + local rows = math.ceil(count / 2) + for offset = 1, rows do + local other = ((row + step * offset) % rows) * 2 + col + 1 + if other <= count then + return other + end + end + return index +end +local function position(menu, i) + local panel = menu:panelFor(i) + if panel.icon then + return panel.icon.x, panel.icon.y + end + -- The fallback art draws a 32-pixel icon at the left of its panel. + return panel.x + 20, panel.y + panel.height / 2 +end +function M.spatial(menu, input) + local dir = input:wasPressed("up") and "up" + or input:wasPressed("down") and "down" + or input:wasPressed("left") and "left" + or input:wasPressed("right") and "right" + if not dir then + return nil + end + local count, cur = #menu:party(), menu.index + local cancel, target = count + 1, cur + if cur == cancel then + if count > 0 then + if dir == "up" then + target = count + elseif dir == "down" then + target = 1 + end + end + elseif cur >= 1 and cur <= count then + local x, y = position(menu, cur) + local best + for i = 1, count do + if i ~= cur then + local tx, ty = position(menu, i) + local dx, dy = tx - x, ty - y + local distance + if + (dir == "left" and dx < 0) + or (dir == "right" and dx > 0) + or (dx == 0 and ((dir == "up" and dy < 0) or (dir == "down" and dy > 0))) + then + distance = math.abs(dy) + end + if distance and (not best or distance < best) then + target, best = i, distance + end + end + end + if not best and (dir == "up" or dir == "down") then + target = cancel + end + end + return target +end +return M diff --git a/tests/engine/party_grid_navigation.lua b/tests/engine/party_grid_navigation.lua new file mode 100644 index 000000000..cf11024af --- /dev/null +++ b/tests/engine/party_grid_navigation.lua @@ -0,0 +1,101 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local Fixture = dofile("tests/party_grid/fixture.lua") +local Cases = dofile("tests/party_grid/cases.lua") +local old = Version.get() +for _, version in ipairs(Version.ORDER) do + Version.set(version) + if Version.generation() <= 3 then + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + Cases.run(f) + f.count(6) + f.index(1) + T.eq(f.press("right"), f.generation == 3 and 2 or 1, "unwrapped native horizontal navigation") + f.index(1) + T.eq(f.press("up"), f.generation == 3 and "cancel" or 6, "unwrapped native vertical navigation") + f.index(1) + local menu, api, game = f.menu, f.exports.api, f.game + local calls = 0 + local off = api.hooks:wrap("ui.party.grid_navigation", function() + calls = calls + 1 + return true + end) + if mode[1] >= 3 then + local outer = api.hooks:wrap("ui.party.grid_navigation", function(next) + next() + return next() + end, 100) + T.eq(f.grid(), true, "API 3 retains grid decision") + T.eq(calls, 1, "API 3 repeated next is cached") + outer() + end + f.count(5) + f.index(1) + menu.submenu, menu.subItems, menu.subIndex = + { { action = "CANCEL" }, { action = "CANCEL" } }, { "A", "B" }, 1 + local before = calls + f.press("down") + T.eq(f.index(), 1, "submenu owns cursor input") + T.eq(menu.subIndex, 2, "submenu uses its own navigation") + T.eq(calls, before, "submenu bypasses grid predicate") + menu.submenu = nil + local mon = game.save.party[1] + menu.heal = { mon = mon, shown = 0 } + before = calls + f.press("right") + T.eq(f.index(), 1, "healing animation owns input") + T.eq(calls, before, "healing bypasses grid predicate") + menu.heal = nil + if f.generation == 3 then + menu.noInput = true + f.press("right") + T.eq(f.index(), 1, "scripted no-input screen retains cursor") + T.eq(calls, before, "no-input bypasses predicate") + menu.noInput = false + local record = game.data.constants.gen3PartyMenu + record.slots[2].icon.y, record.slots[3].icon.y = 40, 80 + T.eq(f.press("right"), 2, "imported sprite coordinates determine spatial peer") + game.data.constants.gen3PartyMenu = nil + f.index(1) + T.eq(f.press("right"), 3, "old-cache fallback uses rendered panel positions") + game.data.constants.gen3PartyMenu = record + f.count(0) + f.index("cancel") + T.eq(f.press("up"), "cancel", "empty GBA party retains cancel") + else + f.count(0) + f.index(1) + T.eq(f.press("right"), 1, "empty GB party retains cursor") + end + f.count(6) + f.index(1) + local confirm, cancel = 0, 0 + local oldConfirm, oldCancel = menu.confirm, menu.cancel + menu.confirm = function() + confirm = confirm + 1 + end + menu.cancel = function() + cancel = cancel + 1 + end + f.press("a") + f.press("b") + T.eq(confirm, 1, "native confirm handler remains reachable") + T.eq(cancel, 1, "native cancel handler remains reachable") + f.press({ right = true, a = true }) + T.eq(confirm, f.generation == 3 and 2 or 1, "combined input retains generation-specific priority") + menu.confirm, menu.cancel = oldConfirm, oldCancel + off() + f.close() + end + else + T.eq( + require("src.mods.Capabilities").supports("hooks", "ui.party.grid_navigation", version), + false, + "DS has no shared party producer yet" + ) + end +end +Version.set(old) +T.finish("portable ui.party.grid_navigation native controllers") diff --git a/tests/party_grid/cases.lua b/tests/party_grid/cases.lua new file mode 100644 index 000000000..e21743d26 --- /dev/null +++ b/tests/party_grid/cases.lua @@ -0,0 +1,65 @@ +local M = {} +function M.run(f) + local T, e = f.T, f.exports + if e.api.supports then + T.eq(e.api:supports("hooks", "ui.party.grid_navigation"), true, "native capability") + end + T.eq(f.grid(), false, "native party navigation remains the default") + e.grid(true) + T.eq(f.grid(), true, "opt-in grid") + T.eq(e.seen.menu, f.menu, "original opaque menu") + local rows = {} + for count = 1, 6 do + f.count(count) + for index = 1, count + (f.generation == 3 and 1 or 0) do + local slot = index > count and "cancel" or index + for _, dir in ipairs({ "left", "right", "up", "down" }) do + f.index(slot) + local target = f.press(dir) + T.check( + target == "cancel" or (type(target) == "number" and target >= 1 and target <= count), + "native target is occupied or cancel" + ) + rows[#rows + 1] = { count = count, index = slot, direction = dir, target = target } + end + end + end + f.count(6) + f.index(1) + T.eq(f.press("right"), f.generation == 3 and 3 or 2, "lead's horizontal peer") + f.index(1) + T.eq(f.press("up"), f.generation == 3 and "cancel" or 5, "top edge follows native grid policy") + f.count(5) + f.index(5) + T.eq(f.press("down"), f.generation == 3 and "cancel" or 1, "odd final row") + for _, row in ipairs({ {}, { value = false }, { value = 0 }, { value = "" }, { value = {} } }) do + local off = e.api.hooks:wrap("ui.party.grid_navigation", function() + return row.value + end, 100) + T.eq(f.grid(), false, "only literal true enables grid") + off() + end + for _, after in ipairs({ false, true }) do + local off = e.api.hooks:wrap("ui.party.grid_navigation", function(next) + if after then + next() + end + error("intentional party navigation predicate failure") + end, 100) + local calls = e.seen.calls + T.eq(f.grid(), true, "failure preserves downstream decision") + T.eq(e.seen.calls, calls + 1, "failure does not replay downstream") + off() + end + local calls = e.seen.calls + f.battleMode(false) + T.eq(f.grid(), false, "field party does not opt in") + T.eq(e.seen.calls, calls, "field party bypasses battle predicate") + f.battleMode(true) + e.stop() + e.stop() + T.eq(f.grid(), false, "unsubscribe restores native controller") + T.eq(e.seen.calls, calls, "unsubscribe stops consumer") + return rows +end +return M diff --git a/tests/party_grid/fixture.lua b/tests/party_grid/fixture.lua new file mode 100644 index 000000000..4fbd83c25 --- /dev/null +++ b/tests/party_grid/fixture.lua @@ -0,0 +1,109 @@ +local M = {} +function M.new(root, backend, version, api, format) + local f = dofile(root .. "/tests/trainer_preparation/fixture.lua").new( + root, + backend, + version, + api, + format, + false, + false, + "portable_party_grid" + ) + f.start() + local game, battle, party = f.game, f.battle(), f.party() + local menu + local original = party[1] + if backend == "gen1" and f.generation == 3 then + menu = require("src.ui.game3.party_menu") + menu.show(party, nil, { session = game.session, battle = battle, mode = "battle_switch" }) + f.index = function(value) + if value then + menu.cursor = value == "cancel" and 7 or value + end + return menu.cursor == 7 and "cancel" or menu.cursor + end + f.count = function(n) + menu._party = {} + for i = 1, n do + menu._party[i] = original + end + end + local oldClose = f.close + f.close = function() + menu.open = false + oldClose() + end + f.update = function() + menu.handleInput(game.input) + end + f.battleMode = function(value) + menu._battle = value and battle or nil + end + elseif backend == "gen1" and f.generation == 2 then + menu = require("src.ui.gen2.PartyMenu").new(game, { party = party, battle = true }) + f.count = function(n) + menu.party = {} + for i = 1, n do + menu.party[i] = original + end + end + else + local class = f.generation == 3 and "src.ui.Gen3PartyMenu" or "src.ui.PartyMenu" + menu = require(class).new(game, { battle = battle, party = party }) + f.count = function(n) + local list = {} + for i = 1, n do + list[i] = original + end + if f.generation ~= 3 then + menu.party = list + end + battle.playerParty = list + end + if f.generation == 3 then + game.data.constants = game.data.constants or {} + local slots = {} + for i = 1, 6 do + slots[i] = { + window = { x = 0, y = 0, width = 80, height = 32 }, + icon = { x = i == 1 and 16 or 104, y = i == 1 and 40 or 18 + 24 * (i - 2) }, + } + end + game.data.constants.gen3PartyMenu = { slots = slots } + end + end + if not f.index then + f.index = function(value) + if value then + menu.index = value == "cancel" and #menu:party() + 1 or value + end + if f.generation == 3 and menu.index == #menu:party() + 1 then + return "cancel" + end + return menu.index + end + end + f.battleMode = f.battleMode or function(value) + menu.battle = value and battle or nil + end + f.update = f.update or function() + menu:update(0) + end + f.menu = menu + f.press = function(direction) + game.input.pressed = type(direction) == "table" and direction + or (direction and { [direction] = true } or {}) + f.update() + return f.index() + end + f.grid = function() + if backend == "gen1" and f.generation == 3 then + return menu.gridNavigation() + end + return menu:gridNavigation() + end + f.count(6) + return f +end +return M diff --git a/tests/party_grid/worker.lua b/tests/party_grid/worker.lua new file mode 100644 index 000000000..2e46e6fa5 --- /dev/null +++ b/tests/party_grid/worker.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local f = dofile(root .. "/tests/party_grid/fixture.lua").new(root, backend, version, tonumber(api), format) +local rows = dofile(root .. "/tests/party_grid/cases.lua").run(f) +f.close() +print("PARTY_GRID_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable ui.party.grid_navigation native controllers") diff --git a/tools/party-grid-contract-check/check.py b/tools/party-grid-contract-check/check.py new file mode 100644 index 000000000..b7cddeca7 --- /dev/null +++ b/tools/party-grid-contract-check/check.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Run one unchanged party-grid mod through both engines' native party controllers.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/party_grid/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("PARTY_GRID_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("PARTY_GRID_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "callback_format": "unified" if a.api >= 3 else + ("native" if backend == "gen1" else a.format), + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"party navigation mismatch: {version}" + print(f"PASS {version}: matching native party-navigation behavior across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_party_grid/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From c4152a50219af8256b0df849e3d21fa12030fe8c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:45:40 +0200 Subject: [PATCH 034/138] Add API-3 playthrough storage with shared persistence contracts --- .github/workflows/storage-contract.yml | 44 ++ docs/api3-storage.md | 90 ++++ examples/portable_storage/main.lua | 17 + examples/portable_storage/manifest.json | 1 + src/core/PlaythroughIdentity.lua | 194 +++++++++ src/core/SaveData.lua | 46 +- src/mods/Api3.lua | 6 + src/mods/PlaythroughStorage.lua | 472 +++++++++++++++++++++ tests/engine/api3_playthrough_storage.lua | 138 ++++++ tests/engine/api3_storage_transactions.lua | 312 ++++++++++++++ tests/playthrough_storage/cases.lua | 96 +++++ tests/playthrough_storage/fixture.lua | 67 +++ tests/playthrough_storage/lifecycle.lua | 27 ++ tests/playthrough_storage/worker.lua | 11 + tools/storage-contract-check/check.py | 55 +++ 15 files changed, 1570 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/storage-contract.yml create mode 100644 docs/api3-storage.md create mode 100644 examples/portable_storage/main.lua create mode 100644 examples/portable_storage/manifest.json create mode 100644 src/core/PlaythroughIdentity.lua create mode 100644 src/mods/PlaythroughStorage.lua create mode 100644 tests/engine/api3_playthrough_storage.lua create mode 100644 tests/engine/api3_storage_transactions.lua create mode 100644 tests/playthrough_storage/cases.lua create mode 100644 tests/playthrough_storage/fixture.lua create mode 100644 tests/playthrough_storage/lifecycle.lua create mode 100644 tests/playthrough_storage/worker.lua create mode 100644 tools/storage-contract-check/check.py diff --git a/.github/workflows/storage-contract.yml b/.github/workflows/storage-contract.yml new file mode 100644 index 000000000..cb3634656 --- /dev/null +++ b/.github/workflows/storage-contract.yml @@ -0,0 +1,44 @@ +name: portable API-3 storage contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: storage-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check Gen2 native storage, save lifecycle and legacy cache + run: | + luajit tests/engine/api3_storage_transactions.lua + luajit tests/engine/api3_playthrough_storage.lua + luajit tests/engine/save_slots.lua + luajit tests/mod_storage_tests.lua + - name: Validate pinned Gen1 storage and identity contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/storage.lua + luajit tests/engine/playthrough_identity.lua + - name: Compare unchanged storage consumer and native save lifecycle + run: | + mkdir -p /tmp/storage-contract + python3 tools/storage-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/storage-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: storage-contract + path: /tmp/storage-contract + if-no-files-found: ignore diff --git a/docs/api3-storage.md b/docs/api3-storage.md new file mode 100644 index 000000000..2614b295a --- /dev/null +++ b/docs/api3-storage.md @@ -0,0 +1,90 @@ +# API-3 playthrough storage + +API 3 uses the Gen1Recomp public `mod.storage` contract: data-only records and +opaque bytes belong to a mod, a game version and an opaque playthrough identity. +The handle contains only `context`, `selected`, `read`, `write`, `readBytes`, +`writeBytes`, `list` and `delete`; private paths and mutable engine objects are +not exposed. The transactional implementation follows the pinned Gen1Recomp +`Storage.lua` at `79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. + +```lua +local mod = ... +local context, code = mod.storage:context(mod.game) +if context then + local state, reason = mod.storage:read(mod.game, "progress/visited") + if not state and reason == "not_found" then state = { maps = {} } end + -- Other errors require handling; they do not mean an empty namespace. +end +``` + +`context(game)` returns `engineVersion`, `gameVersion` and `playthroughId`. +The identity is opaque, stable across normal save/load and independent of +slot names. A new game has a distinct requested identity; duplicate imported +slots receive distinct scopes. The engine allocates it lazily without consuming +gameplay RNG. Unused vanilla saves remain identity-free. Legacy backfill writes +routing metadata to options; requesting storage alone does not rewrite normal +progress. The next ordinary save stamps the identity into its metadata. + +Records must be serializable data tables. Bytes are strings, including empty +strings and NULs, up to 512 MiB per key. Keys use nonempty letters/numbers/dash/ +underscore segments separated by `/`. Absolute paths, `..`, dots, empty segments +and backslashes are rejected. A key cannot change between records and bytes +until explicitly deleted. Lists are sorted, de-duplicated logical keys; a prefix +selects an exact key or a directory. Reads return decoded copies. + +Writes stage and verify data, retain a verified backup, replace and verify the +main copy, then remove the staging file. Reads recover from valid staging or +backup data. Reads return `nil, code, message` on failure; writes/deletes return +`false, code, message`. Important codes include `not_found`, `invalid_key`, +`encode_failed`, `type_conflict`, `type_mismatch`, `write_failed`, `verify_failed`, +`storage_unavailable` and `not_in_playthrough`. Recovery is best-effort; this is +not a multi-key transaction or a concurrent-writer database. + +## Selected storage at the title + +`mod.storage:selected(mod.game)` is available only while a native TitleState is +in the stack. It resolves the engine-selected durable playthrough without +allocating a new identity. The returned handle binds that scope: + +```lua +local selected, reason = mod.storage:selected(mod.game) +if selected then + local context = selected:context() -- may include normalSavedAt + local state = selected:read("progress/visited") -- no game argument +end +``` + +The title handle offers the same read/write/list/delete methods without a game +argument. Returned contexts are detached copies; callers cannot reroute the +bound handle by changing them. Init-time reads against a fresh boot skeleton +cannot replace an existing real save's selection. A successfully committed +normal save is authoritative if a later options update fails. Merely browsing +a fresh title without durable state reports `no_selected_playthrough`. + +## Existing Gen2 mods and cache data + +API 1/2 retain the existing `src.mods.Storage` behavior and `modstorage/` data +unchanged. API 3 uses a separate playthrough namespace. No automatic copy guesses +which save owns an old installation-wide payload. ROM-derived, reproducible +assets belong in the existing `mod.cache` service. Authors upgrading a legacy +mod must explicitly export/migrate authored bulk state while using its old API, +or rebuild derived caches through their dedicated cache/import service. Changing +the manifest to API 3 does not automatically assign old shared records to a save. + +## Verification + +The unchanged API-3 example runs through real SDKs for the eight games both +engines support: Red/Blue/Yellow, Gold/Silver/Crystal, FireRed and Emerald. +Shared checks cover data/byte transactions, isolation, recovery/failures, +listing, title handles and native New Game/save/load. Gen2 additionally tests +all fifteen supported hosts and slot promotion, boot skeletons, duplicate slot +imports, failed saves, deletion, legacy API retention and RNG independence. +These are ROM-free persistence/controller contracts; native cartridge smoke +tests are separate and do not establish full playthrough compatibility. + +```sh +luajit tests/engine/api3_storage_transactions.lua +luajit tests/engine/api3_playthrough_storage.lua +python3 tools/storage-contract-check/check.py --gen1 /path/to/gen1 \ + --report /tmp/storage-contract.json +``` diff --git a/examples/portable_storage/main.lua b/examples/portable_storage/main.lua new file mode 100644 index 000000000..35d8e0373 --- /dev/null +++ b/examples/portable_storage/main.lua @@ -0,0 +1,17 @@ +local mod = ... +local storage = mod.storage +for _, method in ipairs({ + "context", + "selected", + "read", + "write", + "readBytes", + "writeBytes", + "list", + "delete", +}) do + local name = method + mod.exports[name] = function(...) + return storage[name](storage, ...) + end +end diff --git a/examples/portable_storage/manifest.json b/examples/portable_storage/manifest.json new file mode 100644 index 000000000..4c01872f9 --- /dev/null +++ b/examples/portable_storage/manifest.json @@ -0,0 +1 @@ +{"id": "portable_storage", "name": "Portable playthrough storage", "version": "1.0.0", "api": 3, "games": ["all"], "entry": "main.lua"} diff --git a/src/core/PlaythroughIdentity.lua b/src/core/PlaythroughIdentity.lua new file mode 100644 index 000000000..8aac1f53e --- /dev/null +++ b/src/core/PlaythroughIdentity.lua @@ -0,0 +1,194 @@ +-- Opaque storage identity. Never consumes gameplay RNG or rewrites progress +-- merely because a mod requests a namespace. Slot routing is engine-owned. +local Version = require("src.core.GameVersion") +local Serializer = require("src.core.SaveSerializer") +local copy = require("src.mods.Merge").deepCopy +local M = {} +local fresh = setmetatable({}, { __mode = "k" }) +local sequence = 0 +local function saves() + return require("src.core.SaveData") +end +local function valid(id) + return type(id) == "string" and id:match("^[%w_-]+$") ~= nil +end +function M.markFresh(save) + if type(save) == "table" then + fresh[save] = true + end +end +function M.newId() + sequence = sequence + 1 + local address = tostring({}):match("0x(%x+)") or "0" + local function word(n) + return math.floor(tonumber(n) or 0) % 4294967296 + end + return ("%08x%08x%08x%08x"):format( + word(os.time()), + word(os.clock() * 1000000), + word(tonumber(address:sub(-8), 16)), + word(sequence) + ) +end +local function routing(version, fs) + local opts = saves().loadOptions(fs) + local reg = opts.saveSlots and opts.saveSlots[version] + local slot = saves()._storageSlot(version, fs) + or (reg and (reg.active or (reg.list and reg.list[1]))) + or "legacy" + if not valid(slot) then + slot = "legacy" + end + local ids = opts.playthroughIds and opts.playthroughIds[version] + return opts, slot, ids and ids[slot] +end +local function normalSave(version, slot, fs) + local main = slot == "legacy" and ("save" .. Version.saveSuffix(version) .. ".lua") + or ("saves/" .. version .. "/" .. slot .. ".lua") + for _, path in ipairs({ main, main .. ".tmp", main .. ".bak" }) do + local body = fs.read(path) + local data = type(body) == "string" and Serializer.decode(body) + if type(data) == "table" and data.version == version then + return data + end + end +end +local function bind(opts, version, slot, id, fs) + opts.playthroughIds = opts.playthroughIds or {} + opts.playthroughIds[version] = opts.playthroughIds[version] or {} + if opts.playthroughIds[version][slot] == id then + return true + end + opts.playthroughIds[version][slot] = id + return saves().saveOptions(opts, fs) ~= nil +end +function M.ensure(save, fs) + if type(save) ~= "table" or not Version.info(save.version) then + return nil + end + save.meta = type(save.meta) == "table" and save.meta or {} + if valid(save.meta.playthroughId) then + return save.meta.playthroughId + end + local opts, slot, mapped = routing(save.version, fs) + local id = not fresh[save] and valid(mapped) and mapped or M.newId() + -- Boot/New Game skeletons must not strand an already-bound real save. + if not (fresh[save] and valid(mapped)) and not bind(opts, save.version, slot, id, fs) then + return nil + end + save.meta.playthroughId = id + return id +end +function M.inherit(save, fs) + if type(save) ~= "table" or fresh[save] or not Version.info(save.version) then + return + end + if save.meta and valid(save.meta.playthroughId) then + return + end + local _, _, id = routing(save.version, fs) + if valid(id) then + save.meta = type(save.meta) == "table" and save.meta or {} + save.meta.playthroughId = id + end +end +function M.selected(save, fs) + if type(save) ~= "table" then + return nil, "not_in_playthrough", "No selected playthrough is available." + end + if not Version.info(save.version) then + return nil, "unknown_game", "The selected game version is unavailable." + end + local id = save.meta and save.meta.playthroughId + if not fresh[save] and valid(id) then + return id + end + local _, slot, mapped = routing(save.version, fs) + -- A committed normal save is authoritative even if its options update + -- failed, or the registry was recovered from an older backup. + local normal = normalSave(save.version, slot, fs) + local stamped = normal and normal.meta and normal.meta.playthroughId + if valid(stamped) then + return stamped + end + if valid(mapped) then + return mapped + end + return nil, "no_selected_playthrough", "The selected playthrough has no durable tool state." +end +function M.remember(save, fs, slot, version) + local id = save.meta and save.meta.playthroughId + if not valid(id) then + fresh[save] = nil + return true + end + version = version or save.version + local opts, active = routing(version, fs) + local ok = bind(opts, version, slot or active, id, fs) + if ok then + fresh[save] = nil + if save.options then + save.options.playthroughIds = copy(opts.playthroughIds) + end + end + return ok +end +function M.preserveOptions(opts, fs) + local stored = saves().loadOptions(fs) + -- A live options snapshot can predate lazy legacy-to-slot promotion. + opts.playthroughIds = copy(stored.playthroughIds) + opts.saveSlots = copy(stored.saveSlots) +end +function M.prepareSlot(save, version, slot, fs) + local id = save.meta and save.meta.playthroughId + if not valid(id) then + return save + end + local opts = saves().loadOptions(fs) + local ids = opts.playthroughIds and opts.playthroughIds[version] + local collision = false + for other, used in pairs(ids or {}) do + if other ~= slot and used == id then + collision = true + end + end + local reg = opts.saveSlots and opts.saveSlots[version] + for _, other in ipairs(reg and reg.list or {}) do + if other ~= slot then + local base = "saves/" .. version .. "/" .. other .. ".lua" + for _, path in ipairs({ base, base .. ".tmp", base .. ".bak" }) do + local bytes = fs.read(path) + local data = type(bytes) == "string" and Serializer.decode(bytes) + if type(data) == "table" and data.meta and data.meta.playthroughId == id then + collision = true + end + end + end + end + if collision then + save = copy(save) + save.meta.playthroughId = M.newId() + end + return save +end +function M.normalInfo(save, fs) + local id, code, message = M.selected(save, fs) + if not id then + return nil, code, message + end + local _, slot = routing(save.version, fs) + local normal = normalSave(save.version, slot, fs) + if + not normal + or normal.version ~= save.version + or (normal.meta and normal.meta.playthroughId and normal.meta.playthroughId ~= id) + then + return { exists = false } + end + local at = normal.meta and normal.meta.savedAt + if type(at) ~= "number" or at < 0 or at ~= at or at == math.huge then + at = nil + end + return { exists = true, savedAt = at } +end +return M diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 2bfe53530..8fa8afde8 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -404,8 +404,8 @@ end -- Public seam for mods (Stadium import, HD zip install, custom pickers). -- Same routing as options/saves: injected fs, then portable.io, then LOVE. -function SaveData.persistenceFs() - return persistFs(love and love.filesystem) +function SaveData.persistenceFs(injectedFs) + return persistFs(injectedFs or (love and love.filesystem)) end -- Port + original Options menu defaults. Missing keys on load are filled @@ -853,6 +853,8 @@ local function tryMigrateLegacy(version, fs) local opts = SaveData.loadOptions(fs) opts.saveSlots = opts.saveSlots or {} opts.saveSlots[version] = { list = { id }, active = id } + local ids = opts.playthroughIds and opts.playthroughIds[version] + if ids and ids.legacy then ids[id], ids.legacy = ids.legacy, nil end SaveData.saveOptions(opts, fs) return id end @@ -1131,11 +1133,14 @@ function SaveData.writeSlot(version, slotId, saveTable) if type(slotId) ~= "string" then return false, "missing slot id" end if type(saveTable) ~= "table" then return false, "missing save table" end local main, bak = slotNames(version, slotId) - local encoded = SaveSerializer.encode(saveTable) local fs = persistFs(nil) + saveTable = require("src.core.PlaythroughIdentity").prepareSlot(saveTable, version, slotId, fs) + local encoded = SaveSerializer.encode(saveTable) ensureParentDir(fs, main) -- atomic, previous good copy kept (SaveData.commitSave) - return SaveData.commitSave(fs, main, bak, encoded, saveTable) + local ok, err = SaveData.commitSave(fs, main, bak, encoded, saveTable) + if ok then require("src.core.PlaythroughIdentity").remember(saveTable, fs, slotId, version) end + return ok, err end -- Delete a registered slot: remove its main/.bak/.tmp files, drop it from the @@ -1174,6 +1179,8 @@ function SaveData.deleteSlot(version, slotId) end end + local ids = opts.playthroughIds and opts.playthroughIds[version] + if ids then ids[slotId] = nil end table.remove(reg.list, idx) if reg.names then reg.names[slotId] = nil end if reg.active == slotId then @@ -1194,6 +1201,14 @@ function SaveData.resetSlotState() for k in pairs(slotsChecked) do slotsChecked[k] = nil end end +-- Internal identity routing follows this process's resolved save destination. +-- A different launcher window can change options without moving this game. +function SaveData._storageSlot(version, fs) + if fs == persistFs(nil) and slotsChecked[version] then + return activeSlotCache[version] or "legacy" + end +end + -- ------- meta -- the version/engine/mod-set stamp every v2 save carries; mods is the @@ -1216,6 +1231,7 @@ function SaveData.buildMeta(mods, previous) engine = Version.engine, savedAt = os.time(), mods = list, + playthroughId = type(previous) == "table" and previous.playthroughId or nil, } end @@ -1719,7 +1735,11 @@ function SaveData.save(data, mods) -- write to the file matching this save's own version, not just the active -- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua local FILENAME, BACKUP_FILENAME = saveNames(data.version) + local identity = require("src.core.PlaythroughIdentity") + local fs = persistFs(nil) + identity.inherit(data, fs) if data.options then + identity.preserveOptions(data.options, fs) SaveData.saveOptions(data.options) end if mods ~= nil or data.meta == nil then @@ -1732,7 +1752,6 @@ function SaveData.save(data, mods) end return SaveSerializer.encode(gameOnly) end - local fs = persistFs(nil) -- the active slot may live in saves//, which must exist before -- the .tmp/.bak/main writes land (a no-op for the flat legacy path) ensureParentDir(fs, FILENAME) @@ -1741,6 +1760,7 @@ function SaveData.save(data, mods) Logger.error("save failed: %s", tostring(err)) return false end + identity.remember(data, fs) markOpen(fs, FILENAME) Logger.info("saved game") return true @@ -1802,6 +1822,7 @@ function SaveData.load(version) loadedWalkerKeys[FILENAME] = SaveSafety.walkerKeys(data) markOpen(fs, FILENAME) SaveData.runMigrations(data) + require("src.core.PlaythroughIdentity").inherit(data, fs) data.options = SaveData.optionsFor(version) Logger.info("loaded save") return data, recovered @@ -2570,7 +2591,20 @@ function SaveData.newGame(boot) -- -- Callers that ARE starting a playthrough pass nothing and are unaffected. if boot.noHooks then return save end - return Runtime.call("save.new_game", function(s) return s end, save) + save = Runtime.call("save.new_game", function(s) return s end, save) + require("src.core.PlaythroughIdentity").markFresh(save) + return save +end + +-- Engine-owned persistence scope used by the portable storage facade. +function SaveData.ensurePlaythroughId(save, fs) + return require("src.core.PlaythroughIdentity").ensure(save, persistFs(fs)) +end +function SaveData.selectedPlaythroughId(save, fs) + return require("src.core.PlaythroughIdentity").selected(save, persistFs(fs)) +end +function SaveData.selectedNormalSaveInfo(save, fs) + return require("src.core.PlaythroughIdentity").normalInfo(save, persistFs(fs)) end return SaveData diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index f5de4a039..984a886bf 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -29,6 +29,12 @@ local function block(gen) } end function A.install(api, loader, readContext, overviewContext, engineRequire) + local storage = require("src.mods.PlaythroughStorage").new(api.id, loader.fs) + api.storage = {} + for _, method in ipairs({ "context", "selected", "read", "write", "readBytes", "writeBytes", "list", "delete" }) do + local name = method + api.storage[name] = function(_, ...) return storage[name](storage, ...) end + end local gen = api.generation local supportedGeneration = gen >= 1 and gen <= 3 -- Passthrough registries keep native shapes but expose the same handle metadata. diff --git a/src/mods/PlaythroughStorage.lua b/src/mods/PlaythroughStorage.lua new file mode 100644 index 000000000..6410bb00b --- /dev/null +++ b/src/mods/PlaythroughStorage.lua @@ -0,0 +1,472 @@ +-- Data-only and opaque-byte per-mod persistence, scoped by game version and +-- opaque playthrough. +-- API-3 contract adapted from Gen1Recomp Storage at 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36. +-- Engine-private; the public handle contains only bound facade methods. + +local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") +local Version = require("src.core.Version") + +local Storage = {} +Storage.__index = Storage +Storage.MAX_BYTES = 512 * 1024 * 1024 + +local ROOT = "mod_storage" + +local function failure(code, message) + return nil, code, message +end + +local function validSegment(value) + return type(value) == "string" and value ~= "" and value:match("^[%w_-]+$") ~= nil +end + +local function validKey(key, allowEmpty) + if type(key) ~= "string" or (key == "" and not allowEmpty) then + return false + end + if key == "" then + return true + end + if key:sub(1, 1) == "/" or key:sub(-1) == "/" or key:find("//", 1, true) then + return false + end + for segment in key:gmatch("[^/]+") do + if not validSegment(segment) then + return false + end + end + return true +end + +local function ensureParent(fs, path) + local dir = path:match("^(.*)/[^/]+$") + if dir and fs.createDirectory then + fs.createDirectory(dir) + end +end + +local function remove(fs, path) + if fs.remove then + fs.remove(path) + end +end + +local function decodeAt(fs, path) + if not (fs.getInfo and fs.getInfo(path)) then + return nil + end + local body = fs.read and fs.read(path) + if type(body) ~= "string" then + return nil + end + local data = SaveSerializer.decode(body) + if not data then + return nil + end + return data, body +end + +local function readOpaqueAt(fs, path) + if not (fs.getInfo and fs.getInfo(path)) then + return nil + end + local body = fs.read and fs.read(path) + if type(body) ~= "string" then + return nil + end + return body +end + +local function hasAny(fs, paths) + for _, path in ipairs(paths) do + if fs.getInfo(path) then + return true + end + end + return false +end + +function Storage.new(modId, fs) + assert(validSegment(modId), "Storage.new needs a safe mod id") + return setmetatable({ modId = modId, injectedFs = fs }, Storage) +end + +function Storage:_scope(game) + local save = game and game.save + local meta = save and save.meta + local version = save and save.version + if not (save and validSegment(version)) then + return failure("not_in_playthrough", "Storage is available only inside an identified playthrough.") + end + local fs = SaveData.persistenceFs(self.injectedFs) + if not (fs and fs.read and fs.write and fs.getInfo) then + return failure("storage_unavailable", "The persistence backend is unavailable.") + end + local playthroughId = meta and meta.playthroughId + if not validSegment(playthroughId) then + playthroughId = SaveData.ensurePlaythroughId(save, self.injectedFs) + end + if not validSegment(playthroughId) then + return failure("not_in_playthrough", "Storage could not identify the active playthrough.") + end + local base = table.concat({ ROOT, version, playthroughId, self.modId }, "/") + return { gameVersion = version, playthroughId = playthroughId, base = base, fs = fs } +end + +local function isTitleSession(game) + local states = game and game.stack and game.stack.states + if type(states) ~= "table" then + return false + end + for _, state in ipairs(states) do + if type(state) == "table" and state.screenId == "TitleState" then + return true + end + end + return false +end + +-- Bind this mod only to the engine-selected existing playthrough while the +-- title session is active. Unlike _scope this must never allocate an identity: +-- browsing history before the first normal SAVE is a read of durable state, +-- not the start of a New Game. The returned facade closes over its private +-- proxy game, so callers cannot substitute another playthrough id or path. +function Storage:selected(game) + if not isTitleSession(game) then + return failure("not_at_title", "Selected playthrough storage is available only from the title session.") + end + local save = game and game.save + local version = save and save.version + if not validSegment(version) then + return failure("not_in_playthrough", "The title session has no selected game version.") + end + local playthroughId, code, message = SaveData.selectedPlaythroughId(save, self.injectedFs) + if not validSegment(playthroughId) then + return failure(code, message) + end + + local selectedGame = { + save = { version = version, meta = { playthroughId = playthroughId } }, + } + local context = { + engineVersion = Version.engine, + gameVersion = version, + playthroughId = playthroughId, + } + local normal = SaveData.selectedNormalSaveInfo(save, self.injectedFs) + if type(normal) == "table" and normal.savedAt ~= nil then + context.normalSavedAt = normal.savedAt + end + return { + context = function() + local copy = { + engineVersion = context.engineVersion, + gameVersion = context.gameVersion, + playthroughId = context.playthroughId, + } + if context.normalSavedAt ~= nil then + copy.normalSavedAt = context.normalSavedAt + end + return copy + end, + read = function(_, key) + return self:read(selectedGame, key) + end, + write = function(_, key, value) + return self:write(selectedGame, key, value) + end, + readBytes = function(_, key) + return self:readBytes(selectedGame, key) + end, + writeBytes = function(_, key, bytes) + return self:writeBytes(selectedGame, key, bytes) + end, + list = function(_, prefix) + return self:list(selectedGame, prefix) + end, + delete = function(_, key) + return self:delete(selectedGame, key) + end, + } +end + +function Storage:context(game) + local scope, code, message = self:_scope(game) + if not scope then + return nil, code, message + end + return { + engineVersion = Version.engine, + gameVersion = scope.gameVersion, + playthroughId = scope.playthroughId, + } +end + +function Storage:_names(game, key, allowEmpty, extension) + if not validKey(key, allowEmpty) then + return failure( + "invalid_key", + "Storage keys use nonempty letters, numbers, underscore, dash and slash segments." + ) + end + local scope, code, message = self:_scope(game) + if not scope then + return nil, code, message + end + local path = scope.base .. (key ~= "" and ("/" .. key) or "") + extension = extension or ".lua" + return scope, path .. extension, path .. extension .. ".bak", path .. extension .. ".tmp", path +end + +function Storage:write(game, key, value) + local scope, main, bak, tmp, path = self:_names(game, key, false) + if not scope then + return false, main, bak + end + local fs = scope.fs + if hasAny(fs, { path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" }) then + return false, "type_conflict", "A byte value already exists for this storage key; delete it first." + end + if type(value) ~= "table" then + return false, "encode_failed", "Storage values must be data-only tables." + end + local encodedOk, encoded = pcall(SaveSerializer.encode, value) + if not encodedOk then + return false, "encode_failed", "Storage value is not serializable data: " .. tostring(encoded) + end + + ensureParent(fs, main) + local _, previous = decodeAt(fs, main) + if not previous then + _, previous = decodeAt(fs, bak) + end + + local ok, err = fs.write(tmp, encoded) + if not ok then + return false, "write_failed", "Could not stage storage data: " .. tostring(err) + end + local staged = decodeAt(fs, tmp) + if not staged then + remove(fs, tmp) + return false, "verify_failed", "Staged storage data could not be verified." + end + + if previous then + fs.write(bak, previous) + end + ok, err = fs.write(main, encoded) + if not ok then + remove(fs, tmp) + return false, "write_failed", "Could not replace storage data: " .. tostring(err) + end + local verified = decodeAt(fs, main) + if not verified then + remove(fs, main) + remove(fs, tmp) + return false, "verify_failed", "Replacement storage data could not be verified." + end + + -- At rest both main and backup hold the newest verified record. If a later + -- write dies after rolling this copy aside, one verified generation remains. + fs.write(bak, encoded) + remove(fs, tmp) + return true +end + +function Storage:read(game, key) + local scope, main, bak, tmp, path = self:_names(game, key, false) + if not scope then + return nil, main, bak + end + local fs = scope.fs + if hasAny(fs, { path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" }) then + return failure("type_mismatch", "This storage key contains opaque bytes; use readBytes instead.") + end + local data, body = decodeAt(fs, main) + if data then + return data + end + + data, body = decodeAt(fs, tmp) + if not data then + data, body = decodeAt(fs, bak) + end + if not data then + return nil, "not_found", "No valid stored value exists for this key." + end + + -- Best-effort healing. The recovered copy remains in tmp/bak if promotion + -- cannot land, so returning it is still safe and the next read can retry. + ensureParent(fs, main) + if fs.write(main, body) then + fs.write(bak, body) + end + remove(fs, tmp) + return data +end + +function Storage:writeBytes(game, key, bytes) + local scope, main, bak, tmp, path = self:_names(game, key, false, ".bin") + if not scope then + return false, main, bak + end + if type(bytes) ~= "string" then + return false, "invalid_bytes", "Opaque storage values must be strings." + end + if #bytes > Storage.MAX_BYTES then + return false, "size_limit", ("Opaque storage values cannot exceed %d bytes."):format(Storage.MAX_BYTES) + end + + local fs = scope.fs + if hasAny(fs, { path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" }) then + return false, "type_conflict", "A table value already exists for this storage key; delete it first." + end + + ensureParent(fs, main) + local previous = readOpaqueAt(fs, main) + if previous == nil then + previous = readOpaqueAt(fs, bak) + end + + local ok, err = fs.write(tmp, bytes) + if not ok then + return false, "write_failed", "Could not stage opaque storage data: " .. tostring(err) + end + local staged = readOpaqueAt(fs, tmp) + if staged == nil or staged ~= bytes then + remove(fs, tmp) + return false, "verify_failed", "Staged opaque storage data could not be verified." + end + + if previous ~= nil then + fs.write(bak, previous) + end + ok, err = fs.write(main, bytes) + if not ok then + remove(fs, tmp) + return false, "write_failed", "Could not replace opaque storage data: " .. tostring(err) + end + local verified = readOpaqueAt(fs, main) + if verified == nil or verified ~= bytes then + remove(fs, main) + remove(fs, tmp) + return false, "verify_failed", "Replacement opaque storage data could not be verified." + end + + fs.write(bak, bytes) + remove(fs, tmp) + return true +end + +function Storage:readBytes(game, key) + local scope, main, bak, tmp, path = self:_names(game, key, false, ".bin") + if not scope then + return nil, main, bak + end + local fs = scope.fs + if hasAny(fs, { path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" }) then + return failure("type_mismatch", "This storage key contains table data; use read instead.") + end + + local bytes = readOpaqueAt(fs, main) + if bytes ~= nil then + return bytes + end + bytes = readOpaqueAt(fs, tmp) + if bytes == nil then + bytes = readOpaqueAt(fs, bak) + end + if bytes == nil then + return nil, "not_found", "No valid opaque value exists for this key." + end + + ensureParent(fs, main) + if fs.write(main, bytes) then + fs.write(bak, bytes) + end + remove(fs, tmp) + return bytes +end + +function Storage:list(game, prefix) + prefix = prefix or "" + local scope, main, codeOrBak = self:_names(game, prefix, true) + if not scope then + return nil, main, codeOrBak + end + local fs = scope.fs + if not fs.getDirectoryItems then + return nil, "storage_unavailable", "The persistence backend cannot enumerate keys." + end + + local base = scope.base + local start = prefix == "" and base or (base .. "/" .. prefix) + local out, seen = {}, {} + + local function add(logical) + if not seen[logical] then + seen[logical] = true + out[#out + 1] = logical + end + end + + local function walk(path, logical) + local info = fs.getInfo(path) + if not info then + return + end + if info.type == "file" then + local suffix = path:sub(-4) + if suffix == ".lua" or suffix == ".bin" then + add(logical:sub(1, -5)) + end + return + end + for _, child in ipairs(fs.getDirectoryItems(path) or {}) do + local childLogical = logical == "" and child or (logical .. "/" .. child) + walk(path .. "/" .. child, childLogical) + end + end + + -- A prefix may identify one exact key or a directory of keys. + if fs.getInfo(start .. ".lua") then + add(prefix) + elseif fs.getInfo(start .. ".bin") then + add(prefix) + else + walk(start, prefix) + end + table.sort(out) + return out +end + +function Storage:delete(game, key) + local scope, main, bak, tmp, path = self:_names(game, key, false) + if not scope then + return false, main, bak + end + local fs = scope.fs + local byteMain, byteBak, byteTmp = path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" + if + not ( + fs.getInfo(main) + or fs.getInfo(bak) + or fs.getInfo(tmp) + or fs.getInfo(byteMain) + or fs.getInfo(byteBak) + or fs.getInfo(byteTmp) + ) + then + return false, "not_found", "No stored value exists for this key." + end + remove(fs, main) + remove(fs, bak) + remove(fs, tmp) + remove(fs, byteMain) + remove(fs, byteBak) + remove(fs, byteTmp) + return true +end + +return Storage diff --git a/tests/engine/api3_playthrough_storage.lua b/tests/engine/api3_playthrough_storage.lua new file mode 100644 index 000000000..1b3a3e2e5 --- /dev/null +++ b/tests/engine/api3_playthrough_storage.lua @@ -0,0 +1,138 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local Fixture = dofile("tests/playthrough_storage/fixture.lua") +local Cases = dofile("tests/playthrough_storage/cases.lua") +local old = Version.get() +for _, version in ipairs(Version.ORDER) do + local f = Fixture.new(".", "gen2", version) + Cases.run(f, version) + dofile("tests/playthrough_storage/lifecycle.lua").run(f, version) + f.close() +end +-- Drive the actual native save/slot lifecycle, not synthetic storage handles. +for _, version in ipairs({ "red", "gold", "firered", "platinum" }) do + local f = Fixture.new(".", "gen2", version) + local S, e, fs, files = f.Save, f.e, f.fs, f.files + local function new() + return { save = S.newGame({ version = version }) } + end + local vanilla = new() + T.eq(vanilla.save.meta.playthroughId, nil, "no-mod New Game has no storage identity") + T.check(S.save(vanilla.save), "vanilla progress saves") + local loaded = assert(S.load(version)) + T.eq(loaded.meta.playthroughId, nil, "unused vanilla load remains identity-free") + local progress = {} + for path, bytes in pairs(files) do + if path:match("^save[^/]*%.lua$") then + progress[path] = bytes + end + end + local first = { save = loaded } + local id = assert(e.context(first)).playthroughId + T.check(type(id) == "string" and id ~= "", "legacy storage receives opaque identity") + for path, bytes in pairs(progress) do + T.eq(files[path], bytes, "legacy allocation never rewrites progress") + end + T.check(e.write(first, "progress/value", { n = 12 }), "legacy mod state writes") + S.resetSlotState() + local resumed = { save = assert(S.load(version)) } + T.eq(e.context(resumed).playthroughId, id, "legacy promotion/restart retains identity") + T.eq(e.read(resumed, "progress/value").n, 12, "legacy state survives slot migration") + T.check(S.save(resumed.save, {}), "identified progress saves") + S.resetSlotState() + resumed.save = assert(S.load(version)) + T.eq(resumed.save.meta.playthroughId, id, "normal save stamps and reloads identity") + local boot = new() + local ghost = e.context(boot).playthroughId + T.check(ghost ~= id, "boot/new-game skeleton has separate identity") + boot.stack = { states = { { screenId = "TitleState" } } } + local title = assert(e.selected(boot)) + T.eq(title:context().playthroughId, id, "init-time storage cannot replace title's real save") + T.eq(title:read("progress/value").n, 12, "title reads selected durable mod state") + T.eq(type(title:context().normalSavedAt), "number", "title includes normal save chronology") + fs.fail = ".tmp" + T.eq(S.save(boot.save, {}), false, "failed normal save remains a failed save") + fs.fail = nil + T.eq(e.selected(boot):context().playthroughId, id, "failed normal save cannot replace title identity") + T.check(S.save(boot.save, {}), "new playthrough commits") + local nextBoot = new() + nextBoot.stack = boot.stack + T.eq(e.selected(nextBoot):context().playthroughId, ghost, "committed new game becomes title selection") + local sourceId = boot.save.meta.playthroughId + local sourceSlot = S.activeSlot(version) + local copySlot = assert(S.createSlot(version)) + T.check(S.writeSlot(version, copySlot, boot.save), "identified save imports to another slot") + T.eq(boot.save.meta.playthroughId, sourceId, "import never mutates source identity") + S.setActiveSlot(version, copySlot) + local imported = { save = assert(S.load(version)) } + T.check(e.context(imported).playthroughId ~= sourceId, "duplicate slot receives distinct storage scope") + T.eq( + S.buildMeta({}, imported.save.meta).playthroughId, + imported.save.meta.playthroughId, + "mod-set stamp retains identity" + ) + T.check(S.deleteSlot(version, copySlot), "identified slot deletes") + local opts = S.loadOptions(fs) + T.eq(opts.playthroughIds[version][copySlot], nil, "deleted slot loses routing binding") + S.setActiveSlot(version, sourceSlot) + S.resetSlotState() + T.eq(S.load(version).meta.playthroughId, sourceId, "original slot remains unchanged") + f.close() +end +-- Identity allocation must not change the next gameplay RNG result. +do + local f = Fixture.new(".", "gen2", "red") + local game = { save = f.Save.newGame({ version = "red" }) } + math.randomseed(318) + local expected = math.random() + math.randomseed(318) + f.e.context(game) + T.eq(math.random(), expected, "storage identity never consumes gameplay RNG") + f.close() +end +-- Reject an unavailable persistence backend before attempting identity I/O. +do + local f = Fixture.new(".", "gen2", "red") + local write = f.fs.write + f.fs.write = nil + local _, code = f.e.context({ save = { version = "red" } }) + T.eq(code, "storage_unavailable", "read-only backend cannot allocate a scope") + f.fs.write = write + f.fs.fail = ".tmp" + local game = { save = f.Save.newGame({ version = "red" }) } + local context = f.e.context(game) + T.eq(context, nil, "failed identity write cannot claim durable scope") + T.eq(game.save.meta.playthroughId, nil, "failed allocation leaves no committed identity") + f.close() +end +-- API 1/2 retain the original installation-wide cache semantics and bytes. +for _, api in ipairs({ 1, 2 }) do + local f = Fixture.new(".", "gen2", "red") + local Store = require("src.mods.Storage") + local legacy = Store.new("portable_storage", f.fs) + T.check(legacy:write({}, "old/value", { n = api }), "legacy cache writes") + T.eq(legacy:read({}, "old/value").n, api, "legacy cache survives") + local Json = require("src.link.Json") + local path = "mods/portable_storage/manifest.json" + local manifest = Json.decode(f.files[path]) + manifest.api = api + f.files[path] = Json.encode(manifest) + local loaded = T.sdk.loadMods( + { "mods/portable_storage" }, + { fs = f.fs, generation = 1, data = T.fixtures.fresh(), dev = false } + ) + T.eq(#loaded.errors, 0, "legacy SDK consumer loads") + T.eq( + loaded.loader.exports.portable_storage.read({}, "old/value").n, + api, + "legacy SDK uses unchanged cache namespace" + ) + local _, code = f.e.read(f.game("new_scope"), "old/value") + T.eq(code, "not_found", "portable storage does not guess legacy cache ownership") + T.eq(legacy:read({}, "old/value").n, api, "portable reads retain legacy cache bytes") + loaded.release() + f.close() +end +Version.set(old) +T.finish("API-3 portable storage and native playthrough lifecycle") diff --git a/tests/engine/api3_storage_transactions.lua b/tests/engine/api3_storage_transactions.lua new file mode 100644 index 000000000..e873f568e --- /dev/null +++ b/tests/engine/api3_storage_transactions.lua @@ -0,0 +1,312 @@ +-- Public mod.storage contract: data-only transactions, namespace isolation, +-- deterministic listing, recovery, and failure retention. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness").suite("mod storage") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") +local Storage = require("src.mods.PlaythroughStorage") +local Version = require("src.core.Version") + +local savedEvents, savedHooks = Runtime.events, Runtime.hooks + +local function manifest(id) + return ('{"id":"%s","name":"%s","version":"1.0.0",'):format(id, id) + .. '"entry":"main.lua","api":3,"profile":"content"}' +end + +local function memfs(files) + local fs = { files = files, failTmp = false, failMain = false } + + function fs.read(path) + return files[path] + end + function fs.write(path, body) + if fs.failTmp and path:sub(-4) == ".tmp" then + return false, "tmp denied" + end + if fs.failMain and (path:sub(-4) == ".lua" or path:sub(-4) == ".bin") then + return false, "main denied" + end + files[path] = body + return true + end + function fs.remove(path) + files[path] = nil + return true + end + function fs.createDirectory() + return true + end + function fs.getInfo(path) + if files[path] then + return { type = "file" } + end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + return { type = "directory" } + end + end + return nil + end + function fs.load(path) + if not files[path] then + return nil, "no file: " .. path + end + return load(files[path], path) + end + function fs.getDirectoryItems(path) + local prefix, seen, out = path .. "/", {}, {} + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + out[#out + 1] = child + end + end + end + table.sort(out) + return out + end + return fs +end + +local function game(version, playthroughId) + return { + save = { + version = version, + meta = { format = 4, mods = {}, playthroughId = playthroughId }, + }, + } +end + +local files = { + ["mods/alpha/manifest.json"] = manifest("alpha"), + -- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua) + ["mods/alpha/main.lua"] = [[ +return function(mod) mod.exports.storage = mod.storage end +]], + ["mods/beta/manifest.json"] = manifest("beta"), + ["mods/beta/main.lua"] = [[ +return function(mod) mod.exports.storage = mod.storage end +]], +} +local fs = memfs(files) +local loader = Loader.new({ fs = fs }) +local current = game("red", "play-a") +loader.game = current +T.check(loader:load({}) == true, "storage fixture mods load") + +local alpha = (loader.exports.alpha or {}).storage +local beta = (loader.exports.beta or {}).storage +T.check( + type(alpha) == "table" and type(beta) == "table", + "Loader exposes mod.storage through the public mod object" +) +if type(alpha) ~= "table" or type(beta) ~= "table" then + Runtime.events, Runtime.hooks = savedEvents, savedHooks + T.finish() +end + +-- Removing scope identity or exposing a mutable private slot id breaks this. +local context = alpha:context(current) +T.same(context, { + engineVersion = Version.engine, + gameVersion = "red", + playthroughId = "play-a", +}, "context exposes stable engine/game/playthrough compatibility identity") + +-- Data-only write/read. The literal expected table is independent of storage. +local payload = { format = 1, nested = { money = 1234 }, flags = { a = true } } +local ok, code, message = alpha:write(current, "states/quick/q1", payload) +T.check(ok == true, "data-only payload writes: " .. tostring(code or message)) +local loaded = alpha:read(current, "states/quick/q1") +T.same(loaded, payload, "stored payload roundtrips as data") +T.check( + loaded ~= payload and loaded.nested ~= payload.nested, + "read returns decoded data rather than the caller's live table" +) + +T.check( + type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function", + "mod.storage exposes opaque byte read/write methods" +) +if type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function" then + local binary = "MESH\0\1\255\128\nreturn _G.MOD_STORAGE_EXECUTED = true" + local binaryOk, binaryCode, binaryMessage = alpha:writeBytes(current, "states/quick/blob", binary) + T.check(binaryOk == true, "opaque bytes write exactly: " .. tostring(binaryCode or binaryMessage)) + local binaryLoaded, binaryReadCode = alpha:readBytes(current, "states/quick/blob") + T.eq(binaryLoaded, binary, "opaque bytes round-trip without text or Lua decoding") + T.eq(binaryReadCode, nil, "successful opaque byte read has no error") + T.eq(_G.MOD_STORAGE_EXECUTED, nil, "Lua-looking opaque bytes are never executed") + + local emptyOk = alpha:writeBytes(current, "binary/empty", "") + T.check(emptyOk == true, "empty opaque byte payloads are valid") + T.eq(alpha:readBytes(current, "binary/empty"), "", "empty opaque byte payloads round-trip") + + local badBytes, badBytesCode = alpha:writeBytes(current, "binary/bad-type", { byte = true }) + T.check(not badBytes and badBytesCode == "invalid_bytes", "non-string opaque payloads are rejected") + + local savedLimit = Storage.MAX_BYTES + Storage.MAX_BYTES = 4 + local tooLarge, tooLargeCode = alpha:writeBytes(current, "binary/too-large", "12345") + Storage.MAX_BYTES = savedLimit + T.check( + not tooLarge and tooLargeCode == "size_limit", + "opaque payloads over the per-key limit are rejected" + ) + + local tableConflict, tableConflictCode = alpha:writeBytes(current, "states/quick/q1", "table-key-conflict") + T.check( + not tableConflict and tableConflictCode == "type_conflict", + "bytes cannot replace a table record without deletion" + ) + local wrongType, wrongTypeCode = alpha:read(current, "states/quick/blob") + T.check( + wrongType == nil and wrongTypeCode == "type_mismatch", + "table reads identify byte records as the wrong storage type" + ) +end + +local bad, badCode = alpha:write(current, "states/bad", { callback = function() end }) +T.check(not bad and badCode == "encode_failed", "functions are rejected with a stable data-only error") + +local escaped, escapedCode = alpha:write(current, "../escape", {}) +T.check(not escaped and escapedCode == "invalid_key", "path traversal is rejected before persistence") + +-- Logical enumeration is deterministic and prefix-scoped. +T.check(alpha:write(current, "states/quick/zeta", { n = 2 }), "write zeta") +T.check(alpha:write(current, "states/quick/alpha", { n = 1 }), "write alpha") +T.check(alpha:write(current, "settings", { enabled = true }), "write settings") +local keys = alpha:list(current, "states/quick") +T.same( + keys, + { "states/quick/alpha", "states/quick/blob", "states/quick/q1", "states/quick/zeta" }, + "list returns sorted logical keys under the requested prefix" +) + +-- Mod, playthrough, and game namespaces cannot observe each other. +local missing, missingCode = beta:read(current, "states/quick/q1") +T.check(missing == nil and missingCode == "not_found", "another mod cannot read the first mod's payload") +if type(alpha.readBytes) == "function" then + missing, missingCode = beta:readBytes(current, "states/quick/blob") + T.check( + missing == nil and missingCode == "not_found", + "another mod cannot read the first mod's opaque payload" + ) +end +missing, missingCode = alpha:read(game("red", "play-b"), "states/quick/q1") +T.check(missing == nil and missingCode == "not_found", "another playthrough cannot read the payload") +missing, missingCode = alpha:read(game("blue", "play-a"), "states/quick/q1") +T.check(missing == nil and missingCode == "not_found", "another game version cannot read the payload") +if type(alpha.readBytes) == "function" then + missing, missingCode = alpha:readBytes(game("red", "play-b"), "states/quick/blob") + T.check(missing == nil and missingCode == "not_found", "another playthrough cannot read the opaque payload") + missing, missingCode = alpha:readBytes(game("blue", "play-a"), "states/quick/blob") + T.check( + missing == nil and missingCode == "not_found", + "another game version cannot read the opaque payload" + ) +end + +-- Find the implementation-owned file only to inject corruption; assertions stay +-- on public read behavior, not the path shape. +local function mainFor(fragment) + for path in pairs(files) do + if path:find(fragment, 1, true) and path:sub(-4) == ".lua" then + return path + end + end +end + +local function byteMainFor(fragment) + for path in pairs(files) do + if path:find(fragment, 1, true) and path:sub(-4) == ".bin" then + return path + end + end +end + +local q1Main = mainFor("q1") +T.check(type(q1Main) == "string", "failure fixture locates the persisted q1") +files[q1Main] = "not a serialized table" +loaded, code = alpha:read(current, "states/quick/q1") +T.same(loaded, payload, "corrupt main recovers the last verified payload") +T.eq(code, nil, "successful recovery is a normal read") + +-- A failed replacement cannot destroy the prior verified value. +T.check(alpha:write(current, "replace", { version = 1 }), "seed replace value") +fs.failTmp = true +ok, code = alpha:write(current, "replace", { version = 2 }) +fs.failTmp = false +T.check(not ok and code == "write_failed", "staging failure is reported") +T.same(alpha:read(current, "replace"), { version = 1 }, "staging failure leaves the prior value readable") + +if type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function" then + T.check(alpha:writeBytes(current, "binary/recover", "old-bytes"), "seed opaque recovery value") + local recoverMain = byteMainFor("binary/recover") + T.check(type(recoverMain) == "string", "failure fixture locates opaque recovery data") + files[recoverMain] = nil + T.eq( + alpha:readBytes(current, "binary/recover"), + "old-bytes", + "missing opaque main recovers the last verified backup" + ) + + T.check(alpha:writeBytes(current, "binary/replace", "version-1"), "seed opaque replacement value") + fs.failTmp = true + ok, code = alpha:writeBytes(current, "binary/replace", "version-2") + fs.failTmp = false + T.check(not ok and code == "write_failed", "opaque staging failure is reported") + T.eq( + alpha:readBytes(current, "binary/replace"), + "version-1", + "opaque staging failure leaves the prior value readable" + ) + + fs.failMain = true + ok, code = alpha:writeBytes(current, "binary/replace", "version-3") + fs.failMain = false + T.check(not ok and code == "write_failed", "opaque replacement failure is reported") + T.eq( + alpha:readBytes(current, "binary/replace"), + "version-1", + "opaque replacement failure leaves the prior value readable" + ) + + local byteConflict, byteConflictCode = alpha:write(current, "binary/replace", { version = 3 }) + T.check( + not byteConflict and byteConflictCode == "type_conflict", + "tables cannot replace a byte record without deletion" + ) + + T.check(alpha:writeBytes(current, "binary/delete", "delete-me"), "seed opaque delete target") + T.check(alpha:delete(current, "binary/delete") == true, "delete removes an opaque record") + missing, missingCode = alpha:readBytes(current, "binary/delete") + T.check(missing == nil and missingCode == "not_found", "deleted opaque key is unavailable") +end + +-- Delete is exact and idempotent-not-found is explicit. +T.check(alpha:write(current, "delete/me", { yes = true }), "seed delete target") +T.check(alpha:write(current, "delete/keep", { yes = true }), "seed delete neighbor") +T.check(alpha:delete(current, "delete/me") == true, "delete removes its target") +missing, missingCode = alpha:read(current, "delete/me") +T.check(missing == nil and missingCode == "not_found", "deleted key is unavailable") +T.same(alpha:read(current, "delete/keep"), { yes = true }, "delete leaves neighboring keys untouched") + +-- No-mod parity: constructing/loading an empty loader creates no storage bytes. +local emptyFiles, emptyFs = {}, nil +emptyFs = memfs(emptyFiles) +local emptyLoader = Loader.new({ fs = emptyFs }) +emptyLoader.game = current +T.check(emptyLoader:load({}) == true, "no-mod loader still boots") +T.eq(next(emptyFiles), nil, "no-mod boot creates no storage paths or files") + +Runtime.events, Runtime.hooks = savedEvents, savedHooks +Runtime.currentMod = nil + +T.finish() diff --git a/tests/playthrough_storage/cases.lua b/tests/playthrough_storage/cases.lua new file mode 100644 index 000000000..fbb46d0ef --- /dev/null +++ b/tests/playthrough_storage/cases.lua @@ -0,0 +1,96 @@ +local M = {} +function M.run(f, version) + local T, e, rows = f.T, f.e, {} + local function eq(actual, expected, label) + T.eq(actual, expected, label) + rows[#rows + 1] = { case = label, value = actual } + end + local a, b = f.game("first"), f.game("second") + local context = assert(e.context(a)) + eq(context.gameVersion, version, "context version") + eq(context.playthroughId, "first", "context identity") + eq(type(context.engineVersion), "string", "context engine version") + eq(context.root, nil, "no private root") + eq(e.write(a, "state/value", { n = 7, nested = { ready = true } }), true, "write record") + local got = assert(e.read(a, "state/value")) + got.nested.ready = false + eq(e.read(a, "state/value").nested.ready, true, "decoded copy isolation") + local _, code = e.read(b, "state/value") + eq(code, "not_found", "playthrough isolation") + _, code = f.other.read(a, "state/value") + eq(code, "not_found", "mod isolation") + local otherVersion = f.game("first") + otherVersion.save.version = version == "red" and "blue" or "red" + _, code = e.read(otherVersion, "state/value") + eq(code, "not_found", "version isolation") + local bytes = "CACHE\0\255\128 return MALICIOUS = true" + eq(e.writeBytes(a, "state/bytes", bytes), true, "write bytes") + eq(e.readBytes(a, "state/bytes") == bytes, true, "opaque byte roundtrip") + _, code = e.read(a, "state/bytes") + eq(code, "type_mismatch", "record-byte read mismatch") + _, code = e.write(a, "state/bytes", {}) + eq(code, "type_conflict", "record cannot replace bytes") + _, code = e.writeBytes(a, "state/value", "x") + eq(code, "type_conflict", "bytes cannot replace record") + eq(e.writeBytes(a, "state/empty", ""), true, "empty bytes write") + eq(e.readBytes(a, "state/empty"), "", "empty bytes read") + for _, key in ipairs({ "", "../escape", "/absolute", "double//slash", "tail/", "a.b", "a\\b" }) do + local ok + ok, code = e.write(a, key, {}) + eq(ok, false, "reject key " .. key) + eq(code, "invalid_key", "key error " .. key) + end + local ok + ok, code = e.write(a, "bad/value", { callback = function() end }) + eq(ok, false, "reject executable data") + eq(code, "encode_failed", "data-only error") + eq( + table.concat(assert(e.list(a, "state")), ","), + "state/bytes,state/empty,state/value", + "sorted unique listing" + ) + eq(table.concat(assert(e.list(a, "state/value")), ","), "state/value", "exact-key prefix") + local main + for path in pairs(f.files) do + if path:match("/first/portable_storage/state/value%.lua$") then + main = path + end + end + assert(main, "native persisted record missing") + f.files[main] = "corrupt" + eq(e.read(a, "state/value").n, 7, "backup recovery") + f.fs.fail = ".tmp" + ok, code = e.write(a, "state/value", { n = 8 }) + eq(ok, false, "staging failure") + eq(code, "write_failed", "staging error") + f.fs.fail = ".lua" + ok, code = e.write(a, "state/value", { n = 9 }) + eq(ok, false, "replacement failure") + eq(code, "write_failed", "replacement error") + f.fs.fail = nil + eq(e.read(a, "state/value").n, 7, "failed write retains data") + a.stack = { states = { { screenId = "TitleState" } } } + local selected = assert(e.selected(a)) + eq(selected:context().playthroughId, "first", "selected context") + eq(selected:read("state/value").n, 7, "selected read") + local detached = selected:context() + detached.playthroughId = "second" + a.save.meta.playthroughId = "second" + eq(selected:read("state/value").n, 7, "selected facade cannot be rerouted") + _, code = selected:read(b, "state/value") + eq(code, "invalid_key", "selected rejects injected game") + _, code = e.selected(b) + eq(code, "not_at_title", "selected title guard") + local empty = f.game(nil) + empty.stack = a.stack + _, code = e.selected(empty) + eq(code, "no_selected_playthrough", "selected does not allocate") + eq(e.delete(f.game("first"), "state/value"), true, "delete record") + _, code = selected:read("state/value") + eq(code, "not_found", "delete removes recovery copies") + eq(e.delete(f.game("first"), "state/bytes"), true, "delete bytes") + _, code = e.readBytes(f.game("first"), "state/bytes") + eq(code, "not_found", "delete removes byte recovery") + return rows +end +return M diff --git a/tests/playthrough_storage/fixture.lua b/tests/playthrough_storage/fixture.lua new file mode 100644 index 000000000..5a15f187b --- /dev/null +++ b/tests/playthrough_storage/fixture.lua @@ -0,0 +1,67 @@ +local M = {} +function M.new(root, backend, version) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + assert(Version.VERSIONS[version], "unsupported native game: " .. tostring(version)) + Version.set(version) + local Save = require("src.core.SaveData") + local Json = require("src.link.Json") + local files = {} + for _, id in ipairs({ "portable_storage", "other_storage" }) do + local function read(name) + local f = assert(io.open(root .. "/examples/portable_storage/" .. name, "rb")) + local s = f:read("*a") + f:close() + return s + end + local manifest = Json.decode(read("manifest.json")) + manifest.id = id + files["mods/" .. id .. "/manifest.json"] = Json.encode(manifest) + files["mods/" .. id .. "/main.lua"] = read("main.lua") + end + local fs = T.sdk.memfs(files) + local write = fs.write + fs.write = function(path, body) + if fs.fail and path:sub(-#fs.fail) == fs.fail then + return false, "intentional write denial" + end + return write(path, body) + end + fs.remove = function(path) + files[path] = nil + return true + end + fs.createDirectory = function() + return true + end + local oldFs, oldPortable = love.filesystem, Save.portableFs + love.filesystem, Save.portableFs = fs, function() + return fs + end + Save.resetSlotState() + local result = T.sdk.loadMods({ "mods/portable_storage", "mods/other_storage" }, { + fs = fs, + data = T.fixtures.fresh(), + generation = Version.generation(), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + return { + T = T, + fs = fs, + files = files, + Save = Save, + result = result, + e = result.loader.exports.portable_storage, + other = result.loader.exports.other_storage, + game = function(id) + return { save = { version = version, meta = { playthroughId = id } } } + end, + close = function() + result.release() + love.filesystem, Save.portableFs = oldFs, oldPortable + Save.resetSlotState() + end, + } +end +return M diff --git a/tests/playthrough_storage/lifecycle.lua b/tests/playthrough_storage/lifecycle.lua new file mode 100644 index 000000000..728f68b06 --- /dev/null +++ b/tests/playthrough_storage/lifecycle.lua @@ -0,0 +1,27 @@ +local M = {} +function M.run(f, version) + local T, e, S, rows = f.T, f.e, f.Save, {} + local function check(value, label) + T.eq(value, true, label) + rows[#rows + 1] = { case = label, value = value } + end + local a = { save = S.newGame({ version = version }) } + local first = assert(e.context(a)).playthroughId + check(e.write(a, "lifecycle/value", { n = 27 }), "lifecycle write") + check(S.save(a.save, {}), "native ordinary save") + S.resetSlotState() + local loaded = { save = assert(S.load(version)) } + check(e.context(loaded).playthroughId == first, "native reload preserves storage identity") + check(e.read(loaded, "lifecycle/value").n == 27, "native reload preserves mod record") + local b = { save = S.newGame({ version = version }) } + check(e.context(b).playthroughId ~= first, "new game receives separate namespace") + local value, code = e.read(b, "lifecycle/value") + check(value == nil and code == "not_found", "new game cannot inherit prior mod record") + loaded.stack = { states = { { screenId = "TitleState" } } } + local selected = assert(e.selected(loaded)) + check(selected:context().playthroughId == first, "title resolves selected identity") + check(selected:read("lifecycle/value").n == 27, "title resolves selected mod record") + check(type(selected:context().normalSavedAt) == "number", "title resolves ordinary-save chronology") + return rows +end +return M diff --git a/tests/playthrough_storage/worker.lua b/tests/playthrough_storage/worker.lua new file mode 100644 index 000000000..b39c7f088 --- /dev/null +++ b/tests/playthrough_storage/worker.lua @@ -0,0 +1,11 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version = unpack(arg) +local f = dofile(root .. "/tests/playthrough_storage/fixture.lua").new(root, backend, version) +local rows = dofile(root .. "/tests/playthrough_storage/cases.lua").run(f, version) +local lifecycle = dofile(root .. "/tests/playthrough_storage/lifecycle.lua").run(f, version) +for _, row in ipairs(lifecycle) do + rows[#rows + 1] = row +end +f.close() +print("STORAGE_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable playthrough storage") diff --git a/tools/storage-contract-check/check.py b/tools/storage-contract-check/check.py new file mode 100644 index 000000000..144ef075e --- /dev/null +++ b/tools/storage-contract-check/check.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Compare unchanged API-3 storage consumers through real SDK/save lifecycles.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" +VERSIONS = ["red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald"] + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in VERSIONS: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2/"tests/playthrough_storage/worker.lua"), + str(gen2), backend, version], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("STORAGE_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("STORAGE_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"storage mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching API-3 storage/lifecycle observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": 3, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_storage/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From aef565014e18051acc979aabac6641978b5e822d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:47:53 +0200 Subject: [PATCH 035/138] Preserve requested storage identities across stale option writes --- src/core/SaveData.lua | 6 ++++++ tests/engine/api3_playthrough_storage.lua | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 8fa8afde8..ffa666151 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -597,6 +597,12 @@ function SaveData.saveOptions(opts, fs, gen) -- write cannot clobber another mod's persisted keys. Every other -- option stays on the shallow path. local onDisk = readTable(fs, OPTIONS_FILENAME) + -- A pre-storage UI/settings snapshot has no identity map. Saving it must + -- not erase tool state allocated since that snapshot was taken. Engine + -- routing changes supply a table explicitly (including an empty table). + if onDisk and opts.playthroughIds == nil and type(onDisk.playthroughIds) == "table" then + opts.playthroughIds = onDisk.playthroughIds + end if onDisk and type(onDisk.modOptions) == "table" then local merged = {} for modId, bucket in pairs(onDisk.modOptions) do diff --git a/tests/engine/api3_playthrough_storage.lua b/tests/engine/api3_playthrough_storage.lua index 1b3a3e2e5..ee24e1524 100644 --- a/tests/engine/api3_playthrough_storage.lua +++ b/tests/engine/api3_playthrough_storage.lua @@ -35,6 +35,13 @@ for _, version in ipairs({ "red", "gold", "firered", "platinum" }) do T.eq(files[path], bytes, "legacy allocation never rewrites progress") end T.check(e.write(first, "progress/value", { n = 12 }), "legacy mod state writes") + T.check(S.saveOptions(S.defaultOptions(), fs), "stale options snapshot writes") + local persisted = S.loadOptions(fs).playthroughIds + T.eq( + persisted and persisted[version] and persisted[version].legacy, + id, + "stale option writers cannot erase a requested storage identity" + ) S.resetSlotState() local resumed = { save = assert(S.load(version)) } T.eq(e.context(resumed).playthroughId, id, "legacy promotion/restart retains identity") @@ -72,6 +79,17 @@ for _, version in ipairs({ "red", "gold", "firered", "platinum" }) do imported.save.meta.playthroughId, "mod-set stamp retains identity" ) + local external = S.loadOptions(fs) + external.saveSlots[version].active = sourceSlot + S.saveOptions(external, fs) + local localTitle = new() + localTitle.stack = boot.stack + T.eq( + e.selected(localTitle):context().playthroughId, + imported.save.meta.playthroughId, + "another launcher's selection cannot reroute this process's native slot" + ) + S.setActiveSlot(version, copySlot) T.check(S.deleteSlot(version, copySlot), "identified slot deletes") local opts = S.loadOptions(fs) T.eq(opts.playthroughIds[version][copySlot], nil, "deleted slot loses routing binding") From d7581ba7202773cd58945c00fb030fc1a35e6bb5 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:05:53 +0200 Subject: [PATCH 036/138] Add native live-map lifecycle events and portable API-3 pixel placements --- .github/workflows/live-maps-contract.yml | 44 +++++ docs/api3-live-maps.md | 85 ++++++++++ docs/world-read-api.md | 5 +- examples/portable_live_maps/main.lua | 21 +++ examples/portable_live_maps/manifest.json | 1 + src/mods/Api3.lua | 8 +- src/mods/Capabilities.lua | 1 + src/mods/UnifiedCallbacks.lua | 1 + src/world/LiveMaps.lua | 35 ++++ src/world/OverworldController.lua | 11 +- tests/engine/live_maps_lifecycle.lua | 112 +++++++++++++ tests/live_maps/cases.lua | 41 +++++ tests/live_maps/fixture.lua | 189 ++++++++++++++++++++++ tests/live_maps/worker.lua | 7 + tools/live-maps-contract-check/check.py | 61 +++++++ 15 files changed, 618 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/live-maps-contract.yml create mode 100644 docs/api3-live-maps.md create mode 100644 examples/portable_live_maps/main.lua create mode 100644 examples/portable_live_maps/manifest.json create mode 100644 src/world/LiveMaps.lua create mode 100644 tests/engine/live_maps_lifecycle.lua create mode 100644 tests/live_maps/cases.lua create mode 100644 tests/live_maps/fixture.lua create mode 100644 tests/live_maps/worker.lua create mode 100644 tools/live-maps-contract-check/check.py diff --git a/.github/workflows/live-maps-contract.yml b/.github/workflows/live-maps-contract.yml new file mode 100644 index 000000000..a1b8e83bb --- /dev/null +++ b/.github/workflows/live-maps-contract.yml @@ -0,0 +1,44 @@ +name: portable live-map lifecycle contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: live-maps-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native lifecycle, pixel reader and legacy readers + run: | + luajit tests/engine/live_maps_lifecycle.lua + luajit tests/engine/api3_compat.lua + luajit tests/engine/world_read_api.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/live-maps-contract + python3 tools/live-maps-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --format native --report /tmp/live-maps-contract/api1-native.json + python3 tools/live-maps-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format native --report /tmp/live-maps-contract/api2-native.json + python3 tools/live-maps-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format unified --report /tmp/live-maps-contract/api2-unified.json + python3 tools/live-maps-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/live-maps-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: live-maps-contract + path: /tmp/live-maps-contract + if-no-files-found: ignore diff --git a/docs/api3-live-maps.md b/docs/api3-live-maps.md new file mode 100644 index 000000000..520ac1ac0 --- /dev/null +++ b/docs/api3-live-maps.md @@ -0,0 +1,85 @@ +# Portable live-map placements and lifecycle + +API 3 `mod.world:liveMaps()` returns detached placements with the Gen1Recomp +contract. The active map is first, followed by the native loaded-neighbor order: + +```lua +{ + { mapId = "ROOT", ox = 0, oy = 0, active = true }, + { mapId = "EAST", ox = 320, oy = -32, active = false }, +} +``` + +`ox`/`oy` are **world pixels**, relative to the active map. Negative offsets, +native ordering and repeated placements are retained. The query reads maps +already held by the world: it does not load maps, spawn NPCs, rebuild neighbors, +consume RNG or fire events. No game/world returns the existing refusal result. +Changing the returned records cannot change native residency or placement. +The shared native Gen2 overworld supports this API-3 reader on all Gen1–5 hosts. +Other API-3 world methods keep their own generation/availability limits. + +API 1/2 retain the enriched `WorldReads.liveMaps` records documented in +`world-read-api.md`: `x`/`y` are walk cells, with geometry metadata and duplicate +placement collapsing. API 3 previously inherited that Gen2 shape; this corrects +its pixel-field mismatch with Gen1. API-3 consumers must use `ox`/`oy` rather +than infer offsets from the legacy cell fields. + +## Native lifecycle events + +The shared overworld dispatches these around its actual connected-map ghost +rebuild: + +- `world.live_maps_preparing`: maps have been loaded and retained, before their + neighbor NPCs/ghosts are rebuilt. The internal preparation phase is active + during synchronous listeners. +- `world.live_maps_updated`: neighbor NPCs and cast invalidation have completed; + the preparation phase is over. + +Both payloads are `{ mapId = "ROOT", maps = }` in all +SDK formats. They contain fresh data records, not native Map handles. No return +value vetoes a rebuild. Listener failure follows the event bus's normal recovery +and does not stop native construction. Recursive rebuilds during preparation +suppress nested notifications and restore the outer preparation flag. Ordinary +rebuilds, including ones whose map IDs happen to stay the same, still notify. +When nobody subscribes, the native path constructs no event snapshots. + +```lua +local mod = ... +if mod:supports("events", "world.live_maps_updated") then + mod.events:on("world.live_maps_updated", function(payload) + for _, placement in ipairs(payload.maps) do + -- Refresh this mod's map-dependent state from mapId and pixel offsets. + end + end) +end +``` + +Gen1's pinned GB/Gold native paths provide these events. Its GBA field provides +the pixel reader but has no native producer for these event names. Capability +checks must therefore gate event-dependent behavior; registration alone does +not prove a producer exists. Gen2's shared native world provides both events +across all fifteen supported hosts. This does not provide a follower entity or +change the separate `world.follower.spawn` hook. + +## Verification + +The unchanged `portable_live_maps` consumer runs through both real SDKs and +native GB/Gold rebuild methods for Red, Blue, Yellow, Gold, Silver and Crystal, +in API 1 native, API 2 native/unified and API 3. The API-3 reader is also compared +with Gen1's real FireRed/Emerald world facade. Gen1 remains clean and pinned to +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`; its world producers are unchanged at +current dev `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. + +Gen2 additionally tests all host versions, real NPC ghost construction after a +preparing listener, legacy cell-reader availability during preparation, native +placement duplicates, safe missing-world reads, once/unsubscribe, entry rollback, +listener failures, nested rebuilds and the unsubscribed allocation-free path. +Fixtures replace asset-loading boundaries while retaining the native graph walk, +Map/NPC objects and rebuild/SDK/event logic. These are ROM-free contracts, not +GPU pixel or full-playthrough validation. + +```sh +luajit tests/engine/live_maps_lifecycle.lua +python3 tools/live-maps-contract-check/check.py --gen1 /path/to/gen1 \ + --api 3 --format unified --report /tmp/live-maps.json +``` diff --git a/docs/world-read-api.md b/docs/world-read-api.md index 01b2a60d0..7fce046bc 100644 --- a/docs/world-read-api.md +++ b/docs/world-read-api.md @@ -108,7 +108,10 @@ a public collision/elevation encoding**. Use the existing terrain/elevation services for those questions. A DS map must carry the runtime grid; this method does not generate one from meshes or lazily load shared layouts. -## Loaded map snapshots +## Loaded map snapshots (API 1/2) + +API 3 uses the portable pixel-placement contract in `api3-live-maps.md`. +The enriched records below remain the API-1/2 contract. `liveMaps()` returns only maps already held by the active overworld, with the active map first and neighbors in native order. It never enumerates all ROM diff --git a/examples/portable_live_maps/main.lua b/examples/portable_live_maps/main.lua new file mode 100644 index 000000000..da0cfb322 --- /dev/null +++ b/examples/portable_live_maps/main.lua @@ -0,0 +1,21 @@ +local mod = ... +local events, stops = {}, {} +for _, phase in ipairs({ "preparing", "updated" }) do + local label = phase + local name = "world.live_maps_" .. phase + if not mod.supports or mod:supports("events", name) then + stops[#stops + 1] = mod.events:on(name, function(payload) + events[#events + 1] = { phase = label, mapId = payload.mapId, maps = payload.maps } + end) + end +end +mod.exports.api = mod +mod.exports.events = events +mod.exports.maps = function() + return mod.world:liveMaps() +end +mod.exports.stop = function() + for _, stop in ipairs(stops) do + stop() + end +end diff --git a/examples/portable_live_maps/manifest.json b/examples/portable_live_maps/manifest.json new file mode 100644 index 000000000..9e02dea12 --- /dev/null +++ b/examples/portable_live_maps/manifest.json @@ -0,0 +1 @@ +{"id": "portable_live_maps", "name": "Portable live-map lifecycle", "version": "1.0.0", "api": 3, "games": ["all"], "entry": "main.lua", "affects_link": false} diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index 984a886bf..fda5cd0d0 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -85,7 +85,7 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) return nativeWorld end local function dispatch(name, ...) - if not supportedGeneration or not SUPPORTED[name] then + if (not supportedGeneration and not (name == "liveMaps" and gen >= 1 and gen <= 5)) or not SUPPORTED[name] then return nil, "unsupported: API 3 world method " .. name end local game = current() @@ -137,6 +137,11 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) if name == "queueScript" then return scripts:queueScript(...) end + if name == "liveMaps" then + local ow = native(game):overworld() + if not (ow and ow.map) then return nil, "no overworld" end + return require("src.world.LiveMaps").snapshot(ow) + end local raw = native(game) local result = pack(raw[name](raw, ...)) if name == "party" and type(result[1]) == "table" then @@ -194,6 +199,7 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) local supports = api.supports function api:supports(surface, name) if surface == "world" then + if name == "liveMaps" and gen >= 1 and gen <= 5 then return true end if supportedGeneration and SUPPORTED[name] then return true end diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 52f2f930f..dc3fbfe5c 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -36,6 +36,7 @@ add("hooks", "123", "pokemon.level_visible ui.party.grid_navigation") add("hooks", "12", "intro.oak_speech.build") add("events", "12345", [[ + world.live_maps_preparing world.live_maps_updated assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt battle.ended battle.exp_gained battle.fainted battle.move_used battle.started battle.status_inflicted battle.turn_ended battle.turn_started discord.join_requested diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index c28a85dc8..ba100053d 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -27,6 +27,7 @@ C.hooks = names([[ ui.title_menu.items warp.destination world.talk world.tod zoom.range ]]) C.events = names([[ + world.live_maps_preparing world.live_maps_updated assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt battle.ended battle.exp_gained battle.fainted battle.move_used battle.started battle.status_inflicted battle.turn_ended battle.turn_started discord.join_requested flag.changed game.ready intro.birch_speech.finished diff --git a/src/world/LiveMaps.lua b/src/world/LiveMaps.lua new file mode 100644 index 000000000..9c312c578 --- /dev/null +++ b/src/world/LiveMaps.lua @@ -0,0 +1,35 @@ +-- Detached residency snapshots. Offsets are world pixels, as in Gen1Recomp. +local Runtime = require("src.mods.Runtime") +local M = {} +function M.snapshot(world) + local out = {} + if not (world and world.map) then + return out + end + out[1] = { mapId = world.map.id, ox = 0, oy = 0, active = true } + for _, neighbor in ipairs(world.neighbors or {}) do + out[#out + 1] = { + mapId = neighbor.map and neighbor.map.id or neighbor.id, + ox = neighbor.ox or (neighbor.cx or 0) * 16, + oy = neighbor.oy or (neighbor.cy or 0) * 16, + active = false, + } + end + return out +end +function M.preparing(world) + if not world.map or world.preparingLiveMaps or not Runtime.wants("world.live_maps_preparing") then + return + end + local previous = world.preparingLiveMaps + world.preparingLiveMaps = true + Runtime.emit("world.live_maps_preparing", { mapId = world.map.id, maps = M.snapshot(world) }) + world.preparingLiveMaps = previous +end +function M.updated(world) + if not world.map or world.preparingLiveMaps or not Runtime.wants("world.live_maps_updated") then + return + end + Runtime.emit("world.live_maps_updated", { mapId = world.map.id, maps = M.snapshot(world) }) +end +return M diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index e143fb17a..104169687 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -20,6 +20,7 @@ local Logger = require("src.core.Logger") local Probe = require("src.core.Probe") local Map = require("src.world.Map") local MapLoader = require("src.world.MapLoader") +local LiveMaps = require("src.world.LiveMaps") local NPC = require("src.world.NPC") local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") @@ -2036,6 +2037,10 @@ function OverworldState:neighborReach() return rw, rh, vw, vh end +function OverworldState:liveMaps() + return LiveMaps.snapshot(self) +end + function OverworldState:rebuildNeighbors() local mapId = self.map.id self.neighbors = {} @@ -2052,11 +2057,13 @@ function OverworldState:rebuildNeighbors() hops, reachW, reachH)) do keep[n.id] = true local m = MapLoader.load(Game.data, n.id) - table.insert(self.neighbors, { map = m, ox = n.ox, oy = n.oy }) + table.insert(self.neighbors, { map = m, ox = n.ox, oy = n.oy, cx = n.ox / 16, cy = n.oy / 16 }) end -- bound resident memory: drop maps behind us that are neither current nor -- a drawn neighbor, releasing their window batch / border image / atlas MapLoader.trim(keep) + LiveMaps.preparing(self) + -- visual-only NPCs on connected maps (survey zoom): same spawn filter -- as a real map entry, but they never join self.entities -- no sight @@ -2086,7 +2093,6 @@ function OverworldState:rebuildNeighbors() -- the seam offset in CELLS. Everything in this engine steps on a 16px -- grid whatever the dataset's block size, and a connection offset is a -- whole number of blocks, so this is always exact. - nb.cx, nb.cy = nb.ox / 16, nb.oy / 16 for _, obj in ipairs(nb.map.def.objects or {}) do if objectVisible(Game.save, nb.map.id, obj) and not offMap(nb.map.def, obj) then local npc = pooledNPC(self.npcPool, Game.data, nb.map.id, obj) @@ -2098,6 +2104,7 @@ function OverworldState:rebuildNeighbors() end end self.cast = nil + LiveMaps.updated(self) end -- ONE CAST, IN EVERY LOADED MAP'S OWN CELLS. diff --git a/tests/engine/live_maps_lifecycle.lua b/tests/engine/live_maps_lifecycle.lua new file mode 100644 index 000000000..077832730 --- /dev/null +++ b/tests/engine/live_maps_lifecycle.lua @@ -0,0 +1,112 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local LiveMaps = require("src.world.LiveMaps") +local Fixture = dofile("tests/live_maps/fixture.lua") +local Cases = dofile("tests/live_maps/cases.lua") +local old = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + f.commonEvents = true -- Gen2's real shared overworld produces events on every host. + local world, e = f.world, f.e + T.eq(e.api:supports("events", "world.live_maps_preparing"), true, "native preparing capability") + T.eq(e.api:supports("events", "world.live_maps_updated"), true, "native updated capability") + Cases.run(f) + if mode[1] < 3 then + local maps = e.maps() + T.eq(maps[2].x, 2 * f.pixel / 16, "legacy reader retains cell coordinates") + T.eq(maps[2].ox, nil, "legacy reader shape is preserved") + else + T.eq(e.api:supports("world", "liveMaps"), true, "API-3 reader supports native host") + local neighbors = world.neighbors + world.neighbors = { neighbors[1], neighbors[1], { map = neighbors[1].map, ox = -16, oy = 32 } } + local placements = e.maps() + T.eq(#placements, 4, "API-3 retains native placement order and duplicates") + T.eq(placements[4].ox, -16, "same map at another offset is retained") + world.neighbors = neighbors + f.game.world, f.game.overworld = nil, nil + local missing, why = e.maps() + T.eq(missing, nil, "API-3 query without a world is safe") + T.eq(why, "no overworld", "API-3 missing-world reason") + f.game.world, f.game.overworld = world, world + end + local once = 0 + e.api.events:once("world.live_maps_updated", function() + once = once + 1 + end) + f.rebuild() + f.rebuild() + T.eq(once, 1, "once unsubscribes from native event") + local calls = 0 + local off = e.api.events:on("world.live_maps_preparing", function() + calls = calls + 1 + T.eq(world.preparingLiveMaps, true, "preparation callback owns preparation phase") + local legacy = require("src.mods.WorldReads").liveMaps(world) + T.eq(legacy[2].x, 2 * f.pixel / 16, "legacy offsets are ready during preparation") + f.game.data.maps.EAST.objects = { + { + index = 1, + name = "LIVE_GHOST", + sprite = "SPRITE_FIX_NPC", + movement = "STAY", + range = "NONE", + x = 0, + y = 0, + }, + } + end) + f.rebuild() + T.eq(calls, 1, "preparing runs once before native ghost construction") + T.eq(#world.ghosts, 1, "preparation changes reach actual ghost builder") + T.eq(world.ghosts[1].npc.id, "EAST_obj_1", "native NPC was constructed") + off() + local before = #e.events + local failure = e.api.events:on("world.live_maps_preparing", function() + error("intentional listener failure") + end, 100) + f.rebuild() + T.eq(#e.events, before + 2, "failed listener does not prevent either lifecycle notification") + T.eq(world.preparingLiveMaps, nil, "failed listener leaves no preparation flag") + failure() + before = #e.events + local nested = e.api.events:on("world.live_maps_preparing", function() + f.rebuild() + end, 100) + f.rebuild() + T.eq(#e.events, before + 2, "nested rebuild does not recursively notify preparation") + T.eq(world.preparingLiveMaps, nil, "nested rebuild restores preparation flag") + nested() + before = #e.events + e.stop() + e.stop() + f.rebuild() + T.eq(#e.events, before, "consumer unsubscribe stops native delivery") + f.close() + local failed = Fixture.new(".", "gen2", version, mode[1], mode[2], true) + T.check(#failed.result.errors > 0, "entry failure is recorded") + failed.rebuild() + for _, name in ipairs({ "world.live_maps_preparing", "world.live_maps_updated" }) do + for _, entry in ipairs(Runtime.events.listeners[name] or {}) do + T.check(entry.owner ~= "portable_live_maps", "entry rollback removes owned listener") + end + end + failed.close() + end +end +-- No-listener rebuilds never allocate a snapshot; data-only reads never load maps. +do + local f = Fixture.new(".", "gen2", "red", 3, "unified") + Runtime.install(require("src.mods.Events").new(), require("src.mods.Hooks").new(), {}) + local snapshot = LiveMaps.snapshot + LiveMaps.snapshot = function() + error("unsubscribed snapshot allocation") + end + local ok = pcall(f.rebuild) + LiveMaps.snapshot = snapshot + T.eq(ok, true, "native hot path skips snapshots without listeners") + f.close() +end +Version.set(old) +T.finish("portable live-map events and API-3 pixel placements") diff --git a/tests/live_maps/cases.lua b/tests/live_maps/cases.lua new file mode 100644 index 000000000..8b27c2020 --- /dev/null +++ b/tests/live_maps/cases.lua @@ -0,0 +1,41 @@ +local M = {} +function M.run(f) + local T, e, rows = f.T, f.e, {} + f.rebuild() + if f.commonEvents then + T.eq(#e.events, 2, "native rebuild emits one pair") + T.eq(e.events[1].phase, "preparing", "preparing arrives first") + T.eq(e.events[2].phase, "updated", "updated arrives last") + T.eq(f.phase[1].preparing, true, "native preparation flag is set") + T.eq(f.phase[1].ghosts, 1, "preparing precedes ghost rebuild") + T.eq(f.phase[2].preparing, false, "updated observes settled preparation") + T.eq(f.phase[2].ghosts, 0, "updated follows ghost rebuild") + for _, event in ipairs(e.events) do + T.eq(event.mapId, "ROOT", "event identifies active map") + T.eq(#event.maps, 3, "event includes active and connected maps") + T.eq(event.maps[1].mapId, "ROOT", "active placement first") + T.eq(event.maps[1].active, true, "active flag") + T.eq(event.maps[2].ox, 2 * f.pixel, "east offset is pixels") + T.eq(event.maps[2].oy, -f.pixel, "negative offset preserved") + rows[#rows + 1] = event + end + e.events[1].maps[1].mapId = "changed-copy" + T.eq(f.world.map.id, "ROOT", "event mutation cannot change native map") + e.events[1].maps[1].mapId = "ROOT" + end + if f.api >= 3 then + local before = f.loaderCalls() + local maps = assert(e.maps()) + T.eq(#maps, 3, "API-3 query reports loaded maps") + T.eq(maps[1].ox, 0, "API-3 active x is pixels") + T.eq(maps[2].ox, 2 * f.pixel, "API-3 neighbor x is pixels") + T.eq(maps[2].oy, -f.pixel, "API-3 neighbor y is pixels") + T.eq(maps[2].x, nil, "API-3 does not substitute cell coordinates") + T.eq(f.loaderCalls(), before, "query never loads a map") + maps[2].ox = 999 + T.eq(e.maps()[2].ox, 2 * f.pixel, "query returns detached placements") + rows[#rows + 1] = { query = e.maps() } + end + return rows +end +return M diff --git a/tests/live_maps/fixture.lua b/tests/live_maps/fixture.lua new file mode 100644 index 000000000..a8d58abb9 --- /dev/null +++ b/tests/live_maps/fixture.lua @@ -0,0 +1,189 @@ +local M = {} +function M.new(root, backend, version, api, format, failing) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + assert(Version.VERSIONS[version], "unsupported fixture version") + Version.set(version) + local gen = Version.generation() + local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local pixel = gen >= 3 and 16 or 32 + local defs = {} + for _, id in ipairs({ "ROOT", "EAST", "NORTH" }) do + defs[id] = { + id = id, + width = 2, + height = 2, + blockPx = pixel, + tileset = "FIX_OUT", + blocks = { 0, 0, 0, 0 }, + borderBlock = 0, + objects = {}, + connections = {}, + } + end + defs.ROOT.connections.east = { map = "EAST", offset = -1 } + defs.EAST.connections.north = { map = "NORTH", offset = -3 } + defs.NORTH.connections.south = { map = "ROOT", offset = 0 } + data.maps = defs + data.tilesets = data.tilesets or {} + data.tilesets.FIX_OUT = + { blockCells = pixel / 16, blockTiles = pixel / 8, blocks = {}, walkable = {}, id = "FIX_OUT" } + local files, Json = {}, require("src.link.Json") + for _, name in ipairs({ "main.lua", "manifest.json" }) do + local f = assert(io.open(root .. "/examples/portable_live_maps/" .. name, "rb")) + files["mods/portable_live_maps/" .. name] = f:read("*a") + f:close() + end + local manifest = Json.decode(files["mods/portable_live_maps/manifest.json"]) + manifest.api, manifest.callback_format = api, format + files["mods/portable_live_maps/manifest.json"] = Json.encode(manifest) + if failing then + files["mods/portable_live_maps/main.lua"] = files["mods/portable_live_maps/main.lua"] + .. '\nerror("intentional entry failure")' + end + local result = T.sdk.loadMods({ "mods/portable_live_maps" }, { + fs = T.sdk.memfs(files), + generation = gen, + data = data, + dev = false, + }) + if not failing then + assert(#result.errors == 0, table.concat(result.errors, "\n")) + end + local restores = {} + local function patch(owner, key, value) + local old = owner[key] + restores[#restores + 1] = function() + owner[key] = old + end + owner[key] = value + end + local function bind(fn, name, value) + for i = 1, 40 do + local key, old = debug.getupvalue(fn, i) + if not key then + break + end + if key == name then + debug.setupvalue(fn, i, value) + restores[#restores + 1] = function() + debug.setupvalue(fn, i, old) + end + return + end + end + error("missing native binding: " .. name) + end + local game = { + data = data, + save = { version = version, options = {}, flags = {}, party = {} }, + renderer = { + worldViewSize = function() + return 160, 144 + end, + }, + } + local maps = {} + local Map = require("src.world.Map") + for id, def in pairs(defs) do + maps[id] = Map.new(def, data.tilesets.FIX_OUT) + end + local world, rebuild, source, loaderCalls = nil, nil, nil, 0 + if backend == "gen1" and gen == 3 then + local Field, Runtime, Map3 = + require("src.core.game3.field"), require("src.core.game3.runtime"), require("src.core.game3.map") + game.phase, game.session = "field", { map = "ROOT" } + patch(Field, "running", true) + patch(Runtime, "getSession", function() + return game.session + end) + patch(Map3, "current", "ROOT") + source = { + { id = "ROOT", ox = 0, oy = 0 }, + { id = "EAST", ox = 2 * pixel, oy = -pixel }, + { id = "NORTH", ox = -pixel, oy = -3 * pixel }, + } + patch(Map3, "world", source) + world = { map = maps.ROOT, neighbors = {} } + rebuild = function() end -- Gen1 GBA has no producer for these events. + elseif backend == "gen1" and gen == 2 then + local World = require("src.world.gen2.World") + world = setmetatable({ + game = game, + map = maps.ROOT, + maps = defs, + tilesets = data.tilesets, + npcs = {}, + entities = {}, + npcPool = {}, + neighbors = {}, + ghosts = { "old" }, + pixelZoomScale = function() + return 1 + end, + imageFor = function() + return {} + end, + }, { __index = World }) + rebuild = function() + world:rebuildNeighbors() + world:rebuildPeople() + end + else + local World, Loader = require("src.world.OverworldController"), require("src.world.MapLoader") + world = setmetatable({ + game = game, + map = maps.ROOT, + npcs = {}, + entities = {}, + npcPool = {}, + neighbors = {}, + ghosts = { "old" }, + }, { __index = World }) + bind(World.rebuildNeighbors, "Game", game) + patch(Loader, "load", function(_, id) + loaderCalls = loaderCalls + 1 + return assert(maps[id]) + end) + patch(Loader, "trim", function() end) + rebuild = function() + world:rebuildNeighbors() + end + end + game.world, game.overworld = world, world + result.loader.game = game + local phase = {} + for _, name in ipairs({ "world.live_maps_preparing", "world.live_maps_updated" }) do + local label = name + result.loader.events:on(name, function() + phase[#phase + 1] = + { name = label, preparing = world.preparingLiveMaps == true, ghosts = #world.ghosts } + end, 100) + end + return { + T = T, + game = game, + world = world, + result = result, + e = result.loader.exports.portable_live_maps, + api = api, + gen = gen, + backend = backend, + pixel = pixel, + phase = phase, + rebuild = rebuild, + commonEvents = gen <= 2, + source = source, + patch = patch, + loaderCalls = function() + return loaderCalls + end, + close = function() + for i = #restores, 1, -1 do + restores[i]() + end + result.release() + end, + } +end +return M diff --git a/tests/live_maps/worker.lua b/tests/live_maps/worker.lua new file mode 100644 index 000000000..bb9fc7e68 --- /dev/null +++ b/tests/live_maps/worker.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local f = dofile(root .. "/tests/live_maps/fixture.lua").new(root, backend, version, tonumber(api), format) +local rows = dofile(root .. "/tests/live_maps/cases.lua").run(f) +f.close() +print("LIVE_MAPS_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable live-map lifecycle") diff --git a/tools/live-maps-contract-check/check.py b/tools/live-maps-contract-check/check.py new file mode 100644 index 000000000..1b7ddb3c6 --- /dev/null +++ b/tools/live-maps-contract-check/check.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Compare native live-map events and the unchanged API-3 pixel reader.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=3) + p.add_argument("--format", choices=["native", "unified"], default="unified") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + versions = ["red", "blue", "yellow", "gold", "silver", "crystal"] + if a.api >= 3: + versions += ["firered", "emerald"] + for version in versions: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2/"tests/live_maps/worker.lua"), str(gen2), + backend, version, str(a.api), a.format], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("LIVE_MAPS_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("LIVE_MAPS_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "scope": "events+reader" if a.api >= 3 and version not in ["firered", "emerald"] + else ("reader" if a.api >= 3 else "events"), + "observations": rows, "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"live maps mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching native lifecycle/reader observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), "api": a.api, + "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_live_maps/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From 5481fa2a463a24cf314c6ad60c8fb7df886fa495 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:29:39 +0200 Subject: [PATCH 037/138] Add portable native follower predicate and shared lifecycle contracts --- .github/workflows/follower-contract.yml | 44 +++++++ docs/api3-portability.md | 4 + docs/modding.md | 3 + docs/portable-follower.md | 78 ++++++++++++ examples/portable_follower/main.lua | 18 +++ examples/portable_follower/manifest.json | 1 + src/mods/Capabilities.lua | 1 + src/mods/UnifiedCallbacks.lua | 3 +- src/world/OptionalFollower.lua | 143 +++++++++++++++++++++ src/world/PikachuFollower.lua | 21 +++- tests/engine/follower_spawn.lua | 99 +++++++++++++++ tests/follower/cases.lua | 133 ++++++++++++++++++++ tests/follower/fixture.lua | 153 +++++++++++++++++++++++ tests/follower/worker.lua | 14 +++ tools/follower-contract-check/check.py | 59 +++++++++ 15 files changed, 770 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/follower-contract.yml create mode 100644 docs/portable-follower.md create mode 100644 examples/portable_follower/main.lua create mode 100644 examples/portable_follower/manifest.json create mode 100644 src/world/OptionalFollower.lua create mode 100644 tests/engine/follower_spawn.lua create mode 100644 tests/follower/cases.lua create mode 100644 tests/follower/fixture.lua create mode 100644 tests/follower/worker.lua create mode 100644 tools/follower-contract-check/check.py diff --git a/.github/workflows/follower-contract.yml b/.github/workflows/follower-contract.yml new file mode 100644 index 000000000..8b2fd95c4 --- /dev/null +++ b/.github/workflows/follower-contract.yml @@ -0,0 +1,44 @@ +name: portable follower predicate contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: follower-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native followers and compatibility + run: | + luajit tests/engine/follower_spawn.lua + luajit tests/engine/api3_compat.lua + luajit tests/engine/unified_mod_callbacks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/follower-contract + python3 tools/follower-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --format native --report /tmp/follower-contract/api1-native.json + python3 tools/follower-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format native --report /tmp/follower-contract/api2-native.json + python3 tools/follower-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format unified --report /tmp/follower-contract/api2-unified.json + python3 tools/follower-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/follower-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: follower-contract + path: /tmp/follower-contract + if-no-files-found: ignore diff --git a/docs/api3-portability.md b/docs/api3-portability.md index e324d5c36..7e56d41ba 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -143,3 +143,7 @@ or a proof of every exposed native callback's payload. Native action details remain covered by the prerequisite PR suites. Extend these shared fixtures whenever another API-3 service becomes portable; do not make a failing comparison pass by changing only one engine's mod fixture. + +The [portable follower predicate](portable-follower.md) controls native companion +existence on Gen1–3 without replacing a private controller. Artwork remains a +separate concern; check `mod:supports("hooks", "world.follower.spawn")`. diff --git a/docs/modding.md b/docs/modding.md index 7c051609b..f9d14d0d9 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -429,3 +429,6 @@ API-2 mods can select `"content_format": "unified"` to read and edit common Pokémon, move, item, trainer and encounter definition shapes. Existing mods keep native formats. See [the format, supported operations and limitations](unified-mod-content.md) for the manifest guard, generation coverage, symbolic IDs and failure behavior. + +See [portable follower predicate](portable-follower.md) for the native +`world.follower.spawn` lifecycle hook and its generation/artwork boundaries. diff --git a/docs/portable-follower.md b/docs/portable-follower.md new file mode 100644 index 000000000..3efebd839 --- /dev/null +++ b/docs/portable-follower.md @@ -0,0 +1,78 @@ +# Portable follower predicate + +`world.follower.spawn(next, game, world)` controls whether the native optional +companion exists. It receives the original opaque Game/world handles in every +SDK mode. Return `next(game, world)` to keep the host's native decision, or a +Lua truthy/falsey result to enable/disable it. In particular, `0` and `""` enable +it; `false` and `nil` disable it. This is a lifecycle predicate, not a once-only +spawn notification: do not depend on a callback count per frame. + +```lua +local mod = ... +if mod:supports("hooks", "world.follower.spawn") then + mod.hooks:wrap("world.follower.spawn", function(next, game, world) + if myOptions.companion then return true end + return next(game, world) + end) +end +``` + +The producer runs on native map entry and normal follower updates. A denial +removes the companion from its native update/draw lists; a later allowance +recreates it. Removing the wrapper restores the native predicate. Wrapper +errors retain the normal hook bus recovery rules, including keeping a +completed downstream decision after a post-`next` failure. API 3 repeated +`next` evaluates the downstream chain once per invocation. + +## Native scope + +- Gen 1 uses the existing Pikachu controller. The unchanged Yellow default + still checks the starter event, healthy party Pikachu, biking/surfing and + available artwork. Other GB games default to no follower. Existing scripted + Yellow scenes retain their own update ownership. +- Gen 2 and GBA default to no companion. An allowance creates a passable native + NPC which follows committed player steps; the ordinary world NPC loop owns + its interpolation and rendering. Gold-family mid-map spawns start behind + the player when walkable; map loads and GBA spawns start under the player. + Connection carry preserves the NPC and buffered trail. GBA same-map long + teleports reset the companion to the player. These optional companions do + not enter Yellow's emotion/dialogue controller. +- DS reports the hook unavailable. Scripted escorts and HGSS's walking Pokemon + keep their native controllers; this hook does not take ownership of them. + +Artwork and actor internals are not normalized by this predicate. Gen2Recomped +uses `SPRITE_PIKACHU` when present, otherwise the player's sheet (then the native +placeholder fallback); each companion owns a renderer. The fallback never +writes a registry alias. The pinned Gen1 GBA controller instead leaves its +optional renderer to the mod. An artwork mod that imports or replaces private +controllers still needs a public artwork/actor contract before it is portable. +Yellow's existing native eligibility and scene mechanics also differ between +the engines; this PR preserves their defaults rather than changing them. + +## Validation + +The unchanged `examples/portable_follower` entry and manifest are loaded through +both real SDKs. Shared tests drive the real GB, Gold and GBA follower modules +and native NPC interpolation, with fixture assets/session data only. No fake +follower or hand-called Runtime hook substitutes for the native controllers. +The reference is a clean Gen1 checkout at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. + +The checker covers Red/Blue/Yellow/Gold/Silver/Crystal/FireRed/Emerald in API 1 +native, API 2 native/unified and API 3. Gen1 API 2 has no optional unified mode; +that comparison exercises Gen2's opt-in format against Gen1's native format. +Cases include native defaults, original handles, allow/deny, every interpolated +frame of a four-direction walk, map entry, GB/Gold connection carry/rebase, GBA +teleport, Lua truthiness, wrapper failure, removal, API-3 repeated `next`, and +failed-entry cleanup. These are controller/SDK contracts, not identical artwork, +Yellow scene parity or full-playthrough validation. + +Gen2 unit tests additionally check every host version, DS exclusions, unrelated +NPC preservation, fallback rendering ownership, cast invalidation and legacy +SDK behavior. + +```sh +luajit tests/engine/follower_spawn.lua +python3 tools/follower-contract-check/check.py --gen1 /path/to/clean/gen1 \ + --api 3 --format unified --report /tmp/follower-contract.json +``` diff --git a/examples/portable_follower/main.lua b/examples/portable_follower/main.lua new file mode 100644 index 000000000..b3c8f6ef9 --- /dev/null +++ b/examples/portable_follower/main.lua @@ -0,0 +1,18 @@ +local mod = ... +local state = { mode = "native", calls = 0 } +mod.exports.api, mod.exports.state = mod, state +local stop +if not mod.supports or mod:supports("hooks", "world.follower.spawn") then + stop = mod.hooks:wrap("world.follower.spawn", function(next, game, world) + state.calls, state.game, state.world = state.calls + 1, game, world + if state.mode == "native" then + return next(game, world) + end + return state.mode == "allow" + end) +end +mod.exports.stop = function() + if stop then + stop() + end +end diff --git a/examples/portable_follower/manifest.json b/examples/portable_follower/manifest.json new file mode 100644 index 000000000..8d7e6f494 --- /dev/null +++ b/examples/portable_follower/manifest.json @@ -0,0 +1 @@ +{"id":"portable_follower","name":"Portable follower predicate","version":"1.0.0","api":3,"games":["all"],"entry":"main.lua","affects_link":false} diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index dc3fbfe5c..946d8f973 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -33,6 +33,7 @@ add("hooks", "123", "battle.overlay ui.naming.grid ui.party.submenu ui.start_men add("hooks", "12", "ui.pc.items") add("hooks", "12345", "battle.status_hud_visible") add("hooks", "123", "pokemon.level_visible ui.party.grid_navigation") +add("hooks", "123", "world.follower.spawn") add("hooks", "12", "intro.oak_speech.build") add("events", "12345", [[ diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index ba100053d..4414a5f31 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -24,7 +24,7 @@ C.hooks = names([[ music.select music.volume player.sprite pokemon.icon pokemon.level_visible pokemon.sprite render.compose render.hud render.letterbox render.zones save.new_game save.write screen.render_visible script.command trainer.before_battle trainer.party transition.style ui.list_menu ui.naming.grid ui.options.rows ui.party.submenu ui.pc.items ui.start_menu.items - ui.title_menu.items warp.destination world.talk world.tod zoom.range + ui.title_menu.items warp.destination world.follower.spawn world.talk world.tod zoom.range ]]) C.events = names([[ world.live_maps_preparing world.live_maps_updated @@ -41,6 +41,7 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["world.follower.spawn"] = { false, false }, ["ui.party.grid_navigation"] = { false }, ["battle.move_grid_navigation"] = { false }, ["battle.menu_auxiliary"] = { false, "context" }, diff --git a/src/world/OptionalFollower.lua b/src/world/OptionalFollower.lua new file mode 100644 index 000000000..c89bf6329 --- /dev/null +++ b/src/world/OptionalFollower.lua @@ -0,0 +1,143 @@ +-- Opt-in GB Color/GBA companion. The public predicate selects whether it +-- exists; the native NPC update/draw lists own movement and rendering. +local NPC = require("src.world.NPC") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local F = {} +local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + +local function nativeDefault() + return false +end +local function current(world) + for i, npc in ipairs(world.npcs or {}) do + if npc.optionalFollower then + return npc, i + end + end +end +local function remove(world) + local npc, at = current(world) + if not npc then + return + end + table.remove(world.npcs, at) + for i, entity in ipairs(world.entities or {}) do + if entity == npc then + table.remove(world.entities, i) + break + end + end + world.follower, world.cast = nil, nil +end +local function spawn(game, world, opts, viaMapLoad) + local keep = opts and (opts.keepFollower or opts.keepPikachu) + if keep then + table.insert(world.npcs, keep) + table.insert(world.entities, keep) + world.follower, world.cast = keep, nil + return + end + local p, map = world.player, world.map + local x, y = p.cellX, p.cellY + if not viaMapLoad and Version.generation() == 2 then + local d = DELTA[p.facing] or DELTA.down + if map:inBounds(x - d[1], y - d[2]) and map:isWalkableCell(x - d[1], y - d[2]) then + x, y = x - d[1], y - d[2] + end + end + -- A missing optional sheet must not mutate the content registry. Give the + -- NPC its own renderer over the player definition (then NPC's placeholder + -- fallback if neither sheet exists), rather than sharing a renderer. + local data = game.data + if not (data.sprites and data.sprites.SPRITE_PIKACHU) then + local def = p.spriteDef or (p.sprite and p.sprite.def) + if def then + data = setmetatable({ + sprites = setmetatable({ SPRITE_PIKACHU = def }, { __index = data.sprites }), + }, { __index = data }) + end + end + local npc = NPC.new(data, map.id, { + index = 250, + name = "FOLLOWER", + sprite = "SPRITE_PIKACHU", + movement = "STAY", + range = "NONE", + x = x, + y = y, + }) + npc.optionalFollower, npc.follower, npc.pikachuFollower, npc.passable = true, true, true, true + npc.facing, npc.elevation = p.facing or "down", p.elevation + table.insert(world.npcs, npc) + table.insert(world.entities, npc) + world.follower, world.cast = npc, nil + world.followerTrail = { x = p.cellX, y = p.cellY } + world.pikachuTrail = world.followerTrail +end + +function F.onMapEntered(game, world, opts, viaMapLoad) + if not (world and world.map and world.player) then + return + end + remove(world) + if Runtime.call("world.follower.spawn", nativeDefault, game, world) then + spawn(game, world, opts, viaMapLoad) + end +end + +function F.update(game, world) + if not (world and world.map and world.player) then + return + end + -- Lua truthiness is the Gen1 predicate contract, including 0 and "". + if not Runtime.call("world.follower.spawn", nativeDefault, game, world) then + remove(world) + return + end + local npc, p = current(world), world.player + if not npc then + spawn(game, world) + return + end + local trail = world.followerTrail + if not trail then + trail = { x = p.cellX, y = p.cellY } + world.followerTrail, world.pikachuTrail = trail, trail + end + if Version.generation() == 3 and math.abs(p.cellX - trail.x) + math.abs(p.cellY - trail.y) > 6 then + remove(world) + spawn(game, world) + return + end + local x, y = p.targetX or p.cellX, p.targetY or p.cellY + if x ~= trail.x or y ~= trail.y then + npc.goalX, npc.goalY = trail.x, trail.y + trail.x, trail.y = x, y + end + npc.elevation = p.elevation + if npc.moving or not npc.goalX then + return + end + local dx, dy = npc.goalX - npc.cellX, npc.goalY - npc.cellY + local distance = math.abs(dx) + math.abs(dy) + if distance == 0 then + npc.goalX, npc.goalY = nil, nil + return + end + if distance > 6 then + npc.cellX, npc.cellY = npc.goalX, npc.goalY + npc.px, npc.py = npc.cellX * 16, npc.cellY * 16 + npc.goalX, npc.goalY = nil, nil + return + end + npc.facing = dx > 0 and "right" or dx < 0 and "left" or dy > 0 and "down" or "up" + local d = DELTA[npc.facing] + npc.stepDir, npc.targetX, npc.targetY = npc.facing, npc.cellX + d[1], npc.cellY + d[2] + local frames = p.stepFramesCur or p.stepFrames or 16 + npc.stepFrames = distance > 1 and math.max(1, math.floor(frames / 2)) or frames + npc.progress, npc.moving = 0, true + -- The world's normal NPC loop precedes this controller, so advance frame 1. + npc:update(world.map, world.entities) +end +return F diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index 708f386b6..100cc8ccb 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -17,6 +17,7 @@ local Collision = require("src.world.Collision") local GameVersion = require("src.core.GameVersion") +local ModRuntime = require("src.mods.Runtime") local PikachuFollower = {} @@ -166,6 +167,7 @@ local function remove(ow) for j, e in ipairs(ow.entities or {}) do if e == npc then table.remove(ow.entities, j) break end end + ow.cast = nil end -- spawn cell: directly behind the player's facing when that cell is @@ -189,11 +191,16 @@ function PikachuFollower.current(ow) end function PikachuFollower.onMapEntered(game, ow, opts) + local generation = GameVersion.generation() + if generation == 2 or generation == 3 then + return require("src.world.OptionalFollower").onMapEntered(game, ow, opts, true) + end + if generation ~= 1 then return end -- Bill's House owns a short scripted scene that deliberately keeps -- Pikachu off the normal trailing loop. A new map instance ends it. ow.pikachuBillsScene = nil remove(ow) - if not shouldSpawn(game, ow) then return end + if not ModRuntime.call("world.follower.spawn", shouldSpawn, game, ow) then return end -- opts.keepPikachu is the follower a connection crossing kept alive: -- LoadMapHeader's connection path sets wPikachuSpawnState = 2 and bit 4 -- of wPikachuOverworldStateFlags, so SchedulePikachuSpawnForAfterText @@ -205,6 +212,7 @@ function PikachuFollower.onMapEntered(game, ow, opts) if keep then table.insert(ow.npcs, keep) table.insert(ow.entities, keep) + ow.cast = nil return end local x, y = spawnCell(ow) @@ -212,6 +220,7 @@ function PikachuFollower.onMapEntered(game, ow, opts) table.insert(ow.npcs, npc) -- entities is the draw list; passable keeps it out of collision table.insert(ow.entities, npc) + ow.cast = nil ow.pikachuTrail = { x = ow.player.cellX, y = ow.player.cellY } end @@ -395,14 +404,19 @@ end -- one follow step per frame: chase the cell the player last vacated -- (pikachu_follow.asm keeps it one walk step behind) function PikachuFollower.update(game, ow) + local generation = GameVersion.generation() + if generation == 2 or generation == 3 then + return require("src.world.OptionalFollower").update(game, ow) + end + if generation ~= 1 then return end if ow.pikaHop then return end -- the counter hop owns the follower (#417) if ow.pikachuBillsScene then return end local npc = findFollower(ow) if not npc then - if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end + if ModRuntime.call("world.follower.spawn", shouldSpawn, game, ow) then PikachuFollower.onMapEntered(game, ow) end return end - if not shouldSpawn(game, ow) then + if not ModRuntime.call("world.follower.spawn", shouldSpawn, game, ow) then remove(ow) return end @@ -681,6 +695,7 @@ local function bubbleIndex(game, name) end function PikachuFollower.talk(game, ow, npc, done) + if GameVersion.generation() ~= 1 then return false end -- pikachu_follow.asm steps the follower on the player's own walk clock, -- so it is never mid-tile while the player stands and can always be -- addressed; this port's follow is a frame late, so land the step here diff --git a/tests/engine/follower_spawn.lua b/tests/engine/follower_spawn.lua new file mode 100644 index 000000000..23bc6a17d --- /dev/null +++ b/tests/engine/follower_spawn.lua @@ -0,0 +1,99 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Fixture, Cases = dofile("tests/follower/fixture.lua"), dofile("tests/follower/cases.lua") +local old = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local gen, e = f.gen, f.e + T.eq(e.api:supports("hooks", "world.follower.spawn"), gen <= 3, "honest native generation capability") + if gen <= 3 then + Cases.run(f) + -- Preserve unrelated actors, native map seam identity and in-flight + -- interpolation, including the trail's rebase alias. + local off = e.api.hooks:wrap("world.follower.spawn", function() + return true + end, 100) + local bystander = { id = "native-object" } + f.world.npcs[#f.world.npcs + 1] = bystander + f.world.entities[#f.world.entities + 1] = bystander + f.tick() + local npc = f.current() + local trail = f.world.pikachuTrail + local tx, ty = trail.x, trail.y + f.world.cast = { "stale" } + f.enter({ keepPikachu = npc }) + T.eq(f.current(), npc, "connection retains the actual companion") + T.eq(f.world.pikachuTrail, trail, "connection retains buffered trail") + T.eq(f.world.cast, nil, "map entry invalidates entity cast") + f.controller.rebase(f.world, -3, 2) + T.eq(f.world.pikachuTrail.x, tx - 3, "rebase mutates trail x in place") + T.eq(f.world.pikachuTrail.y, ty + 2, "rebase mutates trail y in place") + T.eq(npc.px, npc.cellX * 16, "rebased native pixel x agrees with cell") + if gen >= 2 then + T.eq(f.world.followerTrail, trail, "optional companion shares rebase alias") + T.eq( + f.controller.talk(f.game, f.world, npc), + false, + "optional companion cannot enter Yellow dialogue" + ) + end + off() + local deny = e.api.hooks:wrap("world.follower.spawn", function() + return false + end, 100) + f.tick() + T.eq(f.world.npcs[1], bystander, "denial preserves unrelated native NPC") + T.eq(f.world.entities[2], bystander, "denial preserves unrelated draw entry") + deny() + if gen >= 2 then + -- Fallback artwork must not add a content alias or share the player's + -- mutable renderer. This is an asset seam, not an alternative mod API. + f.game.data.sprites.SPRITE_PIKACHU = nil + local def = f.player.spriteDef + f.player.spriteDef = nil + f.player.sprite = require("src.render.SpriteRenderer").new(def, "fixture-player") + local allow = e.api.hooks:wrap("world.follower.spawn", function() + return true + end, 100) + f.enter() + T.eq(f.current().sprite.def, def, "missing optional sheet uses player definition") + T.neq(f.current().sprite, f.player.sprite, "follower owns a separate renderer") + T.eq(f.game.data.sprites.SPRITE_PIKACHU, nil, "fallback never mutates content registry") + if gen == 3 then + f.player.cellX, f.player.cellY, f.player.px, f.player.py = 20, 20, 320, 320 + f.tick() + T.eq(f.current().cellX, 20, "same-map GBA teleport resets companion x") + T.eq(f.current().cellY, 20, "same-map GBA teleport resets companion y") + end + allow() + end + else + local escort = { id = "scripted-escort", gen4Follower = true } + f.world.npcs, f.world.entities = { escort }, { f.player, escort } + local calls = 0 + local off = e.api.hooks:wrap("world.follower.spawn", function() + calls = calls + 1 + return true + end) + f.enter() + f.tick() + T.eq(calls, 0, "DS has no promised follower producer") + T.eq(f.world.npcs[1], escort, "DS native escort is untouched") + off() + end + f.close() + local failed = Fixture.new(".", "gen2", version, mode[1], mode[2], true) + T.check(#failed.result.errors > 0, "entry rollback is reported") + failed.tick() + T.eq(failed.current() ~= nil, version == "yellow", "failed mod leaves native follower default") + for _, entry in ipairs(Runtime.hooks.chains["world.follower.spawn"] or {}) do + T.check(entry.owner ~= "portable_follower", "entry rollback removes owned hook") + end + failed.close() + end +end +Version.set(old) +T.finish("portable native follower predicate") diff --git a/tests/follower/cases.lua b/tests/follower/cases.lua new file mode 100644 index 000000000..b5bbd9310 --- /dev/null +++ b/tests/follower/cases.lua @@ -0,0 +1,133 @@ +local M = {} +function M.run(f) + local T, state, rows = f.T, f.e.state, {} + local function observe(label) + local n = f.current() + rows[#rows + 1] = { label = label, present = n ~= nil } + end + f.tick() + T.eq(f.current() ~= nil, f.version == "yellow", "native default remains Yellow-only") + observe("native") + state.mode = "deny" + f.tick() + T.eq(f.current(), nil, "public predicate removes companion") + state.mode = "allow" + f.tick() + local npc = f.current() + T.check(npc ~= nil, "public predicate spawns actual native companion") + T.eq(npc.passable, true, "native follower never blocks player") + T.eq(state.game, f.game, "hook receives original opaque Game") + T.eq(state.world.player, f.player, "hook receives native player") + T.eq(state.world.map.id, f.world.map.id, "hook receives active map") + if not (f.backend == "gen1" and f.gen == 3) then + T.eq(state.world, f.world, "opaque world identity is preserved") + T.eq(f.world.npcs[#f.world.npcs], npc, "native update list owns follower") + T.eq(f.world.entities[#f.world.entities], npc, "native draw list owns follower") + end + observe("allow") + -- A moving player commits before the NPC controller. Check each native + -- interpolated frame, not a hand-called Runtime hook or a fake follower. + for _, dir in ipairs({ { "right", 1, 0 }, { "down", 0, 1 }, { "left", -1, 0 }, { "up", 0, -1 } }) do + local p = f.player + p.facing, p.targetX, p.targetY, p.moving = dir[1], p.cellX + dir[2], p.cellY + dir[3], true + for frame = 1, 4 do + f.tick() + npc = f.current() + T.check(npc ~= nil, "native companion survives movement") + rows[#rows + 1] = { dir = dir[1], frame = frame, x = npc.px, y = npc.py, moving = npc.moving == true } + end + p.cellX, p.cellY, p.px, p.py = p.targetX, p.targetY, p.targetX * 16, p.targetY * 16 + p.targetX, p.targetY, p.moving = nil, nil, false + end + state.mode = "deny" + f.tick() + T.eq(f.current(), nil, "denial removes follower during native update") + if not (f.backend == "gen1" and f.gen == 3) then + T.eq(#f.world.npcs, 0, "removal clears update list") + T.eq(#f.world.entities, 1, "removal retains player draw entry") + end + observe("deny") + state.mode = "allow" + f.enter() + T.check(f.current() ~= nil, "native map entry honors predicate") + observe("map entry") + if f.gen <= 2 then + local keep = f.current() + local x, y = keep.px, keep.py + f.enter({ keepPikachu = keep }) + T.eq(f.current(), keep, "native connection keeps companion identity") + f.controller.rebase(f.world, -2, 1) + T.eq(keep.px, x - 32, "native connection rebases companion x") + T.eq(keep.py, y + 16, "native connection rebases companion y") + rows[#rows + 1] = { label = "connection", dx = keep.px - x, dy = keep.py - y } + elseif f.gen == 3 then + f.player.cellX, f.player.cellY, f.player.px, f.player.py = 20, 20, 320, 320 + f.tick() + T.eq(f.current().cellX, 20, "GBA same-map teleport resets companion x") + T.eq(f.current().cellY, 20, "GBA same-map teleport resets companion y") + rows[#rows + 1] = { label = "teleport", x = f.current().px, y = f.current().py } + end + local off = f.e.api.hooks:wrap("world.follower.spawn", function() + return false + end, 100) + f.tick() + T.eq(f.current(), nil, "higher-priority wrapper controls native lifecycle") + off() + f.tick() + T.check(f.current() ~= nil, "unsubscribe restores consumer") + f.e.stop() + f.e.stop() + f.tick() + T.eq(f.current() ~= nil, f.version == "yellow", "unsubscribe restores native predicate") + observe("unsubscribed") + for _, value in ipairs({ false, true, 0, "", {} }) do + local restore = f.e.api.hooks:wrap("world.follower.spawn", function() + return value + end, 100) + f.tick() + T.eq(f.current() ~= nil, not not value, "native predicate uses Lua truthiness") + observe("truthiness:" .. type(value) .. ":" .. tostring(not not value)) + restore() + end + local nilResult = f.e.api.hooks:wrap("world.follower.spawn", function() + return nil + end, 100) + f.tick() + T.eq(f.current(), nil, "nil predicate result removes follower") + observe("nil") + nilResult() + for _, after in ipairs({ false, true }) do + local failing = f.e.api.hooks:wrap("world.follower.spawn", function(next, game, world) + if after then + next(game, world) + end + error("intentional wrapper failure") + end, 100) + f.tick() + T.eq(f.current() ~= nil, f.version == "yellow", "wrapper failure preserves downstream native default") + observe("failure:" .. tostring(after)) + failing() + end + if f.api >= 3 then + local calls = 0 + local count = f.e.api.hooks:wrap("world.follower.spawn", function() + calls = calls + 1 + return true + end, 50) + local replay = f.e.api.hooks:wrap("world.follower.spawn", function(next, game, world) + local before = calls + local a, b = next(game, world), next(game, world) + T.eq(a, true, "first next returns downstream predicate") + T.eq(b, a, "API-3 repeated next preserves result") + T.eq(calls, before + 1, "API-3 repeated next calls downstream once") + return a + end, 100) + f.tick() + T.check(f.current() ~= nil, "replayed next still drives native spawning") + replay() + count() + observe("replay") + end + return rows +end +return M diff --git a/tests/follower/fixture.lua b/tests/follower/fixture.lua new file mode 100644 index 000000000..3e9587938 --- /dev/null +++ b/tests/follower/fixture.lua @@ -0,0 +1,153 @@ +local M = {} +function M.new(root, backend, version, api, format, failing) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local gen = Version.generation() + local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + data.tilesets = data.tilesets or {} + data.tilesets.FIX_OUT = data.tilesets.FIX_OUT or T.fixtures.fresh().tilesets.FIX_OUT + data.sprites = data.sprites or {} + local sheet = T.fixtures.fresh().sprites.SPRITE_FIX_NPC + data.sprites.SPRITE_PIKACHU = sheet + data.gen2Sprites = data.gen2Sprites or {} + data.gen2Sprites.SPRITE_PIKACHU = sheet + local Json, files = require("src.link.Json"), {} + for _, name in ipairs({ "manifest.json", "main.lua" }) do + local file = assert(io.open(root .. "/examples/portable_follower/" .. name, "rb")) + files["mods/portable_follower/" .. name] = file:read("*a") + file:close() + end + local manifest = Json.decode(files["mods/portable_follower/manifest.json"]) + manifest.api, manifest.callback_format = api, format + files["mods/portable_follower/manifest.json"] = Json.encode(manifest) + if failing then + files["mods/portable_follower/main.lua"] = files["mods/portable_follower/main.lua"] + .. '\nerror("entry rollback probe")' + end + local result = T.sdk.loadMods( + { "mods/portable_follower" }, + { fs = T.sdk.memfs(files), data = data, dev = false } + ) + if not failing then + assert(#result.errors == 0, table.concat(result.errors, "\n")) + end + local game = { + data = data, + save = { + version = version, + options = {}, + flags = { EVENT_GOT_STARTER = true, EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true }, + player = { id = 7, name = "PROBE" }, + party = { { species = "PIKACHU", hp = 10, otId = 7, ot = "PROBE" } }, + }, + } + local def = { + id = "FIX_TOWN", + width = 12, + height = 12, + tileset = "FIX_OUT", + blocks = {}, + objects = {}, + connections = {}, + } + for i = 1, 144 do + def.blocks[i] = 1 + end + data.tilesets.FIX_OUT.walkable = { 0, 1, 2 } + data.tilesets.FIX_OUT._walkableSet = nil + if backend == "gen1" and gen == 2 then + data.tilesets.FIX_OUT.collision = { {}, { 0, 0, 0, 0 } } + end + -- Gen1 GBA's real follower reads the Field session map ID, not a GB Map. + local map = backend == "gen1" and gen == 3 and { id = def.id } + or require("src.world.Map").new(def, data.tilesets.FIX_OUT) + local player = { + cellX = 4, + cellY = 4, + px = 64, + py = 64, + facing = "down", + stepFrames = 4, + stepFramesCur = 4, + moving = false, + spriteDef = sheet, + elevation = 3, + } + local world = { map = map, player = player, npcs = {}, entities = { player }, sprites = data.gen2Sprites } + local restores = {} + local function patch(owner, name, value) + local old = owner[name] + restores[#restores + 1] = function() + owner[name] = old + end + owner[name] = value + end + local controller + if backend == "gen1" and gen == 3 then + controller = require("src.world.game3.Follower") + local Field = require("src.core.game3.field") + player = require("src.core.game3.player") + for name, value in pairs(world.player) do + patch(player, name, value) + end + for _, name in ipairs({ "targetX", "targetY", "surfing" }) do + patch(player, name, nil) + end + world.player = player + patch(Field, "running", true) + patch(Field, "getSession", function() + return { map = world.map.id } + end) + controller.reset() + elseif backend == "gen1" and gen == 2 then + controller = require("src.world.gen2.Follower") + else + controller = require("src.world.PikachuFollower") + end + game.world, game.overworld, result.loader.game = world, world, game + local function current() + return controller.current(world) + end + local function tick() + local npc = current() + if npc and npc.moving and not (backend == "gen1" and gen == 3) then + npc:update(world.map, world.entities) + end + controller.update(game, world) + end + return { + T = T, + e = result.loader.exports.portable_follower, + result = result, + game = game, + world = world, + player = player, + controller = controller, + current = current, + tick = tick, + gen = gen, + version = version, + backend = backend, + api = api, + patch = patch, + enter = function(opts) + if backend == "gen1" and gen == 3 then + controller.onMapEntered() + tick() + else + controller.onMapEntered(game, world, opts, gen == 2) + end + end, + close = function() + if backend == "gen1" and gen == 3 then + controller.reset() + end + for i = #restores, 1, -1 do + restores[i]() + end + result.release() + end, + } +end +return M diff --git a/tests/follower/worker.lua b/tests/follower/worker.lua new file mode 100644 index 000000000..f1710d4cc --- /dev/null +++ b/tests/follower/worker.lua @@ -0,0 +1,14 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local f = dofile(root .. "/tests/follower/fixture.lua").new(root, backend, version, tonumber(api), format) +local rows = dofile(root .. "/tests/follower/cases.lua").run(f) +f.close() +local failed = + dofile(root .. "/tests/follower/fixture.lua").new(root, backend, version, tonumber(api), format, true) +failed.T.check(#failed.result.errors > 0, "entry failure is recorded") +failed.tick() +failed.T.eq(failed.current() ~= nil, version == "yellow", "entry rollback restores native predicate") +rows[#rows + 1] = { label = "rollback", present = failed.current() ~= nil } +failed.close() +print("FOLLOWER_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable native follower") diff --git a/tools/follower-contract-check/check.py b/tools/follower-contract-check/check.py new file mode 100644 index 000000000..a7b181f72 --- /dev/null +++ b/tools/follower-contract-check/check.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Compare the same public follower consumer through both native controllers.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=3) + p.add_argument("--format", choices=["native", "unified"], default="unified") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + versions = ["red", "blue", "yellow", "gold", "silver", "crystal"] + versions += ["firered", "emerald"] + for version in versions: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2/"tests/follower/worker.lua"), str(gen2), + backend, version, str(a.api), a.format], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("FOLLOWER_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("FOLLOWER_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "scope": "native predicate/lifecycle/movement", + "observations": rows, "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"follower mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching native follower observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), "api": a.api, + "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_follower/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() From b0a80349f773094db34ef100232b4ee8ff96a90f Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 00:46:18 +0200 Subject: [PATCH 038/138] async-get native compatibility --- .github/workflows/async-get.yml | 38 ++++++++ docs/modding.md | 24 +++++ main.lua | 3 + src/core/HostShell.lua | 67 ++++++++++++++ src/mods/Loader.lua | 4 + src/mods/Net.lua | 80 ++++++++++++++++ src/net/AsyncGet.lua | 152 +++++++++++++++++++++++++++++++ src/net/Fetch.lua | 31 ++++++- src/net/get_worker.lua | 38 ++++++++ tests/engine/async_get_queue.lua | 43 +++++++++ tests/engine/net_permission.lua | 28 ++++++ tools/async-get-check/conf.lua | 6 ++ tools/async-get-check/main.lua | 75 +++++++++++++++ tools/check_async_get.py | 30 ++++++ 14 files changed, 615 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/async-get.yml create mode 100644 src/mods/Net.lua create mode 100644 src/net/AsyncGet.lua create mode 100644 src/net/get_worker.lua create mode 100644 tests/engine/async_get_queue.lua create mode 100644 tests/engine/net_permission.lua create mode 100644 tools/async-get-check/conf.lua create mode 100644 tools/async-get-check/main.lua create mode 100644 tools/check_async_get.py diff --git a/.github/workflows/async-get.yml b/.github/workflows/async-get.yml new file mode 100644 index 000000000..cb03a8521 --- /dev/null +++ b/.github/workflows/async-get.yml @@ -0,0 +1,38 @@ +name: async mod GET +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + transport: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.x' + - if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y love + - name: Exercise real worker requests against a local server + shell: python + run: | + import os, pathlib, shutil, subprocess, urllib.request, zipfile + host = os.environ['RUNNER_OS'] + if host == 'Linux': + executable = shutil.which('love') + else: + suffix = 'macos' if host == 'macOS' else 'win64' + target = pathlib.Path(os.environ['RUNNER_TEMP']) / 'love-get-check' + target.mkdir() + archive = target / 'runtime.zip' + urllib.request.urlretrieve(f'https://github.com/love2d/love/releases/download/11.5/love-11.5-{suffix}.zip', archive) + with zipfile.ZipFile(archive) as z: z.extractall(target) + executable = next(target.rglob('love.app/Contents/MacOS/love')) if host == 'macOS' else next(target.rglob('lovec.exe')) + if host == 'macOS': executable.chmod(0o755) + subprocess.run(['python', 'tools/check_async_get.py', '--love', str(executable)], check=True) diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..e2a395416 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -1,5 +1,29 @@ # Native modding +## Background HTTP GET + +A mod declaring `"network"` receives `mod.fetch:get(url, options)`, `:poll(handle)`, +`:cancel(handle)`, `:release(handle)`, `:available()` and `:capabilities()`. +Handles belong to their creating mod. Poll returns a detached table with +`status` (`pending`, `ok`, `error` or `cancelled`), `body`, `err`, +`receivedBytes` and, when known, `httpStatus`. Always release finished handles. +Rollback and unload release a mod's requests automatically. + +GET transport runs in three LÖVE workers. The engine caps active/queued work at +16 requests, with four pending requests per mod. `maxSeconds` defaults to 20 +and caps at 30, including queue time; `maxBytes` defaults to 4 MiB and caps at +16 MiB. `boundedHttpsGet = 1` requires HTTPS throughout redirects. Desktop +capabilities advertise this constraint. Desktop curl is required; mobile +bridges are not yet supported by this API. Remote URLs and headers are passed +through a private curl config file, outside shell syntax. + +Cancel discards the result immediately. A curl already executing may occupy +its worker until its bounded timeout; releasing a handle cannot bypass the +global worker limit. Shutdown waits for those bounded operations. This adds +GET support; it does not implement POST, arbitrary requests or `mod.job`. +The older download-to-file API still performs a synchronous first poll. + + The modding book lives on the [project wiki](https://github.com/bryanthaboi/gen1recomp/wiki). diff --git a/main.lua b/main.lua index 18325ffdf..8da697a54 100644 --- a/main.lua +++ b/main.lua @@ -1190,6 +1190,9 @@ function love.quit() if package.loaded["src.translation.Service"] then pcall(package.loaded["src.translation.Service"].shutdown) end + if package.loaded["src.net.Fetch"] then + pcall(package.loaded["src.net.Fetch"].shutdown) + end end function love.filedropped(file) diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 72e75e2ba..004a4ae4f 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -381,4 +381,71 @@ function HostShell.httpGet(url, userAgent, accept) return body end + +-- Worker-only GET. Curl arguments containing remote input live in a config +-- file, never in shell syntax (also preserves percent-encoded Windows URLs). +-- errorPath belongs to the engine's private per-job workspace, not the mod. +function HostShell.httpGetBounded(url, opts) + opts = opts or {} + if not HostShell.canSpawnProcess() then return nil, "bounded GET requires desktop curl" end + if type(url) ~= "string" or #url > 8192 or url:find("[%z%c%s]") + or not url:lower():match("^https?://[^/?#]+") then return nil, "invalid HTTP URL" end + if opts.httpsOnly and not url:lower():match("^https://") then return nil, "HTTPS required" end + local function bounded(n, default, cap) + n = tonumber(n) + if not n or n ~= n then n = default end + return math.max(0.1, math.min(cap, n)) + end + local seconds = bounded(opts.maxSeconds, 20, 30) + local bytes = math.floor(bounded(opts.maxBytes, 4 * 1024 * 1024, 16 * 1024 * 1024)) + if bytes < 1 then return nil, "invalid byte limit" end + local path = opts.errorPath + if type(path) ~= "string" or path == "" or path:find("[%z%c]") then return nil, "missing worker path" end + local windows = love.system.getOS() == "Windows" + -- cmd expands these even inside quotes. Only the local config pathname is + -- shell-visible; refuse an unsupported local pathname rather than expand it. + if windows and path:find('[%%!"^]') then return nil, "unsupported characters in worker path" end + local configPath = path .. ".cfg" + local function quoted(value) + value = tostring(value) + if value:find("[%z%c]") then return nil end + return '"' .. value:gsub("\\", "\\\\"):gsub('"', '\\"') .. '"' + end + local ua, accept = quoted(opts.userAgent or "gen2recomp"), quoted(opts.accept or "*/*") + if not ua or not accept then return nil, "invalid HTTP header" end + local config = table.concat({"silent", "show-error", "fail", "location", "globoff", + "connect-timeout = " .. math.min(10, seconds), "max-time = " .. seconds, + "max-filesize = " .. bytes, "max-redirs = 5", + 'proto = "=' .. (opts.httpsOnly and "https" or "http,https") .. '"', + 'proto-redir = "=' .. (opts.httpsOnly and "https" or "http,https") .. '"', + "user-agent = " .. ua, "header = " .. quoted("Accept: " .. (opts.accept or "*/*")), + "stderr = " .. quoted(path), 'write-out = "\\n%{http_code}"', "url = " .. quoted(url), ""}, "\n") + local f, err = io.open(configPath, "wb") + if not f then return nil, tostring(err) end + local wrote, why = f:write(config); local closed = f:close() + if not wrote or not closed then os.remove(configPath); return nil, tostring(why or "config write failed") end + local pipe = HostShell.popen("curl --disable --config " .. HostShell.quote(configPath), windows and "rb" or "r") + if not pipe then os.remove(configPath); return nil, "could not start curl" end + local ok, body = pcall(pipe.read, pipe, bytes + 5) + local closeOk, done, _, code = pcall(pipe.close, pipe) + os.remove(configPath) + local ef = io.open(path, "rb") + local complaint + if ef then complaint = HostShell.curlComplaint(ef:read(4096)); ef:close() end + os.remove(path) + if not ok then return nil, "response read failed" end + body = body or "" + local status = tonumber(body:match("\n(%d%d%d)$")) + if status then body = body:sub(1, -5) end + if #body > bytes then return nil, "response exceeds byte limit" end + if complaint then + return nil, complaint, tonumber(complaint:match("returned error: (%d%d%d)")) + end + if not closeOk or done == nil or (type(code) == "number" and code ~= 0) then + return nil, "curl failed" + end + if not status or status < 200 or status >= 300 then return nil, "incomplete HTTP response", status end + return body, nil, status +end + return HostShell diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..d90803d85 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -11,6 +11,7 @@ local Data = require("src.core.Data") local Version = require("src.core.Version") local Assets = require("src.render.Assets") local ModUI = require("src.ui.ModUI") +local Net = require("src.mods.Net") local AssetTransform = require("src.mods.AssetTransform") local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") @@ -916,6 +917,7 @@ function Loader:_api(mod) end }, -- the widget toolkit facade (12 4.5) is one shared surface, not -- per-mod state; each widget inside it loads on first touch + fetch = Net.forMod(loader, mod), ui = ModUI, -- namespaced per mod; M11 backs these with save.modData / -- options.modOptions, the shape mods compile against is already final @@ -1196,6 +1198,7 @@ end -- migration it took goes with them. The journal only exists around an -- entry chunk; a later failure (script validation) purges every registry. function Loader:_rollback(modId) + Net.releaseAll(self, modId) for name in pairs(self.journal or self.content) do self.content[name]:rollback(modId) end @@ -1653,6 +1656,7 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + Net.unloadAll() local undone = select(1, ModSeams.unwindAll()) pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) diff --git a/src/mods/Net.lua b/src/mods/Net.lua new file mode 100644 index 000000000..39055c505 --- /dev/null +++ b/src/mods/Net.lua @@ -0,0 +1,80 @@ +-- Per-mod, opaque ownership over the engine's non-blocking GET transport. +local Fetch = require("src.net.Fetch") +local Host = require("src.core.HostShell") +local Net = { MAX_INFLIGHT = 4 } +local loaders = setmetatable({}, {__mode = "k"}) + +function Net.urlDenial(url) + if type(url) ~= "string" or #url > 8192 or url:find("[%z%c%s]") then return "invalid URL" end + if not url:match("^[Hh][Tt][Tt][Pp][Ss]?://[^/?#]+") then return "HTTP or HTTPS URL with a host required" end +end + +local function bucket(loader, owner) + loader.netJobs = loader.netJobs or {} + loader.netJobs[owner] = loader.netJobs[owner] or {} + loaders[loader] = true + return loader.netJobs[owner] +end + +function Net.releaseAll(loader, owner) + local b = loader.netJobs and loader.netJobs[owner] + if not b then return end + for handle, id in pairs(b) do Fetch.cancel(id); Fetch.release(id); b[handle] = nil end + loader.netJobs[owner] = nil +end + +function Net.unloadAll() + for loader in pairs(loaders) do + for owner in pairs(loader.netJobs or {}) do Net.releaseAll(loader, owner) end + end +end + +function Net.forMod(loader, mod) + local owner = mod.manifest.id + if not mod.manifest.permissionSet.network then + local function refuse() error(('mod.fetch for %s needs the "network" permission'):format(owner), 2) end + return { available = function() return false end, get = refuse, + poll = refuse, cancel = refuse, release = refuse, + capabilities = function() return {} end } + end + local api = {} + function api:available() return Host.canSpawnProcess() and Fetch.available() end + function api:capabilities() + return Host.canSpawnProcess() and {boundedHttpsGet = 1} or {} + end + function api:get(url, opts) + local denial = Net.urlDenial(url) + if denial then return nil, denial end + opts = type(opts) == "table" and opts or {} + if opts.boundedHttpsGet == 1 and not url:lower():match("^https://") then return nil, "HTTPS required" end + local b, count = bucket(loader, owner), 0 + for _, id in pairs(b) do if Fetch.isPending(id) then count = count + 1 end end + if count >= Net.MAX_INFLIGHT then return nil, "too many requests in flight" end + local id, err = Fetch.get(url, { userAgent = "gen2recomp-mod/" .. owner, + accept = type(opts.accept) == "string" and opts.accept or nil, + maxSeconds = opts.maxSeconds, maxBytes = opts.maxBytes, boundedHttpsGet = opts.boundedHttpsGet }) + if not id then return nil, err end + local handle = {}; b[handle] = id + return handle + end + function api:poll(handle) + local id = bucket(loader, owner)[handle] + if not id then return {status = "error", err = "unknown request"} end + local state = Fetch.poll(id); local out = {} + for key, value in pairs(state) do out[key] = value end + return out + end + function api:cancel(handle) + local id = bucket(loader, owner)[handle] + if not id then return false end + Fetch.cancel(id); return true + end + function api:release(handle) + local b = bucket(loader, owner); local id = b[handle] + if not id then return false end + Fetch.cancel(id); Fetch.release(id); b[handle] = nil; return true + end + return api +end + +return Net diff --git a/src/net/AsyncGet.lua b/src/net/AsyncGet.lua new file mode 100644 index 000000000..e58dc1aeb --- /dev/null +++ b/src/net/AsyncGet.lua @@ -0,0 +1,152 @@ +-- Bounded engine-owned GET workers. No network or pipe read runs in poll(). +local Get = { MAX_JOBS = 16, MAX_BYTES = 16 * 1024 * 1024 } +local jobs, workers, nextId = {}, {}, 0 +local commands, results, quit, ready, failure, poolDirectory + +local function now() return love.timer.getTime() end + +local function ensure() + if ready ~= nil then return ready end + if not (love and love.thread and love.thread.newThread and love.thread.newChannel) then + ready, failure = false, "background threads unavailable"; return false + end + commands, results, quit = love.thread.newChannel(), love.thread.newChannel(), love.thread.newChannel() + local token = tostring(os.time()) .. "-" .. tostring({}):gsub("[^%w]", "") + poolDirectory = "net-get/" .. token + for workerIndex = 1, 3 do + -- Initialize the writable filesystem on the main thread, before any + -- workers race its first write-directory creation. Each owns its files. + if love.filesystem then + local made, done = pcall(love.filesystem.createDirectory, poolDirectory .. "/" .. workerIndex) + if not made or not done then + ready, failure = false, "could not create GET worker directory" + return false + end + end + end + for workerIndex = 1, 3 do + local ok, thread = pcall(love.thread.newThread, "src/net/get_worker.lua") + if ok and thread then + local started = pcall(thread.start, thread, commands, results, quit, token, workerIndex) + if started then workers[#workers + 1] = thread end + end + end + ready = #workers > 0 + if not ready then failure = "could not start GET workers" end + return ready +end + +local function drain() + if not results then return end + while true do + local msg = results:pop() + if not msg then break end + local job = type(msg) == "table" and jobs[msg.id] + if job then + job.busy = false + if not job.retained then jobs[msg.id] = nil + elseif job.state.status == "pending" then + job.state = { status = msg.ok and "ok" or "error", body = msg.body, + err = msg.err, httpStatus = msg.httpStatus, + receivedBytes = msg.body and #msg.body or 0, progress = msg.ok and 1 or 0 } + end + end + end + for _, thread in ipairs(workers) do + local err = thread:getError() + if err then + ready, failure = false, tostring(err) + quit:push(true); commands:clear() + for _ = 1, #workers do commands:push(false) end + for _, job in pairs(jobs) do + if job.state.status == "pending" then job.state = { status = "error", err = failure } end + end + break + end + end + for _, job in pairs(jobs) do + if job.state.status == "pending" and now() >= job.deadline then + job.cancel:push(true); job.state = { status = "error", err = "request timed out" } + end + end +end + +local function finite(value, fallback, maximum) + value = tonumber(value) + if not value or value ~= value then value = fallback end + return math.max(1, math.min(maximum, value)) +end + +function Get.available() return ensure() end + +function Get.get(url, opts) + opts = type(opts) == "table" and opts or {} + if type(url) ~= "string" or #url > 8192 or url:find("[%z%c%s]") + or not url:lower():match("^https?://[^/?#]+") then return nil, "invalid HTTP URL" end + if opts.boundedHttpsGet == 1 and not url:lower():match("^https://") then return nil, "HTTPS required" end + drain() + local busy = 0 + for _, job in pairs(jobs) do if job.busy then busy = busy + 1 end end + if busy >= Get.MAX_JOBS then return nil, "GET worker queue is full" end + nextId = nextId + 1 + local id = nextId + local seconds = finite(opts.maxSeconds, 20, 30) + local state = { status = "pending", progress = 0, receivedBytes = 0 } + if not ensure() then + jobs[id] = { retained = true, state = {status = "error", err = failure} } + return id + end + local cancel = love.thread.newChannel() + local deadline = now() + seconds + jobs[id] = { retained = true, busy = true, cancel = cancel, state = state, deadline = deadline } + commands:push({ id = id, url = url, cancel = cancel, deadline = deadline, + userAgent = opts.userAgent, accept = opts.accept, + maxSeconds = seconds, maxBytes = math.floor(finite(opts.maxBytes, 4 * 1024 * 1024, Get.MAX_BYTES)), + httpsOnly = opts.boundedHttpsGet == 1 }) + return id +end + +function Get.poll(id) + drain() + local job = jobs[id] + if not job or not job.retained then return {status = "error", err = "unknown request"} end + local out = {} + for key, value in pairs(job.state) do out[key] = value end + return out +end + +function Get.cancel(id) + local job = jobs[id] + if not job or not job.retained then return false end + if job.state.status == "pending" then + job.cancel:push(true); job.state = {status = "cancelled"} + end + return true +end + +function Get.release(id) + local job = jobs[id] + if not job then return end + Get.cancel(id) + job.retained = false + if not job.busy then jobs[id] = nil end +end + +function Get.shutdown() + if quit then quit:push(true) end + if commands then + commands:clear() + for _ = 1, #workers do commands:push(false) end + end + for _, thread in ipairs(workers) do pcall(thread.wait, thread) end + if poolDirectory and love.filesystem then love.filesystem.remove(poolDirectory) end + poolDirectory = nil + for _, job in pairs(jobs) do + job.busy = false + if job.state.status == "pending" then job.state = {status = "cancelled"} end + end + commands, results, quit, ready, failure = nil, nil, nil, nil, nil + workers = {} +end + +return Get diff --git a/src/net/Fetch.lua b/src/net/Fetch.lua index d1bd1268a..8435b368b 100644 --- a/src/net/Fetch.lua +++ b/src/net/Fetch.lua @@ -1,8 +1,8 @@ --- Streaming download API for mods to download large files with progress. --- Wraps HostShell's httpDownload in an async job interface for per-frame pumping. --- Required by mods like HD sheet installer that need progress updates during download. +-- GETs run on bounded engine workers; poll never waits for a GET transport. +-- The older download-to-file API retains its synchronous first-poll behavior. local Fetch = {} +local AsyncGet = require("src.net.AsyncGet") -- Active jobs table: jobId -> { url, dest, size, userAgent, startedAt, maxSeconds, state, progress, err, downloaded } local jobs = {} @@ -40,7 +40,7 @@ function Fetch.download(url, dest, options) local jobId = newJob(url, dest, options) local job = jobs[jobId] - -- Start the download in background using HostShell + -- Resolve the legacy download transport local HostShell = require("src.core.HostShell") if not HostShell.canFetch() then job.state = "error" @@ -72,6 +72,7 @@ function Fetch.poll(jobId) if not job then return { status = "error", err = "invalid job handle" } end + if job.kind == "get" then return AsyncGet.poll(job.request) end if job.state ~= "pending" then return { status = job.state, progress = job.progress, err = job.err } @@ -137,6 +138,7 @@ end function Fetch.cancel(jobId) local job = jobs[jobId] if not job then return end + if job.kind == "get" then return AsyncGet.cancel(job.request) end if job.state == "pending" then job.state = "error" job.err = "cancelled" @@ -149,7 +151,28 @@ end -- Release job resources. Call after job is complete or cancelled. function Fetch.release(jobId) + local job = jobs[jobId] + if job and job.kind == "get" then AsyncGet.release(job.request) end jobs[jobId] = nil end +-- The existing download API keeps its call shape. GETs use a real worker +-- pool, so a mod's per-frame poll never performs a network/pipe read. +function Fetch.get(url, options) + local request, err = AsyncGet.get(url, options) + if not request then return nil, err end + local id = newJob(url, nil, options) + jobs[id].kind, jobs[id].request = "get", request + return id +end + +function Fetch.available() return AsyncGet.available() end +function Fetch.isPending(id) + local job = jobs[id] + if not job then return false end + if job.kind == "get" then return AsyncGet.poll(job.request).status == "pending" end + return job.state == "pending" +end +function Fetch.shutdown() AsyncGet.shutdown() end + return Fetch diff --git a/src/net/get_worker.lua b/src/net/get_worker.lua new file mode 100644 index 000000000..cf0476926 --- /dev/null +++ b/src/net/get_worker.lua @@ -0,0 +1,38 @@ +require("love.thread") +require("love.filesystem") +require("love.timer") +require("love.system") +local commands, results, quit, token, workerIndex = ... +local Host = assert(love.filesystem.load("src/core/HostShell.lua"))() +local directory = "net-get/" .. token .. "/" .. workerIndex +-- The main thread creates this workspace before starting any worker. +local saveDir = love.filesystem.getSaveDirectory() + +local function run(job) + if job.cancel:peek() or love.timer.getTime() >= job.deadline then + return {id = job.id, ok = false, err = "cancelled"} + end + local path = directory .. "/" .. job.id .. ".stderr" + local body, err, httpStatus = Host.httpGetBounded(job.url, { + userAgent = job.userAgent, accept = job.accept, + maxSeconds = math.max(0.1, math.min(job.maxSeconds, job.deadline - love.timer.getTime())), + maxBytes = job.maxBytes, httpsOnly = job.httpsOnly, + errorPath = saveDir .. "/" .. path, + }) + love.filesystem.remove(path) + if job.cancel:peek() then return {id = job.id, ok = false, err = "cancelled"} end + return {id = job.id, ok = body ~= nil, body = body, err = err, httpStatus = httpStatus} +end + +while true do + local job = commands:demand() + if quit:peek() or job == false then break end + if type(job) == "table" then + local ok, response = pcall(run, job) + love.filesystem.remove(directory .. "/" .. job.id .. ".stderr") + love.filesystem.remove(directory .. "/" .. job.id .. ".stderr.cfg") + if not ok then response = {id = job.id, ok = false, err = tostring(response)} end + results:push(response) + end +end +love.filesystem.remove(directory) diff --git a/tests/engine/async_get_queue.lua b/tests/engine/async_get_queue.lua new file mode 100644 index 000000000..3ae33da2e --- /dev/null +++ b/tests/engine/async_get_queue.lua @@ -0,0 +1,43 @@ +package.path='./?.lua;'..package.path +local ticks, checks, channels, workers = 0, 0, {}, {} +local function check(ok, label) checks=checks+1;assert(ok,label)end +local function channel() + local q={} + function q:push(v)self[#self+1]=v end + function q:pop()if #self==0 then return nil end;return table.remove(self,1)end + function q:peek()return self[1]end + function q:clear()for i=#self,1,-1 do self[i]=nil end end + channels[#channels+1]=q;return q +end +love={timer={getTime=function()return ticks end},thread={newChannel=channel,newThread=function() + local t={start=function(self,c,r,q)self.commands,self.results,self.quit=c,r,q end, + getError=function(self)return self.err end,wait=function()end};workers[#workers+1]=t;return t +end}} +local Get=require('src.net.AsyncGet') +check(Get.get('file:///x')==nil,'direct caller URL validation') +check(Get.get('http://host',{boundedHttpsGet=1})==nil,'direct caller HTTPS constraint') +local handles={} +for i=1,Get.MAX_JOBS do handles[i]=assert(Get.get('https://host/x')) end +check(#workers==3,'fixed worker ceiling') +check(Get.get('https://host/x')==nil,'global active ceiling') +for _,id in ipairs(handles)do Get.cancel(id);Get.release(id)end +check(Get.get('https://host/x')==nil,'release cannot evade running worker ceiling') +for _,id in ipairs(handles)do workers[1].results:push({id=id,ok=true,body='late'})end +local id=assert(Get.get('https://host/x',{maxSeconds=1})) +check(Get.poll(handles[1]).status=='error','released handle never revived by late result') +ticks=2 +check(Get.poll(id).status=='error','queue time counts toward deadline') +workers[1].results:push({id=id,ok=true,body='too late'}) +check(Get.poll(id).body==nil,'timeout never accepts late body');Get.release(id) +id=assert(Get.get('https://host/x'));Get.cancel(id) +workers[1].results:push({id=id,ok=true,body='late'}) +check(Get.poll(id).status=='cancelled' and Get.poll(id).body==nil,'cancel never accepts late body');Get.release(id) +id=assert(Get.get('https://host/x'));workers[1].err='worker stopped' +check(Get.poll(id).status=='error','worker failure terminates pending jobs');Get.release(id) +Get.shutdown();check(Get.available(),'shutdown restarts pool') +id=assert(Get.get('https://host/x'));local w=workers[#workers] +w.results:push({id=id,ok=true,body='ok'}) +local state=Get.poll(id);state.status='changed' +check(Get.poll(id).status=='ok','poll returns detached state') +Get.release(id);Get.shutdown() +print(('async GET queue: %d checks PASS'):format(checks)) diff --git a/tests/engine/net_permission.lua b/tests/engine/net_permission.lua new file mode 100644 index 000000000..7cc94cf85 --- /dev/null +++ b/tests/engine/net_permission.lua @@ -0,0 +1,28 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Json=require('src.link.Json') +local Net=require('src.mods.Net') +local function load(permissions, entry) + local fs=T.sdk.memfs({ + ['mods/net_probe/manifest.json']=Json.encode({id='net_probe',name='Net probe',version='1.0.0',api=2,entry='main.lua',permissions=permissions}), + ['mods/net_probe/main.lua']=entry, + }) + return T.sdk.loadMod('mods/net_probe',{fs=fs}) +end +local a=load({'network'},[[local mod=...;assert(type(mod.fetch.get)=='function');assert(type(mod.fetch.poll)=='function');mod.exports.fetch=mod.fetch]]) +T.eq(#a.errors,0,'network mod loads real fetch facade') +T.eq(a.mod.state,'loaded','entry completed') +T.eq(type(a.loader.exports.net_probe.fetch.cancel),'function','cancellation exposed') +T.eq(type(a.loader.exports.net_probe.fetch.capabilities),'function','capability query exposed') +T.eq(a.loader.exports.net_probe.fetch:get('file:///private'),nil,'file URL refused') +a.release() +local b=load({},[[local mod=...;mod.exports.available=mod.fetch:available();mod.exports.get=pcall(mod.fetch.get,mod.fetch,'https://example.invalid')]]) +T.eq(#b.errors,0,'ungranted query does not fail entry') +T.eq(b.loader.exports.net_probe.available,false,'transport unavailable without grant') +T.eq(b.loader.exports.net_probe.get,false,'operation requires network grant') +b.release() +for _,bad in ipairs({'https://','https://example.invalid\nHeader: bad','ftp://host','https://host\0','http://a b'})do + T.eq(type(Net.urlDenial(bad)),'string','bad URL denied') +end +T.eq(Net.urlDenial('https://host/a%20b?x=%22&z=1'),nil,'encoded URL accepted') +T.finish('mod network permission') diff --git a/tools/async-get-check/conf.lua b/tools/async-get-check/conf.lua new file mode 100644 index 000000000..b58b4efe7 --- /dev/null +++ b/tools/async-get-check/conf.lua @@ -0,0 +1,6 @@ +function love.conf(t) + t.identity = assert(os.getenv('GET_TEST_IDENTITY')) + t.version = love.getVersion() >= 12 and '12.0' or '11.5' + t.window = false + t.modules.graphics, t.modules.audio, t.modules.joystick, t.modules.physics = false, false, false, false +end diff --git a/tools/async-get-check/main.lua b/tools/async-get-check/main.lua new file mode 100644 index 000000000..6d0f8d3c5 --- /dev/null +++ b/tools/async-get-check/main.lua @@ -0,0 +1,75 @@ +function love.errorhandler(err) + io.stderr:write(debug.traceback(tostring(err)).."\n");io.stderr:flush() + return function()return 1 end +end +local Fetch, Net, base +local checks, pulses = 0, 0 +local function check(ok, message) + checks = checks + 1 + assert(ok, message) +end +local function await(api, handle) + local at = love.timer.getTime() + while true do + local before = love.timer.getTime() + local state = api:poll(handle) + check(love.timer.getTime() - before < .1, 'poll must not perform transport IO') + if state.status ~= 'pending' then return state end + check(love.timer.getTime() - at < 10, 'request completes within test deadline') + pulses = pulses + 1; love.timer.sleep(.005) + end +end +function love.load() + Fetch = require('src.net.Fetch'); Net = require('src.mods.Net') + base = assert(os.getenv('GET_TEST_URL')) +end +function love.run() + local ok, err = xpcall(function() + love.load() + local loader = {} + local function mod(id, network) return Net.forMod(loader, {manifest={id=id, permissionSet={network=network}}}) end + local a, b, denied = mod('a', true), mod('b', true), mod('denied', false) + check(not denied:available(), 'network permission required') + check(not pcall(denied.get, denied, base), 'ungranted GET refuses') + check(a:available(), 'real thread workers available') + check(a:get('file:///etc/hosts') == nil, 'non HTTP URL denied') + check(a:get(base..'/binary', {boundedHttpsGet=1}) == nil, 'bounded HTTPS rejects HTTP') + local started = love.timer.getTime() + local slow = assert(a:get(base..'/slow', {maxSeconds=3})) + check(love.timer.getTime()-started < .1, 'GET starts without waiting for server') + check(b:poll(slow).status == 'error', 'handles isolated between mods') + check(not b:cancel(slow) and not b:release(slow), 'foreign cancellation denied') + local binary = assert(a:get(base..'/binary?encoded=%22%26%25', {maxBytes=256})) + local result = await(a, binary) + check(result.status=='ok' and result.body==string.char(0,255,13,10)..'GET binary', 'binary bytes and encoded URL preserved: '..tostring(result.status)..' '..tostring(result.err)..' bytes='..tostring(result.body and #result.body)) + check(result.receivedBytes==14, 'received byte count exact') + a:release(binary) + local state = await(a, slow) + check(state.status=='ok' and state.body=='slow', 'delayed request completes: '..tostring(state.status)..' '..tostring(state.err)..' bytes='..tostring(state.body and #state.body)) + a:release(slow) + check(pulses>20, 'caller keeps pumping during slow response') + local missing=assert(a:get(base..'/missing'));state=await(a, missing) + check(state.status=='error' and state.httpStatus==404, 'HTTP errors retained');a:release(missing) + local big=assert(a:get(base..'/big', {maxBytes=64}));state=await(a,big) + check(state.status=='error' and state.body==nil, 'oversize body refused');a:release(big) + local chunk=assert(a:get(base..'/chunk', {maxBytes=64}));state=await(a,chunk) + check(state.status=='error' and state.body==nil, 'body without content length bounded');a:release(chunk) + local cancelled=assert(a:get(base..'/slow'));a:cancel(cancelled) + check(a:poll(cancelled).status=='cancelled','cancel terminal');a:release(cancelled) + local timeout=assert(a:get(base..'/slow', {maxSeconds=1}));state=await(a,timeout) + check(state.status=='error','deadline enforced');a:release(timeout) + local held={} + for i=1,4 do held[i]=assert(a:get(base..'/slow')) end + check(a:get(base..'/slow')==nil,'per-mod pending ceiling') + Net.releaseAll(loader,'a') + check(a:poll(held[1]).status=='error','unload invalidates handles') + Net.unloadAll();Fetch.shutdown() + check(Fetch.available(),'worker service can restart') + local restart=assert(b:get(base..'/binary'));state=await(b,restart) + check(state.status=='ok','GET after shutdown works');b:release(restart) + Fetch.shutdown() + print(('async GET: %d checks, %d pump ticks PASS'):format(checks,pulses)) + end, debug.traceback) + if not ok then io.stderr:write(tostring(err).."\n");io.stderr:flush(); if Fetch then Fetch.shutdown() end end + return function() return ok and 0 or 1 end +end diff --git a/tools/check_async_get.py b/tools/check_async_get.py new file mode 100644 index 000000000..87a541799 --- /dev/null +++ b/tools/check_async_get.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Exercise real LOVE worker HTTP against only a task-owned loopback server.""" +import argparse, http.server, os, pathlib, subprocess, threading, time, tempfile, shutil, uuid +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): pass + def do_GET(self): + path=self.path.split('?')[0] + if path=='/slow': time.sleep(1.25) + if path=='/missing': self.send_error(404); return + body=b'\x00\xff\r\nGET binary' if path=='/binary' else b'slow' if path=='/slow' else b'x'*4096 + self.send_response(200) + if path!='/chunk': self.send_header('Content-Length',str(len(body))) + self.end_headers() + try: self.wfile.write(body) + except (BrokenPipeError,ConnectionResetError): pass +if __name__=='__main__': + p=argparse.ArgumentParser();p.add_argument('--love',required=True);args=p.parse_args() + root=pathlib.Path(__file__).resolve().parents[1] + temporary=tempfile.TemporaryDirectory(prefix='gen2recomp-get-check-') + project=pathlib.Path(temporary.name) + for name in ('src/net/Fetch.lua','src/net/AsyncGet.lua','src/net/get_worker.lua','src/mods/Net.lua','src/core/HostShell.lua'): + target=project/name;target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(root/name,target) + for name in ('main.lua','conf.lua'): shutil.copy2(root/'tools/async-get-check'/name,project/name) + server=http.server.ThreadingHTTPServer(('127.0.0.1',0),Handler) + thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + try: + env=os.environ.copy();env.update(GET_TEST_IDENTITY='Gen2Recomp-Async-GET-Check-'+uuid.uuid4().hex,GET_TEST_URL=f'http://127.0.0.1:{server.server_port}') + result=subprocess.run([str(pathlib.Path(args.love).resolve()),str(project)],env=env,cwd=root,timeout=45) + raise SystemExit(result.returncode) + finally: server.shutdown();server.server_close();temporary.cleanup() From 5453b62a6c705d61b938bb1c5c5d04fa17b9e56d Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 13:39:25 +0100 Subject: [PATCH 039/138] Stream file downloads in bounded native workers and publish complete files atomically --- src/mods/Net.lua | 2 +- src/net/AsyncDownload.lua | 205 ++++++++++++++++++++++++++ src/net/DownloadTransport.lua | 75 ++++++++++ src/net/Fetch.lua | 195 ++++-------------------- src/net/download_worker.lua | 35 +++++ tests/engine/async_download_queue.lua | 94 ++++++++++++ tools/async-get-check/main.lua | 73 ++++++++- tools/check_async_get.py | 59 ++++++-- 8 files changed, 559 insertions(+), 179 deletions(-) create mode 100644 src/net/AsyncDownload.lua create mode 100644 src/net/DownloadTransport.lua create mode 100644 src/net/download_worker.lua create mode 100644 tests/engine/async_download_queue.lua diff --git a/src/mods/Net.lua b/src/mods/Net.lua index 39055c505..cd0b018c3 100644 --- a/src/mods/Net.lua +++ b/src/mods/Net.lua @@ -40,7 +40,7 @@ function Net.forMod(loader, mod) local api = {} function api:available() return Host.canSpawnProcess() and Fetch.available() end function api:capabilities() - return Host.canSpawnProcess() and {boundedHttpsGet = 1} or {} + return Host.canSpawnProcess() and {boundedHttpsGet = 1, maxResponseBytes = require("src.net.AsyncGet").MAX_BYTES} or {} end function api:get(url, opts) local denial = Net.urlDenial(url) diff --git a/src/net/AsyncDownload.lua b/src/net/AsyncDownload.lua new file mode 100644 index 000000000..c34c8dcdd --- /dev/null +++ b/src/net/AsyncDownload.lua @@ -0,0 +1,205 @@ +-- File downloads have bounded, engine-owned workers. Poll only drains results, +-- reads file metadata and atomically publishes a completed staging file. +local M = { MAX_JOBS = 8, MAX_BYTES = 512 * 1024 * 1024 } +local jobs, workers, destinations, nextId = {}, {}, {}, 0 +local commands, results, quit, directory, ready, failure +local function now() return love.timer.getTime() end + +local function replace(source, target) + if love.system.getOS() ~= "Windows" then return os.rename(source, target) end + -- Windows rename cannot overwrite an existing file. Never delete the old + -- destination first: MoveFileExW replaces it in one filesystem operation. + local ffi = require("ffi") + ffi.cdef[[ + int MultiByteToWideChar(unsigned int, unsigned long, const char *, int, unsigned short *, int); + int MoveFileExW(const unsigned short *, const unsigned short *, unsigned long); + unsigned long GetLastError(void); + ]] + local function wide(s) + local n = ffi.C.MultiByteToWideChar(65001, 8, s, #s, nil, 0) + if n == 0 then error("invalid UTF-8 download path") end + local out = ffi.new("unsigned short[?]", n + 1) + if ffi.C.MultiByteToWideChar(65001, 8, s, #s, out, n) ~= n then error("download path conversion failed") end + return out + end + if ffi.C.MoveFileExW(wide(source), wide(target), 1) == 0 then + return nil, "download publish failed (Windows " .. tonumber(ffi.C.GetLastError()) .. ")" + end + return true +end + +local function ensure() + if ready ~= nil then return ready end + if not (love and love.thread and love.thread.newThread and love.filesystem and love.timer) + or not require("src.core.HostShell").canSpawnProcess() then + ready, failure = false, "background file downloads unavailable"; return false + end + commands, results, quit = love.thread.newChannel(), love.thread.newChannel(), love.thread.newChannel() + directory = "net-download/" .. os.time() .. "-" .. tostring({}):gsub("[^%w]", "") + local ok, made = pcall(love.filesystem.createDirectory, directory) + if not ok or not made then ready, failure = false, "could not create download workspace"; return false end + for _ = 1, 2 do + local created, thread = pcall(love.thread.newThread, "src/net/download_worker.lua") + if created and thread and pcall(thread.start, thread, commands, results, quit) then workers[#workers + 1] = thread end + end + ready = #workers > 0 + if not ready then failure = "could not start file-download workers" end + return ready +end + +local function retire(job) + if destinations[job.key] == job.id then destinations[job.key] = nil end +end +local function failed(job, reason) + if job.cancel then job.cancel:push(true) end + job.state = {status = "error", err = reason, progress = 0} + retire(job) +end +local function removeStage(job) + if job.stage then love.filesystem.remove(job.stage) end +end +local function drain() + if not results then return end + while true do + local response = results:pop() + if not response then break end + local job = type(response) == "table" and jobs[response.id] + if job then + job.busy = false + if job.retained and job.state.status == "pending" then + if now() >= job.deadline then failed(job, "download timed out") + elseif not response.ok then failed(job, response.err or "download failed") + else + local info = love.filesystem.getInfo(job.stage, "file") + if not info or info.size ~= response.bytes or job.size ~= nil and info.size ~= job.size then + failed(job, "download size mismatch") + else + local base = love.filesystem.getSaveDirectory() .. "/" + local ok, moved, why = pcall(replace, base .. job.stage, base .. job.dest) + if not ok or not moved then failed(job, tostring(why or moved or "could not publish download")) + else + job.state = {status = "ok", progress = 1, receivedBytes = response.bytes, httpStatus = response.httpStatus} + retire(job) + end + end + end + if job.state.status == "error" then job.state.httpStatus = response.httpStatus end + end + removeStage(job) + if not job.retained then jobs[response.id] = nil end + end + end + for _, thread in ipairs(workers) do + local err = thread:getError() + if err then + ready, failure = false, tostring(err) + quit:push(true); commands:clear() + for _ = 1, #workers do commands:push(false) end + for _, job in pairs(jobs) do if job.state.status == "pending" then failed(job, failure) end end + break + end + end + for _, job in pairs(jobs) do + if job.state.status == "pending" and now() >= job.deadline then failed(job, "download timed out") end + end +end + +local function path(value) + if type(value) ~= "string" or #value == 0 or #value > 2048 or value:find("[%z%c:]") then return nil end + value = value:gsub("\\", "/") + if value:sub(1, 1) == "/" or value:sub(-1) == "/" or value:find("//", 1, true) + or value == "net-download" or value:sub(1, 13) == "net-download/" then return nil end + for part in value:gmatch("[^/]+") do if part == "." or part == ".." then return nil end end + return value +end +local function number(value, default, maximum) + if value == nil then return default end + value = tonumber(value) + if not value or value ~= value or value < 0 or value > maximum then return nil end + return value +end + +function M.download(url, destination, options) + options = type(options) == "table" and options or {} + if type(url) ~= "string" or #url > 8192 or url:find("[%z%c%s]") + or not url:lower():match("^https?://[^/?#]+") then return nil, "invalid HTTP URL" end + if options.boundedHttpsGet == 1 and not url:lower():match("^https://") then return nil, "HTTPS required" end + for _, name in ipairs({"userAgent", "accept"}) do + local value = options[name] + if value ~= nil and (type(value) ~= "string" or #value > 1024 or value:find("[%z%c]")) then + return nil, "invalid HTTP header" + end + end + local dest = path(destination) + if not dest then return nil, "relative download file path required" end + local size = number(options.size, nil, M.MAX_BYTES) + if options.size ~= nil and (size == nil or size % 1 ~= 0) then return nil, "invalid expected size" end + local limit = number(options.maxBytes, math.max(size or 0, 64 * 1024 * 1024), M.MAX_BYTES) + local seconds = number(options.maxSeconds, 60, 300) + if not limit or limit < 1 or limit % 1 ~= 0 or size and size > limit then return nil, "invalid byte limit" end + if not seconds or seconds < .1 then return nil, "invalid download timeout" end + drain() + local busy = 0 + for _, job in pairs(jobs) do if job.busy then busy = busy + 1 end end + if busy >= M.MAX_JOBS then return nil, "file-download worker queue is full" end + local key = love and love.system and love.system.getOS and love.system.getOS() == "Windows" and dest:lower() or dest + if destinations[key] then return nil, "download destination is busy" end + nextId = nextId + 1 + local id = nextId + local job = {id = id, dest = dest, key = key, size = size, retained = true, + state = {status = "pending", progress = 0, receivedBytes = 0}} + jobs[id] = job + if not ensure() then failed(job, failure); return id end + local parent = dest:match("^(.+)/[^/]+$") + if parent then + local ok, made = pcall(love.filesystem.createDirectory, parent) + if not ok or not made then failed(job, "could not create download destination directory"); return id end + end + job.stage, job.cancel = directory .. "/" .. id .. ".part", love.thread.newChannel() + job.deadline, job.busy = now() + seconds, true + destinations[key] = id + commands:push({id = id, url = url, stage = job.stage, cancel = job.cancel, + deadline = job.deadline, maxSeconds = seconds, size = size, maxBytes = limit, + userAgent = options.userAgent, accept = options.accept, httpsOnly = options.boundedHttpsGet == 1}) + return id +end + +function M.poll(id) + drain() + local job = jobs[id] + if not job or not job.retained then return {status = "error", err = "invalid job handle"} end + local state = {} + for k, v in pairs(job.state) do state[k] = v end + if state.status == "pending" and job.stage then + local info = love.filesystem.getInfo(job.stage, "file") + state.receivedBytes = info and info.size or 0 + state.progress = job.size and job.size > 0 and math.min(1, state.receivedBytes / job.size) or 0 + end + return state +end +function M.cancel(id) + local job = jobs[id] + if not job or not job.retained then return false end + if job.state.status == "pending" then failed(job, "cancelled") end + return true +end +function M.release(id) + local job = jobs[id] + if not job then return end + M.cancel(id); job.retained = false + if not job.busy then removeStage(job); jobs[id] = nil end +end +function M.shutdown() + for id in pairs(jobs) do M.cancel(id) end + if quit then quit:push(true); commands:clear(); for _ = 1, #workers do commands:push(false) end end + for _, thread in ipairs(workers) do pcall(thread.wait, thread) end + for _, job in pairs(jobs) do + job.busy = false; removeStage(job) + if job.stage then love.filesystem.remove(job.stage .. ".cfg"); love.filesystem.remove(job.stage .. ".stderr") end + end + if directory then love.filesystem.remove(directory) end + workers, destinations = {}, {} + commands, results, quit, directory, ready, failure = nil, nil, nil, nil, nil, nil +end + +return M diff --git a/src/net/DownloadTransport.lua b/src/net/DownloadTransport.lua new file mode 100644 index 000000000..c85da8c08 --- /dev/null +++ b/src/net/DownloadTransport.lua @@ -0,0 +1,75 @@ +-- Worker-only streaming HTTP transport. Remote values stay in a curl config; +-- the response is never assembled in the main thread or retained in memory. +local Host = require("src.core.HostShell") +local M = {} + +function M.run(job, output) + if not Host.canSpawnProcess() then return nil, "background downloads require desktop curl" end + local fs = love.filesystem + local errorPath, configPath = job.stage .. ".stderr", job.stage .. ".cfg" + local absolute = fs.getSaveDirectory() .. "/" .. configPath + if love.system.getOS() == "Windows" and absolute:find('[%%!"^]') then + return nil, "unsupported characters in worker path" + end + local function quote(value) + value = tostring(value) + if value:find("[%z%c]") then return nil end + return '"' .. value:gsub("\\", "\\\\"):gsub('"', '\\"') .. '"' + end + local ua, accept = quote(job.userAgent or "gen2recomp-fetch"), quote(job.accept or "*/*") + if not ua or not accept then return nil, "invalid HTTP header" end + local seconds = math.max(.1, math.min(job.maxSeconds, job.deadline - love.timer.getTime())) + local protocol = job.httpsOnly and "https" or "http,https" + local config = table.concat({ "silent", "show-error", "fail", "location", "globoff", + "connect-timeout = " .. math.min(10, seconds), "max-time = " .. seconds, + "max-filesize = " .. job.maxBytes, "max-redirs = 5", + 'proto = "=' .. protocol .. '"', 'proto-redir = "=' .. protocol .. '"', + "user-agent = " .. ua, "header = " .. quote("Accept: " .. (job.accept or "*/*")), + "stderr = " .. quote(fs.getSaveDirectory() .. "/" .. errorPath), + 'write-out = "\\n%{http_code}"', "url = " .. quote(job.url), "" }, "\n") + local wrote, why = fs.write(configPath, config) + if not wrote then return nil, tostring(why or "could not write download config") end + local pipe = Host.popen("curl --disable --config " .. Host.quote(absolute), + love.system.getOS() == "Windows" and "rb" or "r") + if not pipe then fs.remove(configPath); return nil, "could not start download" end + local bytes, tail, failure = 0, "", nil + local ok, err = pcall(function() + while true do + if job.cancel:peek() then failure = "cancelled"; break end + if love.timer.getTime() >= job.deadline then failure = "download timed out"; break end + local chunk = pipe:read(65536) + if not chunk or chunk == "" then break end + -- Retain curl's four-byte HTTP trailer, even across arbitrary reads. + chunk = tail .. chunk + local n = math.max(0, #chunk - 4) + if bytes + n > job.maxBytes then failure = "response exceeds byte limit"; break end + if n > 0 then + local saved, reason = output:write(chunk:sub(1, n)) + if not saved then error(reason or "download write failed") end + local flushed, flushWhy = output:flush() + if not flushed then error(flushWhy or "download flush failed") end + bytes = bytes + n + end + tail = chunk:sub(n + 1) + end + end) + local closed, done, _, code = pcall(pipe.close, pipe) + local readOk, complaint = pcall(fs.read, errorPath, 4096) + fs.remove(configPath); fs.remove(errorPath) + if not ok then return nil, tostring(err) end + if failure then return nil, failure end + local status = tonumber(tail:match("^\n(%d%d%d)$")) + complaint = readOk and Host.curlComplaint(complaint) or nil + if complaint then return nil, complaint, status end + if not closed or done == nil or type(code) == "number" and code ~= 0 then + return nil, "download transport failed", status + end + if not status or status < 200 or status >= 300 then + return nil, "incomplete HTTP response", status + end + if job.cancel:peek() then return nil, "cancelled", status end + if job.size ~= nil and bytes ~= job.size then return nil, "download size mismatch", status end + return bytes, nil, status +end + +return M diff --git a/src/net/Fetch.lua b/src/net/Fetch.lua index 8435b368b..8d7172dcd 100644 --- a/src/net/Fetch.lua +++ b/src/net/Fetch.lua @@ -1,178 +1,37 @@ --- GETs run on bounded engine workers; poll never waits for a GET transport. --- The older download-to-file API retains its synchronous first-poll behavior. - +-- Engine-owned GET and file-download workers. No HTTP/pipe read in poll. local Fetch = {} -local AsyncGet = require("src.net.AsyncGet") - --- Active jobs table: jobId -> { url, dest, size, userAgent, startedAt, maxSeconds, state, progress, err, downloaded } -local jobs = {} -local nextJobId = 1 - --- Job states: "pending", "ok", "error" -local function newJob(url, dest, options) - local job = { - id = nextJobId, - url = url, - dest = dest, - size = options and options.size, - userAgent = options and options.userAgent or "gen1recomp-fetch", - maxSeconds = options and options.maxSeconds or 60, - state = "pending", - progress = 0, - err = nil, - startedAt = os.time(), - downloaded = false, - } - jobs[nextJobId] = job - nextJobId = nextJobId + 1 - return job.id +local Get = require("src.net.AsyncGet") +local Download = require("src.net.AsyncDownload") +local jobs, nextId = {}, 0 +local function start(service, method, ...) + local request, err = service[method](...) + if not request then return nil, err end + nextId = nextId + 1 + jobs[nextId] = {service = service, request = request} + return nextId end --- Start a download job. Returns job handle (integer). --- options: { size?, userAgent?, maxSeconds? } +-- Legacy shape: download(url, relativeSavePath, {size?,userAgent?,maxSeconds?}). +-- Additional optional bounds: maxBytes, accept, boundedHttpsGet. function Fetch.download(url, dest, options) - if type(url) ~= "string" or url == "" then - return nil - end - if type(dest) ~= "string" or dest == "" then - return nil - end - local jobId = newJob(url, dest, options) - local job = jobs[jobId] - - -- Resolve the legacy download transport - local HostShell = require("src.core.HostShell") - if not HostShell.canFetch() then - job.state = "error" - job.err = "no network transport on this platform" - return jobId - end - - -- Convert dest to absolute path if relative - local absDest = dest - if love and love.filesystem then - local saveOk, saveDir = pcall(function() - return love.filesystem.getSaveDirectory() - end) - if saveOk and saveDir and saveDir ~= "" then - absDest = saveDir .. "/" .. dest - end - end - - -- Note: HostShell.httpDownload is synchronous, so we mark it as started - -- The actual download will happen on the first poll - job.absDest = absDest - return jobId -end - --- Poll a job's status. Returns { status, progress?, err? } --- status: "pending", "ok", "error" -function Fetch.poll(jobId) - local job = jobs[jobId] - if not job then - return { status = "error", err = "invalid job handle" } - end - if job.kind == "get" then return AsyncGet.poll(job.request) end - - if job.state ~= "pending" then - return { status = job.state, progress = job.progress, err = job.err } - end - - -- Check timeout - if os.time() - job.startedAt > job.maxSeconds then - job.state = "error" - job.err = "download timed out" - return { status = "error", err = job.err } - end - - -- Perform the actual download on first poll (synchronous but framed) - if not job.downloaded then - job.downloaded = true - local HostShell = require("src.core.HostShell") - local ok, err = pcall(function() - HostShell.httpDownload(job.url, job.absDest, job.userAgent) - end) - - if not ok then - job.state = "error" - job.err = err or "download failed" - return { status = "error", err = job.err } - end - end - - -- Check file size to estimate progress - local currentSize = 0 - if love and love.filesystem then - local infoOk, info = pcall(love.filesystem.getInfo, job.dest) - if infoOk and info and info.size then - currentSize = tonumber(info.size) or 0 - end - end - - -- If we have an expected size, calculate progress - if job.size and job.size > 0 then - job.progress = math.min(1.0, currentSize / job.size) - else - job.progress = 0 - end - - -- Check if download is complete (file exists and non-zero) - if currentSize > 0 then - -- If we have an expected size, verify it matches - if job.size and job.size > 0 then - if currentSize >= job.size then - job.state = "ok" - job.progress = 1.0 - end - else - -- No expected size, assume complete if file exists - job.state = "ok" - job.progress = 1.0 - end - end - - return { status = job.state, progress = job.progress, err = job.err } -end - --- Cancel an active job. -function Fetch.cancel(jobId) - local job = jobs[jobId] - if not job then return end - if job.kind == "get" then return AsyncGet.cancel(job.request) end - if job.state == "pending" then - job.state = "error" - job.err = "cancelled" - -- Try to remove partial file - if love and love.filesystem then - pcall(love.filesystem.remove, job.dest) - end - end -end - --- Release job resources. Call after job is complete or cancelled. -function Fetch.release(jobId) - local job = jobs[jobId] - if job and job.kind == "get" then AsyncGet.release(job.request) end - jobs[jobId] = nil + return start(Download, "download", url, dest, options) end - --- The existing download API keeps its call shape. GETs use a real worker --- pool, so a mod's per-frame poll never performs a network/pipe read. -function Fetch.get(url, options) - local request, err = AsyncGet.get(url, options) - if not request then return nil, err end - local id = newJob(url, nil, options) - jobs[id].kind, jobs[id].request = "get", request - return id +function Fetch.get(url, options) return start(Get, "get", url, options) end +function Fetch.poll(id) + local job = jobs[id] + if not job then return {status = "error", err = "invalid job handle"} end + return job.service.poll(job.request) end - -function Fetch.available() return AsyncGet.available() end -function Fetch.isPending(id) +function Fetch.cancel(id) local job = jobs[id] if not job then return false end - if job.kind == "get" then return AsyncGet.poll(job.request).status == "pending" end - return job.state == "pending" + return job.service.cancel(job.request) end -function Fetch.shutdown() AsyncGet.shutdown() end - +function Fetch.release(id) + local job = jobs[id] + if job then job.service.release(job.request); jobs[id] = nil end +end +function Fetch.available() return Get.available() end +function Fetch.isPending(id) return Fetch.poll(id).status == "pending" end +function Fetch.shutdown() Get.shutdown(); Download.shutdown() end return Fetch diff --git a/src/net/download_worker.lua b/src/net/download_worker.lua new file mode 100644 index 000000000..201c77968 --- /dev/null +++ b/src/net/download_worker.lua @@ -0,0 +1,35 @@ +require("love.thread") +require("love.filesystem") +require("love.timer") +require("love.system") +local commands, results, quit = ... +local Transport = require("src.net.DownloadTransport") +while true do + local job = commands:demand() + if quit:peek() or job == false then break end + if type(job) == "table" then + local output + local ok, bytes, err, status = pcall(function() + if job.cancel:peek() then return nil, "cancelled" end + if love.timer.getTime() >= job.deadline then return nil, "download timed out" end + local why + local openFile = love.filesystem.openFile or love.filesystem.newFile + output, why = openFile(job.stage, "w") + if not output then return nil, tostring(why or "could not create download staging file") end + return Transport.run(job, output) + end) + if output then + local closed, result = pcall(output.close, output) + if not closed or not result then bytes, err = nil, "could not close download staging file" end + end + if not ok then err, bytes = tostring(bytes or err), nil end + love.filesystem.remove(job.stage .. ".cfg") + love.filesystem.remove(job.stage .. ".stderr") + if bytes == nil or job.cancel:peek() then + love.filesystem.remove(job.stage) + results:push({id = job.id, ok = false, err = err or "cancelled", httpStatus = status}) + else + results:push({id = job.id, ok = true, bytes = bytes, httpStatus = status}) + end + end +end diff --git a/tests/engine/async_download_queue.lua b/tests/engine/async_download_queue.lua new file mode 100644 index 000000000..5856d9406 --- /dev/null +++ b/tests/engine/async_download_queue.lua @@ -0,0 +1,94 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local checks, ticks, workers, files, queued = 0, 0, {}, {}, {} +local function check(ok, label) checks = checks + 1; assert(ok, label) end +local function channel() + local q = {} + function q:push(v) self[#self + 1] = v; if type(v) == "table" and v.stage then queued[v.id] = v end end + function q:pop() if #self > 0 then return table.remove(self, 1) end end + function q:peek() return self[1] end + function q:clear() for i = #self, 1, -1 do self[i] = nil end end + return q +end +love = {system = {getOS = function() return "Linux" end}, timer = {getTime = function() return ticks end}, + filesystem = {createDirectory = function() return true end, getSaveDirectory = function() return "/owned" end, + getInfo = function(p) return files[p] and {type = "file", size = #files[p]} end, + remove = function(p) files[p] = nil; return true end}, thread = {newChannel = channel, + newThread = function() + local t = {start = function(self, c, r) self.commands, self.results = c, r end, + getError = function(self) return self.err end, wait = function() end} + workers[#workers + 1] = t; return t + end}} +package.loaded["src.core.HostShell"] = {canSpawnProcess = function() return true end} +local rename = os.rename +local publishes = 0 +os.rename = function(from, to) + from, to = from:sub(8), to:sub(8) + if to == "blocked" then return nil, "publish refused" end + check(files[from] ~= nil, "only a completed actual staging file is published") + files[to], files[from] = files[from], nil; publishes = publishes + 1; return true +end +local D = require("src.net.AsyncDownload") +local function complete(id, body, options) + local job = queued[id]; check(job ~= nil, "request queued before worker result") + files[job.stage] = body + workers[#workers].results:push({id = id, ok = not options or options.ok ~= false, + bytes = options and options.bytes or #body, err = options and options.err, httpStatus = 200}) +end +for _, dest in ipairs({"", "/absolute", "../escape", "x/../escape", "C:\\escape", "x//file", "x/", "net-download/file"}) do + check(D.download("https://host/file", dest) == nil, "invalid target refused: " .. dest) +end +check(D.download("file:///private", "out") == nil, "only HTTP transport") +check(D.download("http://host/file", "out", {boundedHttpsGet = 1}) == nil, "HTTPS constraint") +check(D.download("https://host/file", "out", {size = 3, maxBytes = 2}) == nil, "contradictory size limit") +check(D.download("https://host/file", "out", {size = .5}) == nil, "fractional size refused") +check(D.download("https://host/file", "out", {maxBytes = math.huge}) == nil, "infinite limit refused") +check(D.download("https://host/file", "out", {userAgent = "bad\r\nheader"}) == nil, "invalid header refused") +files["nested/out"] = "previous" +local id = assert(D.download("https://host/file", "nested/out", {size = 6})) +check(#workers == 2, "fixed file worker ceiling") +check(D.download("https://host/file", "nested\\out") == nil, "one active writer per normalized destination") +check(D.poll(id).status == "pending" and publishes == 0, "poll starts no transfer or publish") +files[queued[id].stage] = "abc" +local progress = D.poll(id) +check(progress.receivedBytes == 3 and progress.progress == .5, "progress reads staging metadata") +check(files["nested/out"] == "previous", "partial file never replaces destination") +complete(id, "abcdef") +local state = D.poll(id) +check(state.status == "ok" and state.receivedBytes == 6 and state.progress == 1, "completed file metadata") +check(files["nested/out"] == "abcdef", "successful atomic replacement") +state.status = "modified"; check(D.poll(id).status == "ok", "detached polling state") +check(D.cancel(id) and files["nested/out"] == "abcdef", "cancel after success cannot remove published file") +D.release(id); check(D.poll(id).status == "error", "released handle invalid") + +for _, mode in ipairs({"cancel", "release", "timeout", "size", "reported-size", "transport", "publish"}) do + local dest = mode == "publish" and "blocked" or "preserved" + files[dest] = "known good" + id = assert(D.download("https://host/file", dest, {size = 6, maxSeconds = 1})) + if mode == "cancel" then D.cancel(id) + elseif mode == "release" then D.release(id) + elseif mode == "timeout" then ticks = ticks + 2; check(D.poll(id).status == "error", "queue time counts toward deadline") end + complete(id, mode == "size" and "short" or "abcdef", + mode == "transport" and {ok = false, err = "HTTP 404"} or mode == "reported-size" and {bytes = 99} or nil) + check(D.poll(id).status == "error", mode .. " is terminal error") + check(files[dest] == "known good", mode .. " preserves previous file") + check(files[queued[id].stage] == nil, mode .. " retires private partial data") + D.release(id) +end +id = assert(D.download("https://host/empty", "empty", {size = 0})); complete(id, "") +check(D.poll(id).status == "ok" and files.empty == "", "valid zero-byte HTTP download completes") +D.release(id) +local held = {} +for i = 1, D.MAX_JOBS do held[i] = assert(D.download("https://host/slow", "held" .. i)) end +check(D.download("https://host/slow", "extra") == nil, "bounded active and queued file jobs") +for _, h in ipairs(held) do D.cancel(h); D.release(h) end +check(D.download("https://host/slow", "extra") == nil, "release cannot evade occupied worker budget") +for _, h in ipairs(held) do complete(h, "late") end +id = assert(D.download("https://host/new", "reused")) +check(D.poll(held[1]).status == "error", "late completion never revives released request") +workers[1].err = "worker stopped" +check(D.poll(id).status == "error", "worker death terminates pending file jobs") +D.shutdown() +id = assert(D.download("https://host/new", "restart")); complete(id, "ready") +check(D.poll(id).status == "ok", "file pool restarts after shutdown") +D.release(id); D.shutdown(); os.rename = rename +print(("async file download queue: %d checks PASS"):format(checks)) diff --git a/tools/async-get-check/main.lua b/tools/async-get-check/main.lua index 6d0f8d3c5..28c7d12d0 100644 --- a/tools/async-get-check/main.lua +++ b/tools/async-get-check/main.lua @@ -32,6 +32,7 @@ function love.run() check(not denied:available(), 'network permission required') check(not pcall(denied.get, denied, base), 'ungranted GET refuses') check(a:available(), 'real thread workers available') + check(a:capabilities().maxResponseBytes >= 4194304, 'bounded receive ceiling advertised to package consumers') check(a:get('file:///etc/hosts') == nil, 'non HTTP URL denied') check(a:get(base..'/binary', {boundedHttpsGet=1}) == nil, 'bounded HTTPS rejects HTTP') local started = love.timer.getTime() @@ -67,8 +68,78 @@ function love.run() check(Fetch.available(),'worker service can restart') local restart=assert(b:get(base..'/binary'));state=await(b,restart) check(state.status=='ok','GET after shutdown works');b:release(restart) + local fileApi = {poll = function(_, id) return Fetch.poll(id) end} + local expected = string.char(0,255,13,10)..'GET binary' + love.filesystem.createDirectory('downloads') + local target = 'downloads/target.bin' + local function oldFile() + assert(love.filesystem.write(target, 'previous complete file')) + end + local function preserved(label) + check(love.filesystem.read(target) == 'previous complete file', label) + end + oldFile() + started = love.timer.getTime() + local fileJob = assert(Fetch.download(base..'/stream', target, {size=262144})) + check(love.timer.getTime()-started < .1, 'file transfer starts without waiting for server') + local observedProgress = false + while true do + local before = love.timer.getTime() + state = Fetch.poll(fileJob) + check(love.timer.getTime()-before < .1, 'file poll never waits on HTTP') + if state.status ~= 'pending' then break end + preserved('in-flight file does not overwrite previous complete file') + if state.receivedBytes > 0 and state.receivedBytes < 262144 then observedProgress = true end + pulses = pulses + 1; love.timer.sleep(.005) + end + check(state.status == 'ok' and state.receivedBytes == 262144, 'streamed file completes: '..tostring(state.err)) + check(observedProgress, 'progress available before transfer finishes') + local pattern = {}; for i=0,255 do pattern[#pattern+1] = string.char(i) end + pattern = table.concat(pattern) + check(love.filesystem.read(target) == pattern:rep(1024), 'streaming preserves all binary bytes') + Fetch.release(fileJob) + for _, case in ipairs({ + {'/missing', {size=14}, 'HTTP failure'}, + {'/partial', {}, 'truncated response'}, + {'/binary', {size=13}, 'expected-size mismatch'}, + {'/binary', {maxBytes=3}, 'known-length bound'}, + {'/chunk', {maxBytes=64}, 'unknown-length bound'}, + {'/slow', {maxSeconds=.1}, 'deadline'}, + }) do + oldFile();fileJob=assert(Fetch.download(base..case[1], target, case[2])) + state=await(fileApi,fileJob) + check(state.status=='error',case[3]..' reported: '..tostring(state.err)) + if case[1]=='/missing' then check(state.httpStatus==404,'file HTTP status retained') end + preserved(case[3]..' preserves previous file');Fetch.release(fileJob) + end + oldFile();fileJob=assert(Fetch.download(base..'/slow',target)) + Fetch.cancel(fileJob);state=Fetch.poll(fileJob) + check(state.status=='error' and state.err=='cancelled','legacy file cancellation remains terminal') + preserved('cancel preserves previous file');Fetch.release(fileJob) + local binaryFile=assert(Fetch.download(base..'/redirect','downloads/redirect.bin',{size=14})) + state=await(fileApi,binaryFile) + check(state.status=='ok' and love.filesystem.read('downloads/redirect.bin')==expected,'redirect and encoded binary output') + Fetch.release(binaryFile) + local empty=assert(Fetch.download(base..'/empty','downloads/empty.bin',{size=0})) + state=await(fileApi,empty) + check(state.status=='ok' and love.filesystem.getInfo('downloads/empty.bin').size==0,'zero-byte HTTP success') + Fetch.release(empty) + local large=assert(Fetch.download(base..'/large','downloads/large.bin',{size=17*1024*1024,maxBytes=20*1024*1024})) + state=await(fileApi,large) + check(state.status=='ok' and state.receivedBytes==17*1024*1024,'file can exceed in-memory GET ceiling') + check(love.filesystem.read('downloads/large.bin')==pattern:rep(69632),'large file bytes exact') + Fetch.release(large) + local heldFiles={} + for i=1,8 do heldFiles[i]=assert(Fetch.download(base..'/slow','downloads/held'..i)) end + check(Fetch.download(base..'/slow','downloads/overflow')==nil,'file queue bounded') + for _, handle in ipairs(heldFiles) do Fetch.cancel(handle);Fetch.release(handle) end + check(Fetch.download(base..'/slow','downloads/overflow')==nil,'release cannot bypass occupied file workers') Fetch.shutdown() - print(('async GET: %d checks, %d pump ticks PASS'):format(checks,pulses)) + for _, child in ipairs(love.filesystem.getDirectoryItems('net-download')) do + check(#love.filesystem.getDirectoryItems('net-download/'..child)==0,'shutdown cleans file worker staging data') + end + preserved('late cancellation cannot publish after shutdown') + print(('async GET and file downloads: %d checks, %d pump ticks PASS'):format(checks,pulses)) end, debug.traceback) if not ok then io.stderr:write(tostring(err).."\n");io.stderr:flush(); if Fetch then Fetch.shutdown() end end return function() return ok and 0 or 1 end diff --git a/tools/check_async_get.py b/tools/check_async_get.py index 87a541799..e14e95b4f 100644 --- a/tools/check_async_get.py +++ b/tools/check_async_get.py @@ -7,24 +7,65 @@ def do_GET(self): path=self.path.split('?')[0] if path=='/slow': time.sleep(1.25) if path=='/missing': self.send_error(404); return + if path=='/redirect': + self.send_response(302);self.send_header('Location','/binary');self.end_headers();return + if path=='/empty': + self.send_response(204);self.send_header('Content-Length','0');self.end_headers();return + if path=='/stream': + self.send_response(200);self.send_header('Content-Length',str(4*65536));self.end_headers() + try: + for _ in range(4):self.wfile.write(bytes(range(256))*256);self.wfile.flush();time.sleep(.25) + except (BrokenPipeError,ConnectionResetError):pass + return + if path=='/partial': + self.send_response(200);self.send_header('Content-Length','999');self.end_headers();self.wfile.write(b'short');return + if path=='/large': + self.send_response(200);self.send_header('Content-Length',str(17*1024*1024));self.end_headers() + try: + for _ in range(272):self.wfile.write(bytes(range(256))*256) + except (BrokenPipeError,ConnectionResetError):pass + return body=b'\x00\xff\r\nGET binary' if path=='/binary' else b'slow' if path=='/slow' else b'x'*4096 self.send_response(200) if path!='/chunk': self.send_header('Content-Length',str(len(body))) self.end_headers() try: self.wfile.write(body) except (BrokenPipeError,ConnectionResetError): pass -if __name__=='__main__': +def main(): p=argparse.ArgumentParser();p.add_argument('--love',required=True);args=p.parse_args() root=pathlib.Path(__file__).resolve().parents[1] - temporary=tempfile.TemporaryDirectory(prefix='gen2recomp-get-check-') - project=pathlib.Path(temporary.name) - for name in ('src/net/Fetch.lua','src/net/AsyncGet.lua','src/net/get_worker.lua','src/mods/Net.lua','src/core/HostShell.lua'): + executable=pathlib.Path(args.love).resolve() + token=uuid.uuid4().hex + identity='oasc-native-http-qa-'+token + temporary=None + run=subprocess.run + # Local participating checkouts use the shared storage leases. Standalone + # upstream/Windows CI runs have neither that local manager nor fcntl. + if os.name!='nt' and (root/'tools/qa_storage.py').is_file(): + from qa_storage import qa_source,qa_copy,qa_profile,qa_run + qa_source(root);qa_source(executable) + project=qa_copy(root/'.local/qa'/('native-http-test-'+token)/'unit') + profile=(pathlib.Path.home()/'Library/Application Support/LOVE'/identity if os.uname().sysname=='Darwin' + else pathlib.Path.home()/'.local/share/love'/identity) + qa_profile(profile) + run=qa_run + project.mkdir(parents=True) + else: + temporary=tempfile.TemporaryDirectory(prefix='gen2recomp-get-check-') + project=pathlib.Path(temporary.name) + for name in ('src/net/Fetch.lua','src/net/AsyncGet.lua','src/net/get_worker.lua', + 'src/net/AsyncDownload.lua','src/net/DownloadTransport.lua','src/net/download_worker.lua', + 'src/mods/Net.lua','src/core/HostShell.lua'): target=project/name;target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(root/name,target) - for name in ('main.lua','conf.lua'): shutil.copy2(root/'tools/async-get-check'/name,project/name) + for name in ('main.lua','conf.lua'):shutil.copy2(root/'tools/async-get-check'/name,project/name) server=http.server.ThreadingHTTPServer(('127.0.0.1',0),Handler) thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() try: - env=os.environ.copy();env.update(GET_TEST_IDENTITY='Gen2Recomp-Async-GET-Check-'+uuid.uuid4().hex,GET_TEST_URL=f'http://127.0.0.1:{server.server_port}') - result=subprocess.run([str(pathlib.Path(args.love).resolve()),str(project)],env=env,cwd=root,timeout=45) - raise SystemExit(result.returncode) - finally: server.shutdown();server.server_close();temporary.cleanup() + env=os.environ.copy();env.update(GET_TEST_IDENTITY=identity,GET_TEST_URL=f'http://127.0.0.1:{server.server_port}') + result=run([str(executable),str(project)],env=env,cwd=root,timeout=45) + return result.returncode + finally: + server.shutdown();server.server_close() + if temporary is not None:temporary.cleanup() + +if __name__=='__main__':raise SystemExit(main()) From 71ac37e8a6627876d4fc2298738761dd147ca48c Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 13:47:27 +0100 Subject: [PATCH 040/138] Isolate cross-platform worker checks and allow an independently pinned source tree --- tools/check_async_get.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tools/check_async_get.py b/tools/check_async_get.py index e14e95b4f..51a8377e9 100644 --- a/tools/check_async_get.py +++ b/tools/check_async_get.py @@ -32,18 +32,19 @@ def do_GET(self): try: self.wfile.write(body) except (BrokenPipeError,ConnectionResetError): pass def main(): - p=argparse.ArgumentParser();p.add_argument('--love',required=True);args=p.parse_args() + p=argparse.ArgumentParser();p.add_argument('--love',required=True);p.add_argument('--source-root',type=pathlib.Path);args=p.parse_args() root=pathlib.Path(__file__).resolve().parents[1] + source=(args.source_root or root).resolve() executable=pathlib.Path(args.love).resolve() token=uuid.uuid4().hex identity='oasc-native-http-qa-'+token temporary=None run=subprocess.run # Local participating checkouts use the shared storage leases. Standalone - # upstream/Windows CI runs have neither that local manager nor fcntl. - if os.name!='nt' and (root/'tools/qa_storage.py').is_file(): + # upstream/ephemeral CI uses its own isolated temporary project instead. + if os.name!='nt' and not os.getenv('GITHUB_ACTIONS') and (root/'tools/qa_storage.py').is_file(): from qa_storage import qa_source,qa_copy,qa_profile,qa_run - qa_source(root);qa_source(executable) + qa_source(root);qa_source(source);qa_source(executable) project=qa_copy(root/'.local/qa'/('native-http-test-'+token)/'unit') profile=(pathlib.Path.home()/'Library/Application Support/LOVE'/identity if os.uname().sysname=='Darwin' else pathlib.Path.home()/'.local/share/love'/identity) @@ -56,7 +57,7 @@ def main(): for name in ('src/net/Fetch.lua','src/net/AsyncGet.lua','src/net/get_worker.lua', 'src/net/AsyncDownload.lua','src/net/DownloadTransport.lua','src/net/download_worker.lua', 'src/mods/Net.lua','src/core/HostShell.lua'): - target=project/name;target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(root/name,target) + target=project/name;target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(source/name,target) for name in ('main.lua','conf.lua'):shutil.copy2(root/'tools/async-get-check'/name,project/name) server=http.server.ThreadingHTTPServer(('127.0.0.1',0),Handler) thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() From 2356135819d0b972d3cd875f3712db4ef542f09f Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 14:06:59 +0100 Subject: [PATCH 041/138] Keep file progress and atomic publication off the native frame thread Coalesce progress through channels and serialize accepted cancellation against the worker publication claim. Slow rename/metadata operations cannot run from poll; refused late cancellation cannot roll back a completed transfer. Exercise slow publication with the real worker and reject filesystem IO in poll. --- src/net/AsyncDownload.lua | 99 ++++++++----------- src/net/DownloadPublish.lua | 25 +++++ src/net/DownloadTransport.lua | 3 + src/net/download_worker.lua | 24 ++++- tests/engine/async_download_queue.lua | 95 ++++++++++-------- .../async-get-check/download_test_worker.lua | 12 +++ tools/async-get-check/main.lua | 44 ++++++++- tools/check_async_get.py | 10 +- 8 files changed, 205 insertions(+), 107 deletions(-) create mode 100644 src/net/DownloadPublish.lua create mode 100644 tools/async-get-check/download_test_worker.lua diff --git a/src/net/AsyncDownload.lua b/src/net/AsyncDownload.lua index c34c8dcdd..f6478e13d 100644 --- a/src/net/AsyncDownload.lua +++ b/src/net/AsyncDownload.lua @@ -1,33 +1,10 @@ -- File downloads have bounded, engine-owned workers. Poll only drains results, --- reads file metadata and atomically publishes a completed staging file. +-- reads coalesced progress. All file IO/publication stays on the workers. local M = { MAX_JOBS = 8, MAX_BYTES = 512 * 1024 * 1024 } local jobs, workers, destinations, nextId = {}, {}, {}, 0 local commands, results, quit, directory, ready, failure local function now() return love.timer.getTime() end -local function replace(source, target) - if love.system.getOS() ~= "Windows" then return os.rename(source, target) end - -- Windows rename cannot overwrite an existing file. Never delete the old - -- destination first: MoveFileExW replaces it in one filesystem operation. - local ffi = require("ffi") - ffi.cdef[[ - int MultiByteToWideChar(unsigned int, unsigned long, const char *, int, unsigned short *, int); - int MoveFileExW(const unsigned short *, const unsigned short *, unsigned long); - unsigned long GetLastError(void); - ]] - local function wide(s) - local n = ffi.C.MultiByteToWideChar(65001, 8, s, #s, nil, 0) - if n == 0 then error("invalid UTF-8 download path") end - local out = ffi.new("unsigned short[?]", n + 1) - if ffi.C.MultiByteToWideChar(65001, 8, s, #s, out, n) ~= n then error("download path conversion failed") end - return out - end - if ffi.C.MoveFileExW(wide(source), wide(target), 1) == 0 then - return nil, "download publish failed (Windows " .. tonumber(ffi.C.GetLastError()) .. ")" - end - return true -end - local function ensure() if ready ~= nil then return ready end if not (love and love.thread and love.thread.newThread and love.filesystem and love.timer) @@ -50,14 +27,18 @@ end local function retire(job) if destinations[job.key] == job.id then destinations[job.key] = nil end end +local function cancelTransfer(job) + if not job.control then return true end + return job.control:performAtomic(function(control) + if control:peek() ~= "open" then return false end + control:clear(); control:push("cancelled"); job.cancel:push(true) + return true + end) +end local function failed(job, reason) - if job.cancel then job.cancel:push(true) end job.state = {status = "error", err = reason, progress = 0} retire(job) end -local function removeStage(job) - if job.stage then love.filesystem.remove(job.stage) end -end local function drain() if not results then return end while true do @@ -67,25 +48,14 @@ local function drain() if job then job.busy = false if job.retained and job.state.status == "pending" then - if now() >= job.deadline then failed(job, "download timed out") - elseif not response.ok then failed(job, response.err or "download failed") + if not response.ok then failed(job, response.err or "download failed") else - local info = love.filesystem.getInfo(job.stage, "file") - if not info or info.size ~= response.bytes or job.size ~= nil and info.size ~= job.size then - failed(job, "download size mismatch") - else - local base = love.filesystem.getSaveDirectory() .. "/" - local ok, moved, why = pcall(replace, base .. job.stage, base .. job.dest) - if not ok or not moved then failed(job, tostring(why or moved or "could not publish download")) - else - job.state = {status = "ok", progress = 1, receivedBytes = response.bytes, httpStatus = response.httpStatus} - retire(job) - end - end + job.state = {status = "ok", progress = 1, receivedBytes = response.bytes, httpStatus = response.httpStatus} + retire(job) end if job.state.status == "error" then job.state.httpStatus = response.httpStatus end end - removeStage(job) + retire(job) if not job.retained then jobs[response.id] = nil end end end @@ -95,12 +65,16 @@ local function drain() ready, failure = false, tostring(err) quit:push(true); commands:clear() for _ = 1, #workers do commands:push(false) end - for _, job in pairs(jobs) do if job.state.status == "pending" then failed(job, failure) end end + for _, job in pairs(jobs) do + if job.state.status == "pending" then cancelTransfer(job); failed(job, failure) end + end break end end for _, job in pairs(jobs) do - if job.state.status == "pending" and now() >= job.deadline then failed(job, "download timed out") end + if job.state.status == "pending" and now() >= job.deadline and cancelTransfer(job) then + failed(job, "download timed out") + end end end @@ -150,15 +124,13 @@ function M.download(url, destination, options) state = {status = "pending", progress = 0, receivedBytes = 0}} jobs[id] = job if not ensure() then failed(job, failure); return id end - local parent = dest:match("^(.+)/[^/]+$") - if parent then - local ok, made = pcall(love.filesystem.createDirectory, parent) - if not ok or not made then failed(job, "could not create download destination directory"); return id end - end job.stage, job.cancel = directory .. "/" .. id .. ".part", love.thread.newChannel() + job.control, job.progress = love.thread.newChannel(), love.thread.newChannel() + job.control:push("open") job.deadline, job.busy = now() + seconds, true destinations[key] = id - commands:push({id = id, url = url, stage = job.stage, cancel = job.cancel, + commands:push({id = id, url = url, stage = job.stage, dest = dest, cancel = job.cancel, + control = job.control, progress = job.progress, deadline = job.deadline, maxSeconds = seconds, size = size, maxBytes = limit, userAgent = options.userAgent, accept = options.accept, httpsOnly = options.boundedHttpsGet == 1}) return id @@ -170,31 +142,42 @@ function M.poll(id) if not job or not job.retained then return {status = "error", err = "invalid job handle"} end local state = {} for k, v in pairs(job.state) do state[k] = v end - if state.status == "pending" and job.stage then - local info = love.filesystem.getInfo(job.stage, "file") - state.receivedBytes = info and info.size or 0 + if state.status == "pending" and job.progress then + local received = job.progress:peek() + job.receivedBytes = math.max(job.receivedBytes or 0, tonumber(received) or 0) + state.receivedBytes = job.receivedBytes state.progress = job.size and job.size > 0 and math.min(1, state.receivedBytes / job.size) or 0 + state.cancellable = job.control:peek() == "open" + if not state.cancellable then state.phase = "publishing" end end return state end function M.cancel(id) local job = jobs[id] if not job or not job.retained then return false end - if job.state.status == "pending" then failed(job, "cancelled") end - return true + if job.state.status == "pending" then + -- Publication claims and accepted cancellations are ordered by one short + -- channel transaction. No disk IO runs under that lock. Once publication + -- has claimed the completed transfer, cancellation is too late. + if not cancelTransfer(job) then return false end + failed(job, "cancelled") + end + return job.state.status ~= "ok" end function M.release(id) local job = jobs[id] if not job then return end M.cancel(id); job.retained = false - if not job.busy then removeStage(job); jobs[id] = nil end + if not job.busy then jobs[id] = nil end end function M.shutdown() for id in pairs(jobs) do M.cancel(id) end if quit then quit:push(true); commands:clear(); for _ = 1, #workers do commands:push(false) end end for _, thread in ipairs(workers) do pcall(thread.wait, thread) end + drain() for _, job in pairs(jobs) do - job.busy = false; removeStage(job) + job.busy = false + if job.stage then love.filesystem.remove(job.stage) end if job.stage then love.filesystem.remove(job.stage .. ".cfg"); love.filesystem.remove(job.stage .. ".stderr") end end if directory then love.filesystem.remove(directory) end diff --git a/src/net/DownloadPublish.lua b/src/net/DownloadPublish.lua new file mode 100644 index 000000000..4ccac01da --- /dev/null +++ b/src/net/DownloadPublish.lua @@ -0,0 +1,25 @@ +-- Worker-only atomic replacement. The caller owns the publication claim; +-- slow filesystem/antivirus operations never run in the game's poll path. +return function(source, target) + if love.system.getOS() ~= "Windows" then return os.rename(source, target) end + -- Windows rename cannot overwrite an existing file. Never delete the old + -- destination first: MoveFileExW replaces it in one filesystem operation. + local ffi = require("ffi") + ffi.cdef[[ + int MultiByteToWideChar(unsigned int, unsigned long, const char *, int, unsigned short *, int); + int MoveFileExW(const unsigned short *, const unsigned short *, unsigned long); + unsigned long GetLastError(void); + ]] + local function wide(s) + local n = ffi.C.MultiByteToWideChar(65001, 8, s, #s, nil, 0) + if n == 0 then error("invalid UTF-8 download path") end + local out = ffi.new("unsigned short[?]", n + 1) + if ffi.C.MultiByteToWideChar(65001, 8, s, #s, out, n) ~= n then error("download path conversion failed") end + return out + end + if ffi.C.MoveFileExW(wide(source), wide(target), 1) == 0 then + return nil, "download publish failed (Windows " .. tonumber(ffi.C.GetLastError()) .. ")" + end + return true +end + diff --git a/src/net/DownloadTransport.lua b/src/net/DownloadTransport.lua index c85da8c08..0a7ab0993 100644 --- a/src/net/DownloadTransport.lua +++ b/src/net/DownloadTransport.lua @@ -49,6 +49,9 @@ function M.run(job, output) local flushed, flushWhy = output:flush() if not flushed then error(flushWhy or "download flush failed") end bytes = bytes + n + -- One progress slot per request; a paused menu cannot accumulate an + -- unbounded message queue while a large file keeps streaming. + job.progress:clear(); job.progress:push(bytes) end tail = chunk:sub(n + 1) end diff --git a/src/net/download_worker.lua b/src/net/download_worker.lua index 201c77968..6863f5dfe 100644 --- a/src/net/download_worker.lua +++ b/src/net/download_worker.lua @@ -4,6 +4,7 @@ require("love.timer") require("love.system") local commands, results, quit = ... local Transport = require("src.net.DownloadTransport") +local publish = require("src.net.DownloadPublish") while true do local job = commands:demand() if quit:peek() or job == false then break end @@ -23,9 +24,30 @@ while true do if not closed or not result then bytes, err = nil, "could not close download staging file" end end if not ok then err, bytes = tostring(bytes or err), nil end + if bytes ~= nil then + local checked, result, why = pcall(function() + local info = love.filesystem.getInfo(job.stage, "file") + if not info or info.size ~= bytes then return nil, "download size mismatch" end + local parent = job.dest:match("^(.+)/[^/]+$") + if parent and not love.filesystem.createDirectory(parent) then + return nil, "could not create download destination directory" + end + -- The claim is the completion boundary. Cancel either wins before it + -- or reports false after it; it can never race a later file overwrite. + local claimed = job.control:performAtomic(function(control) + if control:peek() ~= "open" or job.cancel:peek() then return false end + if love.timer.getTime() >= job.deadline then return false end + control:clear(); control:push("publishing"); return true + end) + if not claimed then return nil, job.cancel:peek() and "cancelled" or "download timed out" end + local base = love.filesystem.getSaveDirectory() .. "/" + return publish(base .. job.stage, base .. job.dest) + end) + if not checked or not result then err, bytes = tostring(why or result or "could not publish download"), nil end + end love.filesystem.remove(job.stage .. ".cfg") love.filesystem.remove(job.stage .. ".stderr") - if bytes == nil or job.cancel:peek() then + if bytes == nil then love.filesystem.remove(job.stage) results:push({id = job.id, ok = false, err = err or "cancelled", httpStatus = status}) else diff --git a/tests/engine/async_download_queue.lua b/tests/engine/async_download_queue.lua index 5856d9406..8e385c753 100644 --- a/tests/engine/async_download_queue.lua +++ b/tests/engine/async_download_queue.lua @@ -1,5 +1,5 @@ package.path = "./?.lua;./?/init.lua;" .. package.path -local checks, ticks, workers, files, queued = 0, 0, {}, {}, {} +local checks, ticks, workers, queued = 0, 0, {}, {} local function check(ok, label) checks = checks + 1; assert(ok, label) end local function channel() local q = {} @@ -7,12 +7,13 @@ local function channel() function q:pop() if #self > 0 then return table.remove(self, 1) end end function q:peek() return self[1] end function q:clear() for i = #self, 1, -1 do self[i] = nil end end + function q:performAtomic(fn) return fn(self) end return q end love = {system = {getOS = function() return "Linux" end}, timer = {getTime = function() return ticks end}, filesystem = {createDirectory = function() return true end, getSaveDirectory = function() return "/owned" end, - getInfo = function(p) return files[p] and {type = "file", size = #files[p]} end, - remove = function(p) files[p] = nil; return true end}, thread = {newChannel = channel, + getInfo = function() error("filesystem IO in main-thread poll") end, + remove = function() return true end}, thread = {newChannel = channel, newThread = function() local t = {start = function(self, c, r) self.commands, self.results = c, r end, getError = function(self) return self.err end, wait = function() end} @@ -20,19 +21,18 @@ love = {system = {getOS = function() return "Linux" end}, timer = {getTime = fun end}} package.loaded["src.core.HostShell"] = {canSpawnProcess = function() return true end} local rename = os.rename -local publishes = 0 -os.rename = function(from, to) - from, to = from:sub(8), to:sub(8) - if to == "blocked" then return nil, "publish refused" end - check(files[from] ~= nil, "only a completed actual staging file is published") - files[to], files[from] = files[from], nil; publishes = publishes + 1; return true -end +os.rename = function() error("filesystem publication in main-thread poll") end local D = require("src.net.AsyncDownload") -local function complete(id, body, options) - local job = queued[id]; check(job ~= nil, "request queued before worker result") - files[job.stage] = body - workers[#workers].results:push({id = id, ok = not options or options.ok ~= false, - bytes = options and options.bytes or #body, err = options and options.err, httpStatus = 200}) +local function complete(id, ok, bytes, err) + check(queued[id] ~= nil, "request queued before worker result") + workers[#workers].results:push({id = id, ok = ok, bytes = bytes, err = err, httpStatus = ok and 200 or 404}) +end +local function claim(id) + local control = queued[id].control + return control:performAtomic(function(c) + if c:peek() ~= "open" then return false end + c:clear(); c:push("publishing"); return true + end) end for _, dest in ipairs({"", "/absolute", "../escape", "x/../escape", "C:\\escape", "x//file", "x/", "net-download/file"}) do check(D.download("https://host/file", dest) == nil, "invalid target refused: " .. dest) @@ -43,52 +43,65 @@ check(D.download("https://host/file", "out", {size = 3, maxBytes = 2}) == nil, " check(D.download("https://host/file", "out", {size = .5}) == nil, "fractional size refused") check(D.download("https://host/file", "out", {maxBytes = math.huge}) == nil, "infinite limit refused") check(D.download("https://host/file", "out", {userAgent = "bad\r\nheader"}) == nil, "invalid header refused") -files["nested/out"] = "previous" local id = assert(D.download("https://host/file", "nested/out", {size = 6})) check(#workers == 2, "fixed file worker ceiling") check(D.download("https://host/file", "nested\\out") == nil, "one active writer per normalized destination") -check(D.poll(id).status == "pending" and publishes == 0, "poll starts no transfer or publish") -files[queued[id].stage] = "abc" +check(D.poll(id).status == "pending", "poll starts no HTTP or filesystem work") +queued[id].progress:push(3) local progress = D.poll(id) -check(progress.receivedBytes == 3 and progress.progress == .5, "progress reads staging metadata") -check(files["nested/out"] == "previous", "partial file never replaces destination") -complete(id, "abcdef") +check(progress.receivedBytes == 3 and progress.progress == .5, "coalesced progress is available without filesystem IO") +queued[id].progress:clear() +check(D.poll(id).receivedBytes == 3, "progress cannot move backwards between worker updates") +check(claim(id), "worker claims completed transfer") +check(D.cancel(id) == false, "cancellation after publication claim is refused") +check(D.poll(id).phase == "publishing" and D.poll(id).cancellable == false, "publication boundary exposed honestly") +complete(id, true, 6) local state = D.poll(id) check(state.status == "ok" and state.receivedBytes == 6 and state.progress == 1, "completed file metadata") -check(files["nested/out"] == "abcdef", "successful atomic replacement") state.status = "modified"; check(D.poll(id).status == "ok", "detached polling state") -check(D.cancel(id) and files["nested/out"] == "abcdef", "cancel after success cannot remove published file") +check(not D.cancel(id) and D.poll(id).status == "ok", "completed publication is not rolled back") D.release(id); check(D.poll(id).status == "error", "released handle invalid") - -for _, mode in ipairs({"cancel", "release", "timeout", "size", "reported-size", "transport", "publish"}) do - local dest = mode == "publish" and "blocked" or "preserved" - files[dest] = "known good" - id = assert(D.download("https://host/file", dest, {size = 6, maxSeconds = 1})) - if mode == "cancel" then D.cancel(id) +for _, mode in ipairs({"cancel", "release", "timeout"}) do + id = assert(D.download("https://host/file", "preserved", {size = 6, maxSeconds = 1})) + if mode == "cancel" then check(D.cancel(id), "pre-publication cancellation accepted") elseif mode == "release" then D.release(id) - elseif mode == "timeout" then ticks = ticks + 2; check(D.poll(id).status == "error", "queue time counts toward deadline") end - complete(id, mode == "size" and "short" or "abcdef", - mode == "transport" and {ok = false, err = "HTTP 404"} or mode == "reported-size" and {bytes = 99} or nil) - check(D.poll(id).status == "error", mode .. " is terminal error") - check(files[dest] == "known good", mode .. " preserves previous file") - check(files[queued[id].stage] == nil, mode .. " retires private partial data") + else ticks = ticks + 2; check(D.poll(id).status == "error", "queue time counts toward deadline") end + check(not claim(id), mode .. " cannot permit worker publication") + complete(id, true, 6) -- adversarial late result must not revive the request + check(D.poll(id).status == "error", mode .. " remains terminal after late completion") + D.release(id) +end +for _, err in ipairs({"HTTP 404", "download size mismatch", "could not publish download"}) do + id = assert(D.download("https://host/file", "retry")); complete(id, false, nil, err) + state = D.poll(id) + check(state.status == "error" and state.err == err and state.httpStatus == 404, "worker error preserved: " .. err) D.release(id) end -id = assert(D.download("https://host/empty", "empty", {size = 0})); complete(id, "") -check(D.poll(id).status == "ok" and files.empty == "", "valid zero-byte HTTP download completes") +id = assert(D.download("https://host/empty", "empty", {size = 0})); claim(id); complete(id, true, 0) +check(D.poll(id).status == "ok" and D.poll(id).receivedBytes == 0, "zero-byte success completes") +D.release(id) +id = assert(D.download("https://host/finishing", "released-publish")); check(claim(id), "publication claim before release") +D.release(id) +check(D.download("https://host/retry", "released-publish") == nil, "released publication still owns destination until finished") +complete(id, true, 6) +local replacement = assert(D.download("https://host/retry", "released-publish")) +check(D.poll(id).status == "error", "released completion handle remains invalid") +D.cancel(replacement); complete(replacement, false, nil, "cancelled"); D.poll(replacement); D.release(replacement) +id = assert(D.download("https://host/finishing", "shutdown-publish")); claim(id); complete(id, true, 6) +D.shutdown(); check(D.poll(id).status == "ok", "shutdown drains a completed publication instead of leaving it pending") D.release(id) local held = {} for i = 1, D.MAX_JOBS do held[i] = assert(D.download("https://host/slow", "held" .. i)) end check(D.download("https://host/slow", "extra") == nil, "bounded active and queued file jobs") for _, h in ipairs(held) do D.cancel(h); D.release(h) end check(D.download("https://host/slow", "extra") == nil, "release cannot evade occupied worker budget") -for _, h in ipairs(held) do complete(h, "late") end +for _, h in ipairs(held) do complete(h, false, nil, "cancelled") end id = assert(D.download("https://host/new", "reused")) -check(D.poll(held[1]).status == "error", "late completion never revives released request") -workers[1].err = "worker stopped" +check(D.poll(held[1]).status == "error", "released request never revived") +workers[#workers].err = "worker stopped" check(D.poll(id).status == "error", "worker death terminates pending file jobs") D.shutdown() -id = assert(D.download("https://host/new", "restart")); complete(id, "ready") +id = assert(D.download("https://host/new", "restart")); claim(id); complete(id, true, 5) check(D.poll(id).status == "ok", "file pool restarts after shutdown") D.release(id); D.shutdown(); os.rename = rename print(("async file download queue: %d checks PASS"):format(checks)) diff --git a/tools/async-get-check/download_test_worker.lua b/tools/async-get-check/download_test_worker.lua new file mode 100644 index 000000000..71f586de1 --- /dev/null +++ b/tools/async-get-check/download_test_worker.lua @@ -0,0 +1,12 @@ +-- Hold only the final OS publication call, to exercise the real worker's +-- cancellation/commit boundary while the main thread keeps pumping. +require("love.thread") +require("love.filesystem") +require("love.timer") +require("love.system") +local publish = require("src.net.DownloadPublish") +package.loaded["src.net.DownloadPublish"] = function(source, target) + if target:sub(-18) == "/delay-publish.bin" then love.timer.sleep(.25) end + return publish(source, target) +end +assert(love.filesystem.load("src/net/download_worker.lua"))(...) diff --git a/tools/async-get-check/main.lua b/tools/async-get-check/main.lua index 28c7d12d0..734f4e9d9 100644 --- a/tools/async-get-check/main.lua +++ b/tools/async-get-check/main.lua @@ -22,6 +22,11 @@ end function love.load() Fetch = require('src.net.Fetch'); Net = require('src.mods.Net') base = assert(os.getenv('GET_TEST_URL')) + local newThread = love.thread.newThread + love.thread.newThread = function(source, ...) + if source == 'src/net/download_worker.lua' then source = 'download_test_worker.lua' end + return newThread(source, ...) + end end function love.run() local ok, err = xpcall(function() @@ -68,7 +73,19 @@ function love.run() check(Fetch.available(),'worker service can restart') local restart=assert(b:get(base..'/binary'));state=await(b,restart) check(state.status=='ok','GET after shutdown works');b:release(restart) - local fileApi = {poll = function(_, id) return Fetch.poll(id) end} + local inFilePoll = false + local getInfo = love.filesystem.getInfo + love.filesystem.getInfo = function(...) + assert(not inFilePoll, 'file poll must not perform filesystem IO') + return getInfo(...) + end + local fileApi = {poll = function(_, id) + inFilePoll = true + local ok, result = pcall(Fetch.poll, id) + inFilePoll = false + assert(ok, result) + return result + end} local expected = string.char(0,255,13,10)..'GET binary' love.filesystem.createDirectory('downloads') local target = 'downloads/target.bin' @@ -85,7 +102,7 @@ function love.run() local observedProgress = false while true do local before = love.timer.getTime() - state = Fetch.poll(fileJob) + state = fileApi:poll(fileJob) check(love.timer.getTime()-before < .1, 'file poll never waits on HTTP') if state.status ~= 'pending' then break end preserved('in-flight file does not overwrite previous complete file') @@ -129,6 +146,29 @@ function love.run() check(state.status=='ok' and state.receivedBytes==17*1024*1024,'file can exceed in-memory GET ceiling') check(love.filesystem.read('downloads/large.bin')==pattern:rep(69632),'large file bytes exact') Fetch.release(large) + local delayedPath='downloads/delay-publish.bin' + assert(love.filesystem.write(delayedPath,'previous complete file')) + local finishing=assert(Fetch.download(base..'/binary',delayedPath,{size=14})) + local observedPublication=false + local finishingAt=love.timer.getTime() + while true do + state=fileApi:poll(finishing) + if state.status~='pending' then break end + if state.phase=='publishing' then + observedPublication=true + check(not Fetch.cancel(finishing),'cancellation after publication claim is refused without waiting') + check(state.cancellable==false,'committing request reports cancellation boundary') + end + check(love.timer.getTime()-finishingAt<5,'delayed publication completes') + love.timer.sleep(.005) + end + check(observedPublication and state.status=='ok','main thread pumps during slow filesystem publication') + check(love.filesystem.read(delayedPath)==expected,'claimed publication completes with exact bytes') + Fetch.release(finishing) + local invalidTarget=assert(Fetch.download(base..'/binary','downloads',{size=14})) + state=await(fileApi,invalidTarget) + check(state.status=='error' and love.filesystem.getInfo('downloads').type=='directory','publish error preserves existing directory') + Fetch.release(invalidTarget) local heldFiles={} for i=1,8 do heldFiles[i]=assert(Fetch.download(base..'/slow','downloads/held'..i)) end check(Fetch.download(base..'/slow','downloads/overflow')==nil,'file queue bounded') diff --git a/tools/check_async_get.py b/tools/check_async_get.py index 51a8377e9..0656edc60 100644 --- a/tools/check_async_get.py +++ b/tools/check_async_get.py @@ -15,7 +15,7 @@ def do_GET(self): self.send_response(200);self.send_header('Content-Length',str(4*65536));self.end_headers() try: for _ in range(4):self.wfile.write(bytes(range(256))*256);self.wfile.flush();time.sleep(.25) - except (BrokenPipeError,ConnectionResetError):pass + except (BrokenPipeError,ConnectionResetError,ConnectionAbortedError):pass return if path=='/partial': self.send_response(200);self.send_header('Content-Length','999');self.end_headers();self.wfile.write(b'short');return @@ -23,14 +23,14 @@ def do_GET(self): self.send_response(200);self.send_header('Content-Length',str(17*1024*1024));self.end_headers() try: for _ in range(272):self.wfile.write(bytes(range(256))*256) - except (BrokenPipeError,ConnectionResetError):pass + except (BrokenPipeError,ConnectionResetError,ConnectionAbortedError):pass return body=b'\x00\xff\r\nGET binary' if path=='/binary' else b'slow' if path=='/slow' else b'x'*4096 self.send_response(200) if path!='/chunk': self.send_header('Content-Length',str(len(body))) self.end_headers() try: self.wfile.write(body) - except (BrokenPipeError,ConnectionResetError): pass + except (BrokenPipeError,ConnectionResetError,ConnectionAbortedError): pass def main(): p=argparse.ArgumentParser();p.add_argument('--love',required=True);p.add_argument('--source-root',type=pathlib.Path);args=p.parse_args() root=pathlib.Path(__file__).resolve().parents[1] @@ -55,10 +55,10 @@ def main(): temporary=tempfile.TemporaryDirectory(prefix='gen2recomp-get-check-') project=pathlib.Path(temporary.name) for name in ('src/net/Fetch.lua','src/net/AsyncGet.lua','src/net/get_worker.lua', - 'src/net/AsyncDownload.lua','src/net/DownloadTransport.lua','src/net/download_worker.lua', + 'src/net/AsyncDownload.lua','src/net/DownloadTransport.lua','src/net/DownloadPublish.lua','src/net/download_worker.lua', 'src/mods/Net.lua','src/core/HostShell.lua'): target=project/name;target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(source/name,target) - for name in ('main.lua','conf.lua'):shutil.copy2(root/'tools/async-get-check'/name,project/name) + for name in ('main.lua','conf.lua','download_test_worker.lua'):shutil.copy2(root/'tools/async-get-check'/name,project/name) server=http.server.ThreadingHTTPServer(('127.0.0.1',0),Handler) thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() try: From 7e02a100f3e839baddfe7135c3a2a0baeebaf43f Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 15:46:26 +0100 Subject: [PATCH 042/138] Synchronize download publication checks with the worker claim --- tools/async-get-check/main.lua | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tools/async-get-check/main.lua b/tools/async-get-check/main.lua index 734f4e9d9..ca68e8911 100644 --- a/tools/async-get-check/main.lua +++ b/tools/async-get-check/main.lua @@ -100,19 +100,31 @@ function love.run() local fileJob = assert(Fetch.download(base..'/stream', target, {size=262144})) check(love.timer.getTime()-started < .1, 'file transfer starts without waiting for server') local observedProgress = false + local pattern = {}; for i=0,255 do pattern[#pattern+1] = string.char(i) end + pattern = table.concat(pattern) + local completeFile = pattern:rep(1024) while true do local before = love.timer.getTime() state = fileApi:poll(fileJob) check(love.timer.getTime()-before < .1, 'file poll never waits on HTTP') if state.status ~= 'pending' then break end - preserved('in-flight file does not overwrite previous complete file') + -- Publication can finish after poll returns pending. Bracket the read + -- with a fresh control snapshot: only an unclaimed transfer still + -- promises the old file. At every phase the bytes must be one complete + -- version, never an in-place/partial overwrite. + local contents = love.filesystem.read(target) + local boundary = fileApi:poll(fileJob) + check(contents == 'previous complete file' or contents == completeFile, + 'concurrent publication exposes only a complete old or new file') + if boundary.status == 'pending' and boundary.cancellable then + check(contents == 'previous complete file', + 'unclaimed transfer preserves previous complete file') + end if state.receivedBytes > 0 and state.receivedBytes < 262144 then observedProgress = true end pulses = pulses + 1; love.timer.sleep(.005) end check(state.status == 'ok' and state.receivedBytes == 262144, 'streamed file completes: '..tostring(state.err)) check(observedProgress, 'progress available before transfer finishes') - local pattern = {}; for i=0,255 do pattern[#pattern+1] = string.char(i) end - pattern = table.concat(pattern) check(love.filesystem.read(target) == pattern:rep(1024), 'streaming preserves all binary bytes') Fetch.release(fileJob) for _, case in ipairs({ From c8af61c17ab8777eab36115d272013736e5bc2d4 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 18:33:44 +0100 Subject: [PATCH 043/138] Expose the native Gen2 follower predicate with loader cleanup --- .github/workflows/follower-contract.yml | 1 + src/core/ModCompat.lua | 4 +- src/world/OptionalFollower.lua | 20 +++- tests/engine/gen2_follower_predicate.lua | 117 +++++++++++++++++++++++ 4 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 tests/engine/gen2_follower_predicate.lua diff --git a/.github/workflows/follower-contract.yml b/.github/workflows/follower-contract.yml index 8b2fd95c4..0aecf2e29 100644 --- a/.github/workflows/follower-contract.yml +++ b/.github/workflows/follower-contract.yml @@ -23,6 +23,7 @@ jobs: - name: Check native followers and compatibility run: | luajit tests/engine/follower_spawn.lua + luajit tests/engine/gen2_follower_predicate.lua luajit tests/engine/api3_compat.lua luajit tests/engine/unified_mod_callbacks.lua - name: Compare both engines in every SDK mode diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..ec93b5cd1 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -30,8 +30,6 @@ -- src.world.gen2.FieldMoves field-move entry points live in -- src/script/* and the fieldmove.eligibility -- hook, not one module --- src.world.gen2.Follower PikachuFollower is Yellow's, not a general --- party follower -- src.ui.gen2.Chrome no shared window-chrome module -- src.ui.gen2.SaveMenu the save flow is part of StartMenu here -- src.ui.gen2.BattleAnimView Gen2AnimPlayer is a player, not a view @@ -63,6 +61,8 @@ ModCompat.ALIASES = { ["src.world.gen2.World"] = "src.world.OverworldController", ["src.world.gen2.Map"] = "src.world.Map", ["src.world.gen2.Permissions"] = "src.world.Collision", + -- Native optional companion, with Gen2 predicate and lifecycle lookup. + ["src.world.gen2.Follower"] = "src.world.OptionalFollower", ["src.world.gen2.Palettes"] = "src.render.PaletteFX", -- save-side data diff --git a/src/world/OptionalFollower.lua b/src/world/OptionalFollower.lua index c89bf6329..192007a00 100644 --- a/src/world/OptionalFollower.lua +++ b/src/world/OptionalFollower.lua @@ -6,7 +6,22 @@ local Version = require("src.core.GameVersion") local F = {} local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } -local function nativeDefault() +-- Keep the predicate on the native module table so ModSeams can restore it +-- when a legacy mod fails to load or is disabled. A closure-only setter would +-- leave the unloaded mod's callback alive in subsequent game sessions. +F._shouldSpawn = function() + return false +end +function F.setShouldSpawn(fn) + local previous = F._shouldSpawn + if fn then + assert(type(fn) == "function", "follower predicate must be a function") + F._shouldSpawn = fn + end + return previous +end +local function nativeDefault(game, world) + if Version.generation() == 2 then return F._shouldSpawn(game, world) end return false end local function current(world) @@ -16,6 +31,9 @@ local function current(world) end end end +function F.current(world) + return (current(world)) +end local function remove(world) local npc, at = current(world) if not npc then diff --git a/tests/engine/gen2_follower_predicate.lua b/tests/engine/gen2_follower_predicate.lua new file mode 100644 index 000000000..d845f342e --- /dev/null +++ b/tests/engine/gen2_follower_predicate.lua @@ -0,0 +1,117 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +require("src.core.ModCompat").install() +local Follower = require("src.world.gen2.Follower") +local Fixture = dofile("tests/follower/fixture.lua") +local oldVersion = Version.get() +T.eq(Follower, require("src.world.OptionalFollower"), "alias is the actual native companion") + +-- Use the real mod loader, native NPCs, maps, interpolation and map-entry +-- producer. Only cartridge art/data and the outer application are fixtures. +local function load(f, failing) + local entry = [[ + local mod = ... + local F = require("src.world.gen2.Follower") + mod.exports.api = mod + mod.exports.allow, mod.exports.calls = false, 0 + local previous = F.setShouldSpawn(function(game, world) + mod.exports.calls = mod.exports.calls + 1 + mod.exports.game, mod.exports.world = game, world + return mod.exports.allow + end) + mod.exports.previous = previous + ]] + if failing then entry = entry .. '\nerror("intentional follower entry failure")' end + return T.sdk.loadMods({ "mods/follower_setter" }, { + data = T.fixtures.fresh(), + fs = T.sdk.memfs({ + ["mods/follower_setter/manifest.json"] = [[{ + "id":"follower_setter","name":"Native follower setter", + "version":"1.0.0","api":2,"entry":"main.lua","affects_link":false + }]], + ["mods/follower_setter/main.lua"] = entry, + }), + }) +end + +for _, version in ipairs({ "gold", "silver", "crystal", "prism" }) do + local f = Fixture.new(".", "gen2", version, 2, "native") + f.close() -- remove the fixture's portable hook before loading the setter mod + local nativeDefault = Follower.setShouldSpawn(nil) + local result = load(f) + T.eq(#result.errors, 0, "legacy setter mod loads on " .. version) + local e = result.loader.exports.follower_setter + T.eq(e.previous, nativeDefault, "setter returns the actual previous predicate") + f.tick() + T.eq(Follower.current(f.world), nil, "false predicate keeps vanilla companion absent") + T.eq(e.game, f.game, "predicate receives native game") + T.eq(e.world, f.world, "predicate receives native world") + for _, value in ipairs({ true, 0, "" }) do + e.allow = value + f.tick() + local npc = Follower.current(f.world) + T.check(npc ~= nil, "Lua-truthy predicate spawns real native NPC") + T.eq(npc, f.controller.current(f.world), "native controller and alias share the same NPC") + T.eq(npc, f.world.follower, "world references that same companion") + T.eq(npc.passable, true, "optional companion cannot block native movement") + T.eq(f.world.npcs[#f.world.npcs], npc, "normal NPC loop owns companion updates") + T.eq(f.world.entities[#f.world.entities], npc, "normal draw list owns companion") + end + local npc = Follower.current(f.world) + local bystander = { id = "ordinary-object" } + f.world.npcs[#f.world.npcs + 1] = bystander + f.world.entities[#f.world.entities + 1] = bystander + local startX = npc.px + for _ = 1, 2 do + local p = f.player + p.targetX, p.targetY, p.moving, p.facing = p.cellX + 1, p.cellY, true, "right" + for frame = 1, 4 do f.tick() end + p.cellX, p.px, p.targetX, p.targetY, p.moving = p.cellX + 1, (p.cellX + 1) * 16, nil, nil, false + end + T.check(npc.px > startX, "native interpolation follows committed player destinations") + local trail = f.world.followerTrail + f.enter({ keepFollower = npc }) + T.eq(Follower.current(f.world), npc, "native connection retains companion identity") + T.eq(f.world.followerTrail, trail, "connection retains the live buffered trail") + local x, y = npc.px, npc.py + f.controller.rebase(f.world, -2, 1) + T.eq(npc.px, x - 32, "connection rebase moves real companion x") + T.eq(npc.py, y + 16, "connection rebase moves real companion y") + local off = e.api.hooks:wrap("world.follower.spawn", function() return false end, 100) + f.tick() + T.eq(Follower.current(f.world), nil, "public hook can deny the legacy predicate") + T.eq(f.world.npcs[1], bystander, "denial retains unrelated NPC") + off() + f.tick() + T.check(Follower.current(f.world) ~= nil, "unsubscribe returns to legacy predicate") + require("src.mods.Loader").teardown() + result.release() + T.eq(Follower.setShouldSpawn(nil), nativeDefault, "real mod unload restores original predicate") + local calls = e.calls + f.tick() + T.eq(e.calls, calls, "unloaded callback no longer runs") + T.eq(Follower.current(f.world), nil, "unload removes companion through native update") + T.eq(f.world.npcs[1], bystander, "unload preserves map objects") + local failed = load(f, true) + T.check(#failed.errors > 0, "failed entry is reported") + T.eq(Follower.setShouldSpawn(nil), nativeDefault, "failed entry rolls back legacy setter") + f.tick() + T.eq(Follower.current(f.world), nil, "failed mod cannot leave a companion running") + failed.release() +end + +-- Installing a Gen2 predicate must not replace Yellow's native companion or +-- enable an optional GBA companion. Both remain under their existing owners. +for _, version in ipairs({ "red", "yellow", "firered" }) do + local f = Fixture.new(".", "gen2", version, 2, "native") + local calls = 0 + local previous = Follower.setShouldSpawn(function() calls = calls + 1; return true end) + f.tick() + T.eq(calls, 0, "Gen2 predicate is not called for " .. version) + T.eq(f.current() ~= nil, version == "yellow", "other generation retains native follower default") + Follower.setShouldSpawn(previous) + f.close() +end +Version.set(oldVersion) +T.finish("native Gen2 follower setter lifecycle") From 6fb60df99b2465f6db5d6b0e492f1027a764d11f Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 19:09:29 +0100 Subject: [PATCH 044/138] Add portable asset pack requirements and atomic inbox installs --- .github/workflows/mod-asset-packs.yml | 31 ++++ docs/mod-asset-packs.md | 71 ++++++++ src/import/CacheFs.lua | 15 ++ src/import/Importers.lua | 246 +++++++++++++++++++++++++ src/import/PackData.lua | 130 +++++++++++++ src/import/PackInbox.lua | 33 ++++ src/import/RomImporter.lua | 39 ++++ src/mods/AssetPacks.lua | 98 ++++++++++ src/mods/LauncherMods.lua | 8 + src/mods/Loader.lua | 14 +- src/mods/ManagerState.lua | 7 +- src/mods/Manifest.lua | 3 + src/mods/StreamMD5.lua | 117 ++++++++++++ src/net/DownloadPublish.lua | 25 +++ tests/asset_pack_runtime/conf.lua | 7 + tests/asset_pack_runtime/main.lua | 47 +++++ tests/engine/asset_pack_contracts.lua | 251 ++++++++++++++++++++++++++ 17 files changed, 1140 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/mod-asset-packs.yml create mode 100644 docs/mod-asset-packs.md create mode 100644 src/import/Importers.lua create mode 100644 src/import/PackData.lua create mode 100644 src/import/PackInbox.lua create mode 100644 src/mods/AssetPacks.lua create mode 100644 src/mods/StreamMD5.lua create mode 100644 src/net/DownloadPublish.lua create mode 100644 tests/asset_pack_runtime/conf.lua create mode 100644 tests/asset_pack_runtime/main.lua create mode 100644 tests/engine/asset_pack_contracts.lua diff --git a/.github/workflows/mod-asset-packs.yml b/.github/workflows/mod-asset-packs.yml new file mode 100644 index 000000000..c017f1074 --- /dev/null +++ b/.github/workflows/mod-asset-packs.yml @@ -0,0 +1,31 @@ +name: Portable asset pack contracts +on: + pull_request: + paths: + - 'src/import/Pack*.lua' + - 'src/import/Importers.lua' + - 'src/import/CacheFs.lua' + - 'src/import/LuaWriter.lua' + - 'src/import/RomImporter.lua' + - 'src/mods/AssetPacks.lua' + - 'src/mods/StreamMD5.lua' + - 'src/mods/Manifest.lua' + - 'src/mods/Loader.lua' + - 'src/mods/LauncherMods.lua' + - 'src/mods/ManagerState.lua' + - 'tests/engine/asset_pack_contracts.lua' + - 'tests/asset_pack_runtime/**' + - '.github/workflows/mod-asset-packs.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + packs: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - run: sudo apt-get update && sudo apt-get install -y luajit lua5.4 + - run: luajit tests/engine/asset_pack_contracts.lua + - name: Check the portable data parser on plain Lua + run: lua5.4 -e 'local d=require("src.import.PackData");local w=require("src.import.LuaWriter");assert(d.decode(w.encode({1,true,"x"}))[3]=="x");assert(not d.decode("return os.exit()"))' diff --git a/docs/mod-asset-packs.md b/docs/mod-asset-packs.md new file mode 100644 index 000000000..65f9d6ed0 --- /dev/null +++ b/docs/mod-asset-packs.md @@ -0,0 +1,71 @@ +# Portable asset packs + +Mods may declare `required_assets` and `optional_assets` arrays: + +```json +{"required_assets":[{"importer":"lttp","pack":"sprites","version":">=1.0.0"}], + "optional_assets":[{"importer":"lttp","pack":"music"}]} +``` + +Identities are lowercase letters, numbers, `_` and `-`. Requirements must be +dense arrays, unique across both lists; versions use the existing dependency +range grammar. Unknown optional packs stay unavailable. Missing, incompatible +or incomplete required packs stop the mod before its entry executes and block +hard dependents. The manager reports `missing_assets` and stages a restart once +the requirement is satisfied. The launcher displays the missing pack identity. + +`mod.packs` provides `list`, `info(importer, pack)`, `entries(importer, pack)`, +`entry(importer, pack, entryId)`, `read(importer, pack, entryId)` and +`metadata(importer, pack, entryId)`. Calls return data or `nil, reason`. +Enumerated entries are sorted; nested metadata is detached from the stored +descriptor. Undeclared packs cannot be read through this API. + +## Installing an existing extracted pack + +Copy a portable pack directory, including its `pack.lua` and referenced files, +to `imports/asset_packs///` in the active game-data folder. +Select **Install asset packs from inbox** in the launcher's Mods panel. This +directory flow works with phone file sharing, Android storage and Switch MTP, +without requiring a desktop file picker or a shell command. It preserves the +original inbox files and reports independent failures within a batch. + +The current catalogue recognizes `gen5_bw/battle_sprites`, `pmd_red/sprites`, +and `lttp/sprites`, `lttp/tiles`, `lttp/palettes`, `lttp/sfx`, `lttp/music`. +The final two are catalogue placeholders; no audio extraction is claimed. + +A format-1 `pack.lua` returns a literal table with `format`, `importer`, `pack`, +`kind`, `version`, `source` (cartridge `md5`, optional `name`/`size`) and keyed +`entries`. Each entry records `file`, `size` and optional display/sprite fields. +`metadata={file="animation.lua",size=123}` references another literal table. +The supported kinds are sprite, tileset, palette, font, sfx, music and sample; +their file extensions are checked. No cartridge bytes or extracted assets are +included in the repository or tests. + +Reads are bounded to 8 MiB per descriptor, metadata file or asset. File sizes +are checked before allocation. Data files support literal tables, arrays, +strings, booleans and finite numbers; they are parsed without executing Lua, +with byte, nesting and node limits. Paths cannot leave their pack directory. + +The installer yields after each entry, writes content-addressed objects, +verifies staged bytes and atomically replaces `pack.lua` only after copying all +referenced data. Failed writes, truncated writes, failed replacements and +abandoned jobs leave the previous descriptor and its assets usable. Identical +objects are reused. Unreferenced objects after an interrupted install are +retained; the installer never deletes user source directories or guesses which +old assets are safe to remove. Concurrent installations into one destination +are not offered by the launcher. + +## Scope and remaining work + +This implements the portable pack producer/consumer lifecycle for **existing +extracted packs**. Native cartridge-to-pack extraction for the three catalogue +families, background extraction workers, image/animation parity and physical +device gameplay remain open. A working pack contract does not establish that +an entire mod initializes or is playable. New engine changes also need a fresh +test release before they are present in distributed phone/console packages. + +Validation: `luajit tests/engine/asset_pack_contracts.lua` exercises actual mod +loading, dependent recovery, required/optional semantics, detached metadata, +bounded reads, atomic failure recovery, inbox batching and launcher job paths. +The platform flags in this ROM-free test exercise the shared control flow; +they are not physical-device launch or gameplay evidence. diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index 814ced9e5..f6dd0ee8b 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -716,6 +716,19 @@ function CacheFs.rawRemoveDir(rel) return true end +-- Publish a completed, validated import within the current writable data root. +-- Reuse the platform's atomic replacement used by native downloads, including +-- Windows replacement without deleting the previously installed file first. +function CacheFs.rawRename(from, to) + local root = rawRoot() + if not root then + local lfs = love and love.filesystem + root = lfs and lfs.getSaveDirectory and lfs.getSaveDirectory() + end + if not root then return nil,"no writable data root" end + return require("src.net.DownloadPublish")(realPath(root,from),realPath(root,to)) +end + -- A love.filesystem-shaped File for a path under the live root. -- -- Only WRITING needs this. A read goes through love.filesystem, which can @@ -844,6 +857,7 @@ function CacheFs.dataFs() dataFsCache = { -- reads and enumeration: every home at once getInfo = mounted(function(...) return love.filesystem.getInfo(...) end), + getRealDirectory = mounted(function(...) return love.filesystem.getRealDirectory(...) end), getDirectoryItems = mounted(function(...) return love.filesystem.getDirectoryItems(...) end), read = mounted(function(...) return love.filesystem.read(...) end), load = mounted(function(...) return love.filesystem.load(...) end), @@ -853,6 +867,7 @@ function CacheFs.dataFs() write = function(rel, data) return CacheFs.rawWrite(rel, data) end, createDirectory = function(rel) return CacheFs.rawCreateDirectory(rel) end, remove = function(rel) return CacheFs.rawRemove(rel) end, + rename = function(from,to) return CacheFs.rawRename(from,to) end, removeDir = function(rel) return CacheFs.rawRemoveDir(rel) end, -- `mode` given means ALREADY OPEN, exactly as love.filesystem does it -- -- see realFile. Returning an unopened handle here reads as an empty file diff --git a/src/import/Importers.lua b/src/import/Importers.lua new file mode 100644 index 000000000..29066f3d2 --- /dev/null +++ b/src/import/Importers.lua @@ -0,0 +1,246 @@ +-- Shared, cartridge-derived asset packs. Providers publish data; consumers +-- only see packs explicitly declared by their mod. No game assets ship here. +local Data = require("src.import.PackData") +local Semver = require("src.mods.Semver") +local Writer = require("src.import.LuaWriter") +local MD5 = require("src.mods.StreamMD5") +local Importers = { ROOT="asset_packs", PACK_FILE="pack.lua", PACK_FORMAT=1, + MAX_ENTRY_BYTES=Data.MAX_BYTES } +Importers.KINDS = { + sprite={ext={png=true}}, tileset={ext={png=true}}, palette={ext={png=true,lua=true}}, + font={ext={png=true}}, sfx={ext={wav=true,ogg=true}}, music={ext={wav=true,ogg=true}}, + sample={ext={wav=true,ogg=true}}, +} +Importers.STATUS = { planned=true, beta=true, ready=true } +-- This catalogue describes the portable pack identities. ROM extraction +-- services are tracked separately; installing an existing pack is available. +local catalog = { + {id="gen5_bw",name="Black / White",status="beta", + source={name="Black / White cartridge dump",formats={"nds"},sizes={268435456}}, + packs={{id="battle_sprites",name="Battle sprites",kind="sprite"}}}, + {id="pmd_red",name="Red Rescue Team",status="beta", + source={name="Red Rescue Team cartridge dump",formats={"gba"},sizes={33554432}}, + packs={{id="sprites",name="Sprite sheets",kind="sprite"}}}, + {id="lttp",name="A Link to the Past",status="beta", + source={name="A Link to the Past cartridge dump",formats={"sfc","smc"},sizes={1048576,2097152}}, + packs={{id="sprites",name="Sprites",kind="sprite"},{id="tiles",name="Tiles",kind="tileset"}, + {id="palettes",name="Palettes",kind="palette"}, + {id="sfx",name="Sound effects",kind="sfx",planned=true}, + {id="music",name="Music",kind="music",planned=true}}}, +} +local byId = {}; for _, desc in ipairs(catalog) do byId[desc.id] = desc end +local cache = setmetatable({}, {__mode="k"}) +local function validId(id) return type(id)=="string" and #id<=128 and id:match("^[a-z0-9_%-]+$")~=nil end +Importers.validId = validId +local function pathInside(file) + if type(file)~="string" or file=="" or #file>1024 or file:find("[%z\1-\31\\:]") + or file:sub(1,1)=="/" then return nil end + local parts={} + for p in file:gmatch("[^/]+") do + if p==".." then return nil end + if p~="." then parts[#parts+1]=p end + end + return #parts>0 and table.concat(parts,"/") or nil +end +local function integer(n) return type(n)=="number" and n>=0 and nImporters.MAX_ENTRY_BYTES then return false end + if files[rel] and files[rel]~=size then return false end + files[rel]=size; return true + end + for key,entry in pairs(m.entries) do + if type(key)~="string" or not key:match("^[a-z0-9_%-]+[a-z0-9_/%-]*$") or #key>512 + or type(entry)~="table" or not fileRecord(entry.file,entry.size) + or not kind.ext[ext(entry.file)] then return false,"invalid pack entry: "..tostring(key) end + if entry.metadata~=nil then + local meta=entry.metadata + if type(meta)~="table" or not fileRecord(meta.file,meta.size) or ext(meta.file)~="lua" then + return false,"invalid entry metadata: "..key + end + end + count=count+1; if count>20000 then return false,"pack entry count limit" end + end + return true,nil,count +end +function Importers.invalidate(fs,id,pack) + -- Providers may use a scoped wrapper over the same writable filesystem. + -- Invalidate readers of that relative identity as well as the writer. + local path=Importers.manifestPath(id,pack) + if path then for _,c in pairs(cache) do c[path]=nil end end +end +function Importers.readPack(id,pack,fs) + fs=Importers.fs(fs) + local path,err=Importers.manifestPath(id,pack); if not path then return nil,err end + local info=fs and fs.getInfo and fs.getInfo(path,"file") + if not info then return nil,"pack is not installed" end + local c=cache[fs]; local old=c and c[path] + local origin=fs.getRealDirectory and fs.getRealDirectory(path) + -- Timeless injected filesystems are deliberately never memoized. + if old and type(info.modtime)=="number" and info.modtime>=0 and info.modtime Data.MAX_BYTES then return nil, "pack data exceeds the read limit" end + local at, nodes, depth = 1, 0, 0 + local function bad(why) error("pack data at byte " .. at .. ": " .. why, 0) end + local function space() + while true do + local _, last = bytes:find("^%s*", at); at = last + 1 + if bytes:sub(at, at + 1) ~= "--" then return end + -- Only line comments; block comments are unnecessary for generated data. + at = (bytes:find("\n", at + 2, true) or #bytes) + 1 + end + end + local function stringValue() + local quote, out = bytes:sub(at, at), {}; at = at + 1 + while true do + local c = bytes:sub(at, at); at = at + 1 + if c == "" then bad("unterminated string") end + if c == quote then return table.concat(out) end + if c == "\\" then + c = bytes:sub(at, at); at = at + 1 + local escapes = { n="\n", r="\r", t="\t", a="\a", b="\b", f="\f", v="\v", + ["\\"]="\\", ['"']='"', ["'"]="'", ["\n"]="\n" } + if c:match("%d") then + local digits = c .. (bytes:match("^%d?%d?", at) or "") + if tonumber(digits) > 255 then bad("invalid decimal escape") end + at = at + #digits - 1; c = string.char(tonumber(digits)) + elseif escapes[c] then c = escapes[c] + else bad("invalid string escape") end + end + out[#out + 1] = c + end + end + local value + local function tableValue() + depth = depth + 1; if depth > Data.MAX_DEPTH then bad("table nesting limit") end + at = at + 1 + local out, seen, index = {}, {}, 1 + space() + while bytes:sub(at, at) ~= "}" do + if at > #bytes then bad("unterminated table") end + local key, item + if bytes:sub(at, at) == "[" then + at = at + 1; key = value(); space() + if bytes:sub(at, at) ~= "]" then bad("expected ]") end + at = at + 1; space() + if bytes:sub(at, at) ~= "=" then bad("expected =") end + at = at + 1; item = value() + else + local name = bytes:match("^[%a_][%w_]*", at) + local saved = at + if name then at = at + #name; space() end + if name and bytes:sub(at, at) == "=" then + key = name; at = at + 1; item = value() + else + at = saved; key = index; index = index + 1; item = value() + end + end + if type(key) ~= "string" and type(key) ~= "number" then bad("invalid table key") end + if seen[key] then bad("duplicate table key") end + seen[key], out[key] = true, item + space() + local c = bytes:sub(at, at) + if c == "," or c == ";" then at = at + 1; space() + elseif c ~= "}" then bad("expected table separator") end + end + at = at + 1; depth = depth - 1; return out + end + value = function() + nodes = nodes + 1; if nodes > Data.MAX_NODES then bad("node limit") end + space(); local c = bytes:sub(at, at) + if c == '"' or c == "'" then return stringValue() end + if c == "{" then return tableValue() end + local name = bytes:match("^[%a_][%w_]*", at) + if name == "true" or name == "false" then at = at + #name; return name == "true" end + if c:match("[%d%+%-%.]") then + local token = bytes:match("^[^%s,%]}%;]+", at) + local n = token and tonumber(token) + if not n or n ~= n or n == math.huge or n == -math.huge then bad("invalid finite number") end + at = at + #token; return n + end + bad("expected a literal") + end + local ok, result = pcall(function() + space() + if bytes:match("^[%a_][%w_]*", at) ~= "return" then bad("expected return") end + at = at + 6; local out = value(); space() + if at <= #bytes then bad("trailing content") end + if type(out) ~= "table" then bad("root must be a table") end + return out + end) + if not ok then return nil, result end + return result +end + +function Data.copy(value) + if type(value) ~= "table" then return value end + local out = {}; for k, v in pairs(value) do out[k] = Data.copy(v) end; return out +end + +-- Stat before reading, and give native handles an explicit maximum length. +function Data.read(fs, path, expected) + if not (fs and fs.getInfo) then return nil, "pack file information is unavailable" end + local info = fs.getInfo(path, "file") + local size = info and info.size + if not (info and info.type == "file" and type(size) == "number" and size >= 0 + and size % 1 == 0 and size <= Data.MAX_BYTES) then return nil, "pack file is missing or oversized" end + if expected and size ~= expected then return nil, "pack file size does not match" end + local bytes, err + if fs.newFile then + local file; file, err = fs.newFile(path) + if not file then return nil, err or "could not open pack file" end + local ok, result, why = pcall(function() + local opened, openErr = file:open("r"); if not opened then return nil, openErr end + if size == 0 then return "" end + return file:read(size) + end) + pcall(file.close, file) + if not ok then return nil, tostring(result) end + bytes, err = result, why + elseif fs.readRange then bytes, err = fs.readRange(path, 0, size) + elseif fs.read then bytes, err = fs.read(path, size) end + if type(bytes) ~= "string" or #bytes ~= size then return nil, err or "short pack read" end + return bytes +end + +return Data diff --git a/src/import/PackInbox.lua b/src/import/PackInbox.lua new file mode 100644 index 000000000..9ef63f271 --- /dev/null +++ b/src/import/PackInbox.lua @@ -0,0 +1,33 @@ +-- Files/iTunes sharing, Android storage and console MTP all reach this same +-- relative inbox. Never move or delete the user's source pack directories. +local Importers=require("src.import.Importers") +local Inbox={ROOT="imports/asset_packs"} +function Inbox.start(fs) + fs=Importers.fs(fs) + local queue={} + for _,desc in ipairs(Importers.all()) do + for _,pack in ipairs(desc.packs) do + local root=Inbox.ROOT.."/"..desc.id.."/"..pack.id + if fs.getInfo(root.."/"..Importers.PACK_FILE,"file") then + queue[#queue+1]={importer=desc.id,pack=pack.id,root=root} + end + end + end + return coroutine.create(function() + local installed,failed={},{} + for _,item in ipairs(queue) do + local co=Importers.install(item.importer,item.pack,fs,item.root,fs) + while true do + local ok,value=coroutine.resume(co) + if not ok then + failed[#failed+1]={importer=item.importer,pack=item.pack,error=tostring(value)}; break + end + if coroutine.status(co)=="dead" then installed[#installed+1]=value; break end + value.importer,value.pack=item.importer,item.pack + coroutine.yield(value) + end + end + return {installed=installed,failed=failed,found=#queue} + end) +end +return Inbox diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 858974555..a563b22a2 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -4891,6 +4891,7 @@ function RomImporter:_pollPickedFiles(dt) end function RomImporter:update(dt) + self:_stepAssetPacks() local translation=require("src.translation.Service") translation.update() translation.setLauncher(self:_settings()) @@ -5437,6 +5438,7 @@ function RomImporter:_resetFrameRects() self.modRects = nil self.modDeleteRects = nil self.modImportRect = nil + self.modAssetPacksRect = nil self.modImportFileRects = nil self.modImportGameRects = nil -- Enable all / Disable all share that header and the same rule (#647): they @@ -7140,6 +7142,9 @@ function RomImporter:mousepressed(x, y, button) -- conflict); Delete fires immediately; a toggle switch, which lives in the -- scrollable list, only ARMS a press so _updateSlotDrag can tell a click from -- a drag-scroll (Android, with no pointer polling, toggles on press). + if inside(self.modAssetPacksRect, x, y) then + self:installAssetPacks(); return + end if inside(self.modImportRect, x, y) then self:chooseMod(); return end @@ -9865,6 +9870,36 @@ end -- same options.mods enable-state the loader persists). Cheap enough to call on -- any toggle / install; the per-frame draw calls it lazily through _ensureMods -- so a still list costs nothing after the first paint. +function RomImporter:installAssetPacks() + if self.workState=="working" or self._assetPackInstallJob then return end + self._assetPackInstallJob=require("src.import.PackInbox").start() + self.modNotice={ok=true,text=Strings("Reading asset pack inbox...")} +end + +function RomImporter:_stepAssetPacks() + local co=self._assetPackInstallJob + if not co then return end + -- One bounded entry per frame, on phones and consoles as on desktop. + local ok,value=coroutine.resume(co) + if not ok then + self._assetPackInstallJob=nil + self.modNotice={ok=false,text=Strings("Asset pack installation failed: %s",tostring(value))} + elseif coroutine.status(co)=="dead" then + self._assetPackInstallJob=nil + if value.found==0 then + self.modNotice={ok=false,text=Strings("Copy extracted packs into %s/imports/asset_packs///, then install again.", + require("src.import.CacheFs").rootReport().path or love.filesystem.getSaveDirectory())} + elseif #value.failed>0 then + local first=value.failed[1] + self.modNotice={ok=false,text=Strings("Installed %d pack(s); %s/%s failed: %s",#value.installed, + first.importer,first.pack,first.error)} + else self.modNotice={ok=true,text=Strings("Installed %d asset pack(s).",#value.installed)} end + self:_refreshMods() + else + self.modNotice={ok=true,text=Strings("Installing %s/%s: %d/%d",value.importer,value.pack,value.done,value.total)} + end +end + function RomImporter:_refreshMods() local LauncherMods = require("src.mods.LauncherMods") -- Once per session, ahead of the first listing: pull in any mod the player @@ -10387,6 +10422,10 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged) end local top = y + headerH + 14 * s + self.modAssetPacksRect = self:_glassyButton(x, top, w, btnH, + self._assetPackInstallJob and Strings("Installing asset packs...") or Strings("Install asset packs from inbox"), + self.saveBtnFont, not self._assetPackInstallJob and self.workState~="working") + top = top + btnH + 8 * s -- Notice line: the last install/delete result, else the platform hint. -- diff --git a/src/mods/AssetPacks.lua b/src/mods/AssetPacks.lua new file mode 100644 index 000000000..c65bc2e0a --- /dev/null +++ b/src/mods/AssetPacks.lua @@ -0,0 +1,98 @@ +local Importers=require("src.import.Importers") +local Data=require("src.import.PackData") +local Semver=require("src.mods.Semver") +local Packs={MAX_READ_BYTES=Importers.MAX_ENTRY_BYTES} + +function Packs.parse(required,optional) + local seen={} + local function list(value,field,isRequired) + if value==nil then return {} end + assert(type(value)=="table",field.." must be an array") + local count=0 + for key in pairs(value) do + count=count+1 + assert(type(key)=="number" and key%1==0 and key>=1 and key<=#value,field.." must be a dense array") + end + assert(count==#value,field.." must be a dense array") + local out={} + for _,entry in ipairs(value) do + assert(type(entry)=="table" and Importers.validId(entry.importer) and Importers.validId(entry.pack),field.." has an invalid pack identity") + local key=entry.importer.."/"..entry.pack + assert(not seen[key],"duplicate asset requirement: "..key); seen[key]=true + if entry.version~=nil then + assert(type(entry.version)=="string" and entry.version~="" and Semver.validRange(entry.version),field.." has an invalid version range") + end + out[#out+1]={importer=entry.importer,pack=entry.pack,version=entry.version,required=isRequired} + end + return out + end + local a=list(required,"required_assets",true) + return a,list(optional,"optional_assets",false) +end +function Packs.specs(manifest) + local out={} + for _,list in ipairs({manifest.required_assets or {},manifest.optional_assets or {}}) do + for _,spec in ipairs(list) do out[#out+1]=Data.copy(spec) end + end + return out +end +function Packs.missing(manifest,fs) + local out={} + for _,spec in ipairs(manifest.required_assets or {}) do + local pack,err=Importers.check(spec,fs) + if not pack then out[#out+1]={importer=spec.importer,pack=spec.pack,version=spec.version,reason=err} end + end + return #out>0 and out or nil +end +function Packs.new(manifest,fs) + fs=Importers.fs(fs) + local declared={} + for _,spec in ipairs(Packs.specs(manifest)) do declared[spec.importer.."/"..spec.pack]=spec end + local function resolve(id,pack) + local spec=declared[tostring(id).."/"..tostring(pack)] + if not spec then return nil,"undeclared asset pack" end + return Importers.resolve(spec,fs) + end + local function entry(id,pack,key) + local m,err=resolve(id,pack); if not m then return nil,err end + local e=m.entries[key]; if not e then return nil,"no such pack entry: "..tostring(key) end + return e + end + local function view(key,e) + local out=Data.copy(e); out.id=key; return out + end + local api={} + function api:list() + local out={} + for _,spec in ipairs(Packs.specs(manifest)) do + local m=Importers.check(spec,fs) + out[#out+1]={importer=spec.importer,pack=spec.pack,required=spec.required~=false, + installed=m~=nil,version=m and m.version,kind=m and m.kind} + end + return out + end + function api:info(id,pack) + local m,err=resolve(id,pack); if not m then return nil,err end + local n=0; for _ in pairs(m.entries) do n=n+1 end + return {importer=id,pack=pack,kind=m.kind,version=m.version,entries=n,source=m.source.name} + end + function api:entries(id,pack) + local m,err=resolve(id,pack); if not m then return nil,err end + local out={}; for key,e in pairs(m.entries) do out[#out+1]=view(key,e) end + table.sort(out,function(a,b)return a.id 0 + self.restartPending = #self:stagedList() > 0 or self.assetsChanged == true -- a live set that drifted off the named profile reverts to ad-hoc local opts = self:optionsTable() if opts.activeProfile then @@ -265,6 +269,7 @@ end function ManagerState:glyphFor(m) if self:isStaged(m) then return GLYPH.staged end if not m.enabled then return GLYPH.disabled end + if m.waitingAssets then return GLYPH.blocked end if m.state == "blocked_dependency" then return GLYPH.blocked end if m.error then return GLYPH.errored end return " " diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index ac5614634..eef915690 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -280,6 +280,7 @@ function Manifest.validate(raw, path) end local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible) + local requiredAssets, optionalAssets = require("src.mods.AssetPacks").parse(raw.required_assets, raw.optional_assets) -- `support`: where to thank the mod's creator -- a Ko-fi, a Patreon, a -- PayPal page. The launcher's mod card turns it into a "Support the @@ -330,6 +331,8 @@ function Manifest.validate(raw, path) -- second game's ROM. Declared here, satisfied by the launcher, read by -- the mod out of its own folder (src/mods/ModImports.lua). requiredImports = require("src.mods.ModImports").parse(raw), + required_assets = requiredAssets, + optional_assets = optionalAssets, -- Cartridges that must be IMPORTED for this mod to mean anything -- a -- different thing from requiredImports above, which is a file the player -- hands over and the launcher writes into the mod's folder. diff --git a/src/mods/StreamMD5.lua b/src/mods/StreamMD5.lua new file mode 100644 index 000000000..cba8f13e6 --- /dev/null +++ b/src/mods/StreamMD5.lua @@ -0,0 +1,117 @@ +local ok, native = pcall(require, "bit") +local bitlib = ok and native or rawget(_G, "bit32") +if not bitlib then + -- Keep the module parseable on Lua 5.1 while supporting the native integer + -- operators of plain Lua 5.3/5.4 test hosts as well. + local make = loadstring or load + local factory = make([[return { + band=function(a,...) for i=1,select('#',...) do a=a & select(i,...) end return a & 0xffffffff end, + bor=function(a,...) for i=1,select('#',...) do a=a | select(i,...) end return a & 0xffffffff end, + bxor=function(a,...) for i=1,select('#',...) do a=a ~ select(i,...) end return a & 0xffffffff end, + bnot=function(a)return (~a) & 0xffffffff end, + lshift=function(a,n)return (a << n) & 0xffffffff end, + rshift=function(a,n)return (a & 0xffffffff) >> n end, + rol=function(a,n)return ((a << n) | ((a & 0xffffffff) >> (32-n))) & 0xffffffff end + }]]) + if factory then bitlib=factory() end +end +if not bitlib then error("StreamMD5 requires bit or bit32") end + +local band, bor, bxor, bnot = bitlib.band, bitlib.bor, bitlib.bxor, bitlib.bnot +local lshift, rshift = bitlib.lshift, bitlib.rshift +local rol = bitlib.rol or bitlib.lrotate + +local K = { + 0xd76aa478,0xe8c7b756,0x242070db,0xc1bdceee,0xf57c0faf,0x4787c62a,0xa8304613,0xfd469501, + 0x698098d8,0x8b44f7af,0xffff5bb1,0x895cd7be,0x6b901122,0xfd987193,0xa679438e,0x49b40821, + 0xf61e2562,0xc040b340,0x265e5a51,0xe9b6c7aa,0xd62f105d,0x02441453,0xd8a1e681,0xe7d3fbc8, + 0x21e1cde6,0xc33707d6,0xf4d50d87,0x455a14ed,0xa9e3e905,0xfcefa3f8,0x676f02d9,0x8d2a4c8a, + 0xfffa3942,0x8771f681,0x6d9d6122,0xfde5380c,0xa4beea44,0x4bdecfa9,0xf6bb4b60,0xbebfbc70, + 0x289b7ec6,0xeaa127fa,0xd4ef3085,0x04881d05,0xd9d4d039,0xe6db99e5,0x1fa27cf8,0xc4ac5665, + 0xf4292244,0x432aff97,0xab9423a7,0xfc93a039,0x655b59c3,0x8f0ccc92,0xffeff47d,0x85845dd1, + 0x6fa87e4f,0xfe2ce6e0,0xa3014314,0x4e0811a1,0xf7537e82,0xbd3af235,0x2ad7d2bb,0xeb86d391, +} +local S = { + 7,12,17,22, 7,12,17,22, 7,12,17,22, 7,12,17,22, + 5,9,14,20, 5,9,14,20, 5,9,14,20, 5,9,14,20, + 4,11,16,23, 4,11,16,23, 4,11,16,23, 4,11,16,23, + 6,10,15,21, 6,10,15,21, 6,10,15,21, 6,10,15,21, +} + +local function add32(a,b,c,d) + local n = (a or 0) + (b or 0) + (c or 0) + (d or 0) + return band(n, 0xffffffff) +end + +local function le32_from(s, i) + local b1,b2,b3,b4 = s:byte(i, i+3) + return bor(b1, lshift(b2,8), lshift(b3,16), lshift(b4,24)) +end + +local function le32_bytes(x) + return string.char( + band(x,0xff), band(rshift(x,8),0xff), + band(rshift(x,16),0xff), band(rshift(x,24),0xff)) +end + +local M = {} +local StreamMD5 = {} +StreamMD5.__index = StreamMD5 + +function StreamMD5.new() + return setmetatable({ + a=0x67452301, b=0xefcdab89, c=0x98badcfe, d=0x10325476, + bytes=0, buffer="", done=false, + }, StreamMD5) +end + +function StreamMD5:_block(block) + for j=1,16 do M[j] = le32_from(block, (j-1)*4+1) end + local a,b,c,d = self.a,self.b,self.c,self.d + for i=0,63 do + local f,g + if i < 16 then + f = bor(band(b,c), band(bnot(b),d)); g=i + elseif i < 32 then + f = bor(band(d,b), band(bnot(d),c)); g=(5*i+1)%16 + elseif i < 48 then + f = bxor(b,c,d); g=(3*i+5)%16 + else + f = bxor(c, bor(b,bnot(d))); g=(7*i)%16 + end + local tmp=d + d=c + c=b + b=add32(b, rol(add32(a,f,K[i+1],M[g+1]), S[i+1])) + a=tmp + end + self.a=add32(self.a,a); self.b=add32(self.b,b) + self.c=add32(self.c,c); self.d=add32(self.d,d) +end + +function StreamMD5:update(data) + assert(not self.done, "StreamMD5 context already finalized") + assert(type(data)=="string", "StreamMD5:update expects a string") + self.bytes = self.bytes + #data + local s = self.buffer .. data + local full = #s - (#s % 64) + for i=1,full,64 do self:_block(s:sub(i,i+63)) end + self.buffer = s:sub(full+1) + return self +end + +function StreamMD5:final() + assert(not self.done, "StreamMD5 context already finalized") + local originalBytes = self.bytes + local padLen = (56 - ((originalBytes + 1) % 64)) % 64 + local bits = originalBytes * 8 + local lo = bits % 4294967296 + local hi = math.floor(bits / 4294967296) % 4294967296 + self:update("\128" .. string.rep("\0", padLen) .. le32_bytes(lo) .. le32_bytes(hi)) + assert(#self.buffer == 0, "MD5 finalization left a partial block") + self.done=true + local raw = le32_bytes(self.a)..le32_bytes(self.b)..le32_bytes(self.c)..le32_bytes(self.d) + return (raw:gsub(".", function(ch) return string.format("%02x", ch:byte()) end)) +end + +return StreamMD5 diff --git a/src/net/DownloadPublish.lua b/src/net/DownloadPublish.lua new file mode 100644 index 000000000..d8c708bea --- /dev/null +++ b/src/net/DownloadPublish.lua @@ -0,0 +1,25 @@ +-- Atomic replacement shared by download workers and validated import copies. +-- The caller owns its staging file and supplies the scoped destination. +return function(source, target) + if love.system.getOS() ~= "Windows" then return os.rename(source, target) end + -- Windows rename cannot overwrite an existing file. Never delete the old + -- destination first: MoveFileExW replaces it in one filesystem operation. + local ffi = require("ffi") + ffi.cdef[[ + int MultiByteToWideChar(unsigned int, unsigned long, const char *, int, unsigned short *, int); + int MoveFileExW(const unsigned short *, const unsigned short *, unsigned long); + unsigned long GetLastError(void); + ]] + local function wide(s) + local n = ffi.C.MultiByteToWideChar(65001, 8, s, #s, nil, 0) + if n == 0 then error("invalid UTF-8 download path") end + local out = ffi.new("unsigned short[?]", n + 1) + if ffi.C.MultiByteToWideChar(65001, 8, s, #s, out, n) ~= n then error("download path conversion failed") end + return out + end + if ffi.C.MoveFileExW(wide(source), wide(target), 1) == 0 then + return nil, "download publish failed (Windows " .. tonumber(ffi.C.GetLastError()) .. ")" + end + return true +end + diff --git a/tests/asset_pack_runtime/conf.lua b/tests/asset_pack_runtime/conf.lua new file mode 100644 index 000000000..f656c9241 --- /dev/null +++ b/tests/asset_pack_runtime/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + t.identity=os.getenv('PACK_QA_IDENTITY') or 'oasc-asset-pack-runtime-qa' + t.window=false + t.modules.graphics=false + t.modules.audio=false + t.modules.sound=false +end diff --git a/tests/asset_pack_runtime/main.lua b/tests/asset_pack_runtime/main.lua new file mode 100644 index 000000000..cabd3538d --- /dev/null +++ b/tests/asset_pack_runtime/main.lua @@ -0,0 +1,47 @@ +local root=assert(os.getenv('PACK_ENGINE_ROOT'),'PACK_ENGINE_ROOT is required') +package.path=root..'/?.lua;'..root..'/?/init.lua;'..package.path +function love.load() + local ok,err=pcall(function() + local I=require('src.import.Importers') + local P=require('src.mods.AssetPacks') + local W=require('src.import.LuaWriter') + local fs=I.fs() + local source='imports/asset_packs/lttp/sprites' + assert(fs.createDirectory(source)) + local image=love.image.newImageData(1,1) + image:setPixel(0,0,0.25,0.5,0.75,1) + local encoded=image:encode('png'); local bytes=encoded:getString() + image:release(); encoded:release() + assert(fs.write(source..'/pixel.png',bytes)) + local metadata=W.encode({directions={'south','north'},durations={2,3}}) + assert(fs.write(source..'/pixel.lua',metadata)) + assert(fs.write(source..'/pack.lua',W.encode({format=1,importer='lttp',pack='sprites',kind='sprite', + version='1.0.0',source={name='Generated QA pixel; no cartridge content',md5=string.rep('0',32)}, + entries={pixel={file='pixel.png',size=#bytes,width=1,height=1, + metadata={file='pixel.lua',size=#metadata}}}}))) + local co=I.install('lttp','sprites',fs,source,fs) + repeat local resumed,why=coroutine.resume(co); assert(resumed,why) until coroutine.status(co)=='dead' + local manifest={required_assets={{importer='lttp',pack='sprites'}}} + assert(not P.missing(manifest,fs)) + local api=P.new(manifest,fs) + local installed=assert(api:read('lttp','sprites','pixel')) + assert(installed==bytes) + local fileData=love.filesystem.newFileData(installed,'pixel.png') + local decoded=love.image.newImageData(fileData) + assert(decoded:getWidth()==1 and decoded:getHeight()==1) + decoded:release(); fileData:release() + assert(api:metadata('lttp','sprites','pixel').directions[2]=='north') + assert(fs.read(source..'/pixel.png')==bytes) + local path=I.manifestPath('lttp','sprites'); local previous=fs.read(path) + local failing=setmetatable({rename=function()return nil,'injected native publication failure' end},{__index=fs}) + local replacement=I.install('lttp','sprites',fs,source,failing) + local failed=false + repeat local resumed=coroutine.resume(replacement);if not resumed then failed=true;break end + until coroutine.status(replacement)=='dead' + assert(failed and fs.read(path)==previous) + assert(api:read('lttp','sprites','pixel')==bytes) + print('Native pack filesystem, PNG decode, metadata and failed replacement: PASS') + end) + if not ok then io.stderr:write(tostring(err)..'\n'); love.event.quit(1) + else love.event.quit(0) end +end diff --git a/tests/engine/asset_pack_contracts.lua b/tests/engine/asset_pack_contracts.lua new file mode 100644 index 000000000..972cf5925 --- /dev/null +++ b/tests/engine/asset_pack_contracts.lua @@ -0,0 +1,251 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Data=require('src.import.PackData') +local I=require('src.import.Importers') +local Packs=require('src.mods.AssetPacks') +local Manifest=require('src.mods.Manifest') +local Writer=require('src.import.LuaWriter') +local Json=require('src.link.Json') +local Versions=require('src.core.GameVersion') +local Manager=require('src.mods.ManagerState') +local CacheFs=require('src.import.CacheFs') +local Launcher=require('src.mods.LauncherMods') +local Inbox=require('src.import.PackInbox') +local savedVersion=Versions.get(); Versions.set('red') + +local function memory(initial) + local files,times={},{}; local clock=0 + local fs={reads=0,maxRead=0} + for k,v in pairs(initial or {}) do files[k]=v; times[k]=0 end + function fs.getInfo(path,kind) + if files[path] then return {type='file',size=#files[path],modtime=times[path]} end + if kind=='file' then return nil end + for key in pairs(files) do if key:sub(1,#path+1)==path..'/' then return {type='directory'} end end + end + function fs.read(path,n) + fs.reads=fs.reads+1 + return files[path] and files[path]:sub(1,n or -1) + end + function fs.write(path,body) + if fs.failWrite then return nil,'write failed' end + clock=clock+1; files[path]=fs.shortWrite and body:sub(1,1) or body; times[path]=clock; return true + end + function fs.remove(path) files[path]=nil; times[path]=nil; return true end + function fs.rename(from,to) + if fs.throwRename then error('rename exception') end + if fs.failRename then return nil,'rename failed' end + if fs.failManifestRename and to:match('/pack%.lua$') then return nil,'manifest replacement failed' end + if not files[from] then return nil,'no staging file' end + local ok=fs.write(to,files[from]); if ok then fs.remove(from) end; return ok + end + function fs.createDirectory() return not fs.failDirectory end + function fs.load(path) return (loadstring or load)(files[path] or 'error("missing source")',path) end + function fs.getDirectoryItems(path) + local out,seen={},{}; for key in pairs(files) do + if key:sub(1,#path+1)==path..'/' then local part=key:sub(#path+2):match('^[^/]+') + if part and not seen[part] then out[#out+1]=part; seen[part]=true end end + end; table.sort(out); return out + end + function fs.newFile(path) + return {open=function()return files[path]~=nil end,close=function()return true end, + read=function(_,n) fs.maxRead=math.max(fs.maxRead,n); return fs.read(path,n) end} + end + return fs,files +end +local function raw(extra,id) + local m={id=id or 'consumer',name='Pack consumer',version='1.0.0',api=2,entry='main.lua',games={'all'}} + for k,v in pairs(extra or {}) do m[k]=v end; return m +end +local requirement={importer='lttp',pack='sprites',version='>=1.0.0'} +local function pack(version,bytes) + return {format=1,importer='lttp',pack='sprites',kind='sprite',version=version or '1.2.0', + source={name='Synthetic cartridge',md5=string.rep('a',32),size=1048576}, + entries={hero={file='hero.png',size=#(bytes or 'PNG!'),width=16,height=24, + sprite={anchorX=8,anchorY=24,durations={2,3}},metadata={file='hero.lua',size=0}}, + alias={file='hero.png',size=#(bytes or 'PNG!')}}} +end +local meta=Writer.encode({animations={{name='walk',frames={1,2}}},directions={'south','north'}}) +local function input(version,bytes) + local m=pack(version,bytes); m.entries.hero.metadata.size=#meta + return memory({['inbox/pack.lua']=Writer.encode(m),['inbox/hero.png']=bytes or 'PNG!', ['inbox/hero.lua']=meta}) +end +local function complete(co) + local value + repeat local ok,result=coroutine.resume(co); if not ok then return nil,result end; value=result + until coroutine.status(co)=='dead' + return value +end + +T.same(Data.decode(Writer.encode({array={1,'a\0b',true,false},number=-1.5e4})), + {array={1,'a\0b',true,false},number=-1.5e4},'portable literals round trip without executing a chunk') +for _,bad in ipairs({'return os.execute("x")','return (function() while true do end end)()', + 'return {x=1,x=2}','return {a=1}; error("executed")','return {a=1/0}', + 'return {a=1e999}','return {[{}]=true}','return {a=function() end}', + 'return '..string.rep('{',66)..string.rep('}',66)}) do + T.eq(Data.decode(bad),nil,'executable or unbounded pack data is refused') +end +local oldLimit=Data.MAX_NODES; Data.MAX_NODES=3 +T.eq(Data.decode('return {1,2,3,4}'),nil,'literal node budget is enforced'); Data.MAX_NODES=oldLimit +for _,desc in ipairs(I.all()) do T.check(I.validateDescriptor(desc),'known portable descriptor validates') end +local desc=I.get('lttp'); desc.packs[1].kind='bad' +T.eq(I.pack('lttp','sprites').kind,'sprite','catalogue callers cannot mutate pack identity') +for _,bad in ipairs({'../escape','/absolute','C:drive','nul\0path','back\\slash'}) do + T.eq(I.assetPath('lttp','sprites',bad),nil,'asset paths remain within their pack') + T.eq(I.packRoot(bad,'sprites'),nil,'importer identities cannot escape storage') +end +for _,bad in ipairs({{required_assets={[2]=requirement}},{required_assets={stray=requirement}}, + {required_assets={{importer='LTTP',pack='sprites'}}}, + {required_assets={{importer='lttp',pack='sprites',version='broken'}}}, + {required_assets={requirement},optional_assets={requirement}}}) do + T.raises(function()Manifest.validate(raw(bad))end,nil,'invalid or ambiguous requirements are rejected') +end +local m=Manifest.validate(raw({required_assets={requirement},optional_assets={{importer='lttp',pack='music'}}})) +T.check(m.required_assets[1].required,'required declarations survive normalization') +T.eq(m.optional_assets[1].required,false,'optional declarations stay optional') + +local source,original=input(); local fs,files=memory() +local co=I.install('lttp','sprites',source,'inbox',fs) +T.eq(I.state('lttp',fs),'missing','unpublished extraction is unavailable') +local ok=coroutine.resume(co); T.check(ok,'producer starts and yields progress') +T.eq(I.resolve(requirement,fs),nil,'partly copied entries do not publish a manifest') +local result,err=complete(co); T.check(result~=nil,'complete producer publishes: '..tostring(err)) +T.eq(result.entries,2,'aliases retain their entry identities') +T.eq(I.state('lttp',fs),'partial','one installed graphics pack does not claim all packs ready') +T.eq(#original['inbox/hero.png'],4,'installation preserves original source') +local api=Packs.new(m,fs) +T.eq(api:read('lttp','sprites','hero'),'PNG!','native consumer reads installed data') +T.eq(api:info('lttp','sprites').version,'1.2.0','consumer sees actual pack version') +T.eq(#api:entries('lttp','sprites'),2,'consumer can enumerate aliases') +T.same(api:metadata('lttp','sprites','hero').directions,{'south','north'},'animation metadata supports literal arrays') +local exposed=api:entry('lttp','sprites','hero'); exposed.sprite.durations[1]=999 +T.eq(api:entry('lttp','sprites','hero').sprite.durations[1],2,'consumer receives detached nested metadata') +T.eq(api:read('lttp','tiles','hero'),nil,'undeclared pack is inaccessible') +T.eq(api:read('lttp','sprites','absent'),nil,'unknown entry is unavailable') +T.eq(api:info('lttp','music'),nil,'missing optional pack is unavailable without blocking') +T.eq(Packs.missing(m,fs),nil,'complete declared pack satisfies requirement') +T.check(not I.resolve({importer='lttp',pack='sprites',version='>=2.0.0'},fs),'pack version ranges are enforced') +local manifestPath=I.manifestPath('lttp','sprites') +local prior=files[manifestPath] +for _,fault in ipairs({'failWrite','shortWrite','failRename','throwRename','failDirectory','failManifestRename'}) do + local replacement=input('2.0.0','DIFFERENT') + fs[fault]=true + T.eq(complete(I.install('lttp','sprites',replacement,'inbox',fs)),nil,'producer rejects '..fault) + fs[fault]=false + T.eq(files[manifestPath],prior,'failed publication preserves old descriptor') + T.eq(api:read('lttp','sprites','hero'),'PNG!','failed replacement preserves old asset') + local pending=0; for path in pairs(files) do if path:find('.pending-',1,true) then pending=pending+1 end end + T.eq(pending,0,'failed transaction cleans its staging files') +end +local cancelled=I.install('lttp','sprites',input('3.0.0','CANCELLED'),'inbox',fs) +T.check(coroutine.resume(cancelled),'replacement can yield before completion') +T.eq(files[manifestPath],prior,'abandoning an unfinished coroutine preserves previous descriptor') +T.eq(api:read('lttp','sprites','hero'),'PNG!','abandoning a producer preserves old assets') +local missingSource=input('2.0.0'); missingSource.remove('inbox/hero.png') +T.eq(complete(I.install('lttp','sprites',missingSource,'inbox',fs)),nil,'missing producer input cannot publish') +local badSource,badFiles=input('2.0.0'); badFiles['inbox/hero.lua']='return os.execute("bad")' +local badManifest=assert(Data.decode(badFiles['inbox/pack.lua'])) +badManifest.entries.hero.metadata.size=#badFiles['inbox/hero.lua'] +badFiles['inbox/pack.lua']=Writer.encode(badManifest) +T.eq(complete(I.install('lttp','sprites',badSource,'inbox',fs)),nil,'invalid animation metadata cannot publish') +T.eq(files[manifestPath],prior,'invalid extraction preserves installed pack') +result=complete(I.install('lttp','sprites',input('2.0.0','NEW!'),'inbox',fs)) +T.check(result~=nil,'successful update publishes new descriptor') +T.eq(api:info('lttp','sprites').version,'2.0.0','already constructed consumer observes update') +T.eq(api:read('lttp','sprites','hero'),'NEW!','already constructed consumer reads replacement') +local hero=api:entry('lttp','sprites','hero') +local heroPath=I.assetPath('lttp','sprites',hero.file) +files[heroPath]=string.rep('x',Data.MAX_BYTES+1) +local reads=fs.reads +T.eq(api:read('lttp','sprites','hero'),nil,'oversized on-disk entry is refused') +T.eq(fs.reads,reads,'oversized entry is rejected before allocating it') +T.check(Packs.missing(m,fs)~=nil,'incomplete pack is not ready') +files[heroPath]='NEW!' +T.check(fs.maxRead<=Data.MAX_BYTES,'physical reads stay bounded') +T.eq(api:info('lttp','sprites').version,'2.0.0','reader cache is populated before a scoped update') +local baseTime=fs.getInfo(manifestPath).modtime +local getInfo=fs.getInfo +fs.getInfo=function(path,kind)local info=getInfo(path,kind);if path==manifestPath and info then info.modtime=baseTime end;return info end +local proxy=setmetatable({}, {__index=fs}) +T.check(complete(I.install('lttp','sprites',input('2.1.0','MORE'),'inbox',proxy))~=nil,'scoped filesystem provider publishes') +T.eq(api:info('lttp','sprites').version,'2.1.0','scoped publication invalidates readers even with unchanged coarse timestamp') +fs.getInfo=getInfo +local backing='first'; fs.getRealDirectory=function()return backing end +api:info('lttp','sprites') +files[manifestPath]=files[manifestPath]:gsub('2%.1%.0','2.2.0'); backing='second' +T.eq(api:info('lttp','sprites').version,'2.2.0','changed data mount invalidates equal-size and equal-time descriptor') + +local paletteScript='return os.execute("bad")' +local aliasSource=memory({['inbox/pack.lua']=Writer.encode({format=1,importer='lttp',pack='palettes',kind='palette', + version='1.0.0',source={md5=string.rep('a',32)},entries={color={file='palette.lua',size=#paletteScript, + metadata={file='palette.lua',size=#paletteScript}}}}),['inbox/palette.lua']=paletteScript}) +local aliasResult,aliasError=complete(I.install('lttp','palettes',aliasSource,'inbox',fs)) +T.eq(aliasResult,nil,'metadata alias of a previously copied data file is still parsed') +T.check(aliasError:find('expected a literal',1,true)~=nil,'metadata alias failure comes from the literal decoder') + +local inboxFs,inboxFiles=input('3.0.0','INBOX') +local root=Inbox.ROOT..'/lttp/sprites' +local inboxSeed={} +for path,bytes in pairs(inboxFiles) do inboxSeed[root..path:sub(#'inbox'+1)]=bytes end +-- A second, invalid pack must not prevent the independent valid pack landing. +inboxSeed[Inbox.ROOT..'/lttp/tiles/pack.lua']='return os.execute("bad")' +local batchFs,batchFiles=memory(inboxSeed) +local batch=complete(Inbox.start(batchFs)) +T.eq(batch.found,2,'portable inbox discovers known pack descriptors') +T.eq(#batch.installed,1,'valid pack installs independently') +T.eq(#batch.failed,1,'invalid pack is reported separately') +T.eq(Packs.new(m,batchFs):read('lttp','sprites','hero'),'INBOX','inbox provider feeds actual consumer') +T.eq(batchFiles[root..'/hero.png'],'INBOX','console/mobile inbox source is preserved') +T.eq(complete(Inbox.start(memory())).found,0,'empty inbox is a normal result') + +local RomImporter=require('src.import.RomImporter') +local oldDataFs=CacheFs.dataFs; CacheFs.dataFs=function()return batchFs end +for _,platform in ipairs({'ios','android','switch','desktop'}) do + local refresh=0 + local view=setmetatable({workState='idle',ios=platform=='ios',android=platform=='android', + _refreshMods=function()refresh=refresh+1 end},RomImporter) + view:installAssetPacks() + local first=view._assetPackInstallJob; view:installAssetPacks() + T.eq(view._assetPackInstallJob,first,'duplicate launcher presses keep one pack job on '..platform) + while view._assetPackInstallJob do view:_stepAssetPacks() end + T.eq(refresh,1,'launcher refreshes readiness after install on '..platform) + T.check(not view.modNotice.ok,'launcher reports partial failure on '..platform) +end +CacheFs.dataFs=oldDataFs + +local function boot(includePack) + local definitions={consumer={required_assets={requirement}},dependent={dependencies={'consumer'}}, + optional={optional_assets={{importer='lttp',pack='music'}}},independent={}} + local data,paths={},{} + for id,extra in pairs(definitions) do + local path='mods/'..id; paths[#paths+1]=path + data[path..'/manifest.json']=Json.encode(raw(extra,id)) + data[path..'/main.lua']='local mod=...;mod.exports.executed=true;mod.exports.packs=mod.packs:list()' + end + if includePack then for path,body in pairs(files) do data[path]=body end end + local f=memory(data) + return T.sdk.loadMods(paths,{fs=f,dev=false}),f +end +local run,waitingFs=boot() +T.eq(run.mods.consumer.state,'missing_assets','required pack gets actionable load state') +T.eq((run.loader.apis or {}).consumer,nil,'pack gate runs before API creation') +T.eq(run.loader.exports.dependent,nil,'required pack gate propagates to dependent') +T.check(run.loader.exports.optional.executed,'missing optional pack allows mod entry') +T.check(run.loader.exports.independent.executed,'unrelated mods still execute') +local manager=Manager.new({mods=run.loader,save={options={}}}) +T.check(not manager.restartPending,'still missing pack does not stage restart') +T.check(complete(I.install('lttp','sprites',input(),'inbox',waitingFs))~=nil,'pack can be installed after blocked boot') +manager:refresh(); T.check(manager.restartPending,'satisfied blocked pack stages restart') +run.release() +run=boot(true) +T.check(run.loader.exports.consumer.executed,'consumer recovers on fresh boot') +T.check(run.loader.exports.dependent.executed,'dependent recovers on fresh boot') +T.eq(run.loader.exports.consumer.packs[1].installed,true,'real mod entry receives native packs API') +run.release() +local oldFs=CacheFs.dataFs; CacheFs.dataFs=function()return waitingFs end +local rows=Launcher.deriveList({m},{mods={}},'red') +T.eq(rows[1].status,'ok','launcher recognizes installed required pack') +waitingFs.remove(manifestPath) +rows=Launcher.deriveList({m},{mods={}},'red') +T.eq(rows[1].status,'warn','launcher surfaces missing asset requirements') +CacheFs.dataFs=oldFs; Versions.set(savedVersion) +T.finish('Native asset pack contracts') From 5eb85338dc94e1c6dd1a30f0d633df7b31dfd566 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Sun, 25 Oct 2026 20:14:30 +0100 Subject: [PATCH 045/138] Add scoped native asset producers and SNES graphics extraction --- .github/workflows/mod-asset-packs.yml | 6 + docs/mod-asset-packs.md | 45 +++++++- src/import/CacheFs.lua | 1 + src/import/Importers.lua | 38 +++---- src/import/PackInbox.lua | 46 +++++++- src/import/PackProducer.lua | 118 +++++++++++++++++++ src/import/PackPublish.lua | 27 +++++ src/import/RomImporter.lua | 4 +- src/import/lttp/LttpImport.lua | 138 +++++++++++++++++++++++ src/import/lttp/Lz2.lua | 2 + src/import/lttp/Overworld.lua | 80 +++++++++++++ src/import/lttp/SnesGfx.lua | 2 + src/import/snes/Graphics.lua | 84 ++++++++++++++ src/import/snes/Lz2.lua | 51 +++++++++ tests/engine/native_pack_producers.lua | 150 +++++++++++++++++++++++++ tests/native_pack_runtime/conf.lua | 6 + tests/native_pack_runtime/main.lua | 101 +++++++++++++++++ tests/pack_sources/fixture.lua | 62 ++++++++++ 18 files changed, 927 insertions(+), 34 deletions(-) create mode 100644 src/import/PackProducer.lua create mode 100644 src/import/PackPublish.lua create mode 100644 src/import/lttp/LttpImport.lua create mode 100644 src/import/lttp/Lz2.lua create mode 100644 src/import/lttp/Overworld.lua create mode 100644 src/import/lttp/SnesGfx.lua create mode 100644 src/import/snes/Graphics.lua create mode 100644 src/import/snes/Lz2.lua create mode 100644 tests/engine/native_pack_producers.lua create mode 100644 tests/native_pack_runtime/conf.lua create mode 100644 tests/native_pack_runtime/main.lua create mode 100644 tests/pack_sources/fixture.lua diff --git a/.github/workflows/mod-asset-packs.yml b/.github/workflows/mod-asset-packs.yml index c017f1074..af40962ff 100644 --- a/.github/workflows/mod-asset-packs.yml +++ b/.github/workflows/mod-asset-packs.yml @@ -6,6 +6,8 @@ on: - 'src/import/Importers.lua' - 'src/import/CacheFs.lua' - 'src/import/LuaWriter.lua' + - 'src/import/lttp/**' + - 'src/import/snes/**' - 'src/import/RomImporter.lua' - 'src/mods/AssetPacks.lua' - 'src/mods/StreamMD5.lua' @@ -14,7 +16,10 @@ on: - 'src/mods/LauncherMods.lua' - 'src/mods/ManagerState.lua' - 'tests/engine/asset_pack_contracts.lua' + - 'tests/engine/native_pack_producers.lua' + - 'tests/pack_sources/**' - 'tests/asset_pack_runtime/**' + - 'tests/native_pack_runtime/**' - '.github/workflows/mod-asset-packs.yml' workflow_dispatch: permissions: @@ -27,5 +32,6 @@ jobs: - uses: actions/checkout@v7 - run: sudo apt-get update && sudo apt-get install -y luajit lua5.4 - run: luajit tests/engine/asset_pack_contracts.lua + - run: luajit tests/engine/native_pack_producers.lua - name: Check the portable data parser on plain Lua run: lua5.4 -e 'local d=require("src.import.PackData");local w=require("src.import.LuaWriter");assert(d.decode(w.encode({1,true,"x"}))[3]=="x");assert(not d.decode("return os.exit()"))' diff --git a/docs/mod-asset-packs.md b/docs/mod-asset-packs.md index 65f9d6ed0..40a6c5412 100644 --- a/docs/mod-asset-packs.md +++ b/docs/mod-asset-packs.md @@ -24,7 +24,7 @@ descriptor. Undeclared packs cannot be read through this API. Copy a portable pack directory, including its `pack.lua` and referenced files, to `imports/asset_packs///` in the active game-data folder. -Select **Install asset packs from inbox** in the launcher's Mods panel. This +Select **Import asset packs from inbox** in the launcher's Mods panel. This directory flow works with phone file sharing, Android storage and Switch MTP, without requiring a desktop file picker or a shell command. It preserves the original inbox files and reports independent failures within a batch. @@ -55,12 +55,39 @@ retained; the installer never deletes user source directories or guesses which old assets are safe to remove. Concurrent installations into one destination are not offered by the launcher. +## Native SNES graphics source + +The same inbox action also checks `imports/asset_sources/lttp.sfc` (or +`lttp.smc`) in the active game-data folder. The native extractor recognizes the +1 MiB US revision with MD5 `608c22b8ff930c62dc2de54bcd6eba72`; a 512-byte copier +header is accepted and excluded from source identity. Unknown revisions fail +before extraction. No source cartridge is included or downloaded. + +The extractor writes version `1.1.0` of three packs: 116 tile entries, 109 +sprite entries, and six palette entries. These include 223 graphics sheets, +the 4bpp player sheet, and a 1024×1024 atlas of 4096 overworld metatiles with +map palettes and quadrant flips. Sheet defaults retain palette identities and +transparent index zero in entry attributes. This does not provide audio packs. + +Extraction yields progress between sheets and atlas batches. Each pack uses +the same bounded immutable-object store and atomic descriptor publication as +the portable installer. Errors preserve the previous descriptor for the failed +pack. A changed destination root aborts publication. Complete matching packs +are reused. Raw sources run before portable inbox packs, so an explicitly +provided portable pack wins; independent source errors do not block those +portable installs. Inputs are preserved. + +`src.import.PackProducer` also provides a shared engine-side producer with +`put`, `alias`, `finish`, `cancel` and `state`. It validates and detaches input +data, scopes the destination, stages and verifies writes, and publishes only a +complete nonempty pack. This is not an additional mod write permission. + ## Scope and remaining work -This implements the portable pack producer/consumer lifecycle for **existing -extracted packs**. Native cartridge-to-pack extraction for the three catalogue -families, background extraction workers, image/animation parity and physical -device gameplay remain open. A working pack contract does not establish that +This implements the portable pack producer/consumer lifecycle and an initial +native SNES graphics producer. Native extraction for the other two catalogue +families, background extraction workers, real-cartridge image/animation parity +and physical device gameplay remain open. A working contract does not establish that an entire mod initializes or is playable. New engine changes also need a fresh test release before they are present in distributed phone/console packages. @@ -69,3 +96,11 @@ loading, dependent recovery, required/optional semantics, detached metadata, bounded reads, atomic failure recovery, inbox batching and launcher job paths. The platform flags in this ROM-free test exercise the shared control flow; they are not physical-device launch or gameplay evidence. + +`luajit tests/engine/native_pack_producers.lua` additionally checks decompression, +bitplane decoding, palettes, metatile flips, bounded producer writes, cancellation, +scope changes and failure preservation. `tests/native_pack_runtime` exercises the +complete native extractor through actual LÖVE filesystem and PNG APIs, including +reuse and inbox overrides, under a fresh owned QA identity. Its inputs are +generated format fixtures. No real source cartridge was available for pixel +comparison; the resulting pack counts are not a visual-parity claim. diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index f6dd0ee8b..b5f2ccf31 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -858,6 +858,7 @@ function CacheFs.dataFs() -- reads and enumeration: every home at once getInfo = mounted(function(...) return love.filesystem.getInfo(...) end), getRealDirectory = mounted(function(...) return love.filesystem.getRealDirectory(...) end), + getWriteRoot = function() return CacheFs.rootReport().path end, getDirectoryItems = mounted(function(...) return love.filesystem.getDirectoryItems(...) end), read = mounted(function(...) return love.filesystem.read(...) end), load = mounted(function(...) return love.filesystem.load(...) end), diff --git a/src/import/Importers.lua b/src/import/Importers.lua index 29066f3d2..72ec0f1b6 100644 --- a/src/import/Importers.lua +++ b/src/import/Importers.lua @@ -21,7 +21,7 @@ local catalog = { {id="pmd_red",name="Red Rescue Team",status="beta", source={name="Red Rescue Team cartridge dump",formats={"gba"},sizes={33554432}}, packs={{id="sprites",name="Sprite sheets",kind="sprite"}}}, - {id="lttp",name="A Link to the Past",status="beta", + {id="lttp",name="A Link to the Past",status="beta",module="src.import.lttp.LttpImport", source={name="A Link to the Past cartridge dump",formats={"sfc","smc"},sizes={1048576,2097152}}, packs={{id="sprites",name="Sprites",kind="sprite"},{id="tiles",name="Tiles",kind="tileset"}, {id="palettes",name="Palettes",kind="palette"}, @@ -81,6 +81,16 @@ function Importers.fs(fs) if fs and (not love or fs~=love.filesystem) then return fs end return require("src.import.CacheFs").dataFs() end +-- Long-running pack operations yield to the launcher. A folder switch must +-- stop that operation instead of splitting its objects between two roots. +function Importers.pinScope(fs) + local getter=fs and fs.getWriteRoot + local captured=getter and getter() + return function() + if getter and getter()~=captured then return nil,"game-data folder changed during the pack operation" end + return true + end +end function Importers.validatePack(m,id,pack) if type(m)~="table" or m.format~=Importers.PACK_FORMAT or not validId(m.importer) or not validId(m.pack) then return false,"invalid pack manifest identity or format" @@ -183,33 +193,15 @@ function Importers.readMetadata(id,pack,entry,fs) local bytes; bytes,err=Data.read(Importers.fs(fs),path,meta.size); if not bytes then return nil,err end return Data.decode(bytes) end -local serial=0 -local function mkdir(fs,path) - local dir=path:match("^(.*)/[^/]+$") - if dir and fs.createDirectory then return fs.createDirectory(dir) end - return true -end -local function publish(fs,path,bytes) - if not (fs and fs.write and fs.rename and fs.remove) then return nil,"atomic pack publication is unavailable" end - serial=serial+1 - local stage=path..".pending-"..serial - local called,ok,err=pcall(function() - local made,why=mkdir(fs,stage); if not made then return nil,why end - local wrote; wrote,why=fs.write(stage,bytes); if not wrote then return nil,why end - local staged; staged,why=Data.read(fs,stage,#bytes) - if staged~=bytes then return nil,why or "pack staging write did not match" end - return fs.rename(stage,path) - end) - if not called then err,ok=tostring(ok),nil end - if not ok then pcall(fs.remove,stage); return nil,err or "pack publication failed" end - return true -end +local publish=require("src.import.PackPublish") -- Install from an existing extracted pack, one bounded file per resume. The -- source remains untouched. Immutable objects are written before the atomic -- descriptor switch, so cancellation never changes the previous pack. function Importers.install(id,pack,sourceFs,sourceRoot,destinationFs) destinationFs=Importers.fs(destinationFs) + local sourceScope,destinationScope=Importers.pinScope(sourceFs),Importers.pinScope(destinationFs) return coroutine.create(function() + assert(sourceScope());assert(destinationScope()) assert(Importers.pack(id,pack),"unknown asset pack") assert(pathInside(sourceRoot)==sourceRoot,"source root must be a relative directory") local text,err=Data.read(sourceFs,sourceRoot.."/"..Importers.PACK_FILE); assert(text,err) @@ -219,6 +211,7 @@ function Importers.install(id,pack,sourceFs,sourceRoot,destinationFs) local keys={}; for key in pairs(m.entries) do keys[#keys+1]=key end; table.sort(keys) local copied={} local function copy(rec,metadata) + assert(sourceScope());assert(destinationScope()) local rel=pathInside(rec.file) local old=copied[rel] if old and (not metadata or old.metadata) then rec.file=old.file; return end @@ -238,6 +231,7 @@ function Importers.install(id,pack,sourceFs,sourceRoot,destinationFs) coroutine.yield({done=i,total=#keys,status="Installing "..key}) end local encoded=Writer.encode(m); assert(#encoded<=Data.MAX_BYTES,"pack manifest exceeds the read limit") + assert(sourceScope());assert(destinationScope()) ok,err=publish(destinationFs,assert(Importers.manifestPath(id,pack)),encoded); assert(ok,err) Importers.invalidate(destinationFs,id,pack) return {importer=id,pack=pack,version=m.version,entries=#keys} diff --git a/src/import/PackInbox.lua b/src/import/PackInbox.lua index 9ef63f271..da4c2fd3e 100644 --- a/src/import/PackInbox.lua +++ b/src/import/PackInbox.lua @@ -1,10 +1,23 @@ -- Files/iTunes sharing, Android storage and console MTP all reach this same -- relative inbox. Never move or delete the user's source pack directories. local Importers=require("src.import.Importers") -local Inbox={ROOT="imports/asset_packs"} +local Inbox={ROOT="imports/asset_packs",SOURCES="imports/asset_sources"} function Inbox.start(fs) fs=Importers.fs(fs) local queue={} + -- Source cartridges run first. Explicit extracted packs in the inbox may + -- then override individual packs, preserving their established precedence. + for _,desc in ipairs(Importers.all()) do + if desc.module then + for _,extension in ipairs(desc.source.formats or {}) do + local path=Inbox.SOURCES.."/"..desc.id.."."..extension + if fs.getInfo(path,"file") then + queue[#queue+1]={importer=desc.id,pack="graphics",source=path,module=desc.module} + break + end + end + end + end for _,desc in ipairs(Importers.all()) do for _,pack in ipairs(desc.packs) do local root=Inbox.ROOT.."/"..desc.id.."/"..pack.id @@ -14,15 +27,38 @@ function Inbox.start(fs) end end return coroutine.create(function() - local installed,failed={},{} + local installed,failed,positions={},{},{} + local function record(result) + local key=result.importer.."/"..result.pack + local at=positions[key] or #installed+1 + positions[key],installed[at]=at,result + end for _,item in ipairs(queue) do - local co=Importers.install(item.importer,item.pack,fs,item.root,fs) - while true do + local co + local prepared,prepareError=pcall(function() + if item.source then + local data,err=require("src.import.PackData").read(fs,item.source) + assert(data,err) + co=require(item.module).job(data,fs) + else co=Importers.install(item.importer,item.pack,fs,item.root,fs) end + end) + if not prepared then + failed[#failed+1]={importer=item.importer,pack=item.pack,error=tostring(prepareError)} + end + while co do local ok,value=coroutine.resume(co) if not ok then failed[#failed+1]={importer=item.importer,pack=item.pack,error=tostring(value)}; break end - if coroutine.status(co)=="dead" then installed[#installed+1]=value; break end + if coroutine.status(co)=="dead" then + if item.source then + for _,desc in ipairs(Importers.get(item.importer).packs) do + local count=value.packs and value.packs[desc.id] + if count then record({importer=item.importer,pack=desc.id,entries=count,reused=value.reused}) end + end + else record(value) end + break + end value.importer,value.pack=item.importer,item.pack coroutine.yield(value) end diff --git a/src/import/PackProducer.lua b/src/import/PackProducer.lua new file mode 100644 index 000000000..20434be58 --- /dev/null +++ b/src/import/PackProducer.lua @@ -0,0 +1,118 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native extractors publish immutable objects and switch the descriptor only +-- once their complete result is ready. Cancellation never replaces a pack. +local Importers=require("src.import.Importers") +local Data=require("src.import.PackData") +local Writer=require("src.import.LuaWriter") +local MD5=require("src.mods.StreamMD5") +local Producer={} +local publish=require("src.import.PackPublish") + +local function literal(value) + local ok,bytes=pcall(Writer.encode,value) + if not ok then return nil,tostring(bytes) end + if #bytes>Data.MAX_BYTES then return nil,"pack data exceeds the read limit" end + local decoded,err=Data.decode(bytes) + if not decoded then return nil,err end + return decoded,bytes +end + +function Producer.new(id,pack,version,source,fs) + local descriptor=Importers.pack(id,pack) + if not descriptor then return nil,"unknown asset pack" end + local normalized,err=literal(source) + if not normalized then return nil,err end + local manifest={format=Importers.PACK_FORMAT,importer=id,pack=pack, + kind=descriptor.kind,version=version,source=normalized,entries={}} + local valid; valid,err=Importers.validatePack(manifest,id,pack) + if not valid then return nil,err end + fs=Importers.fs(fs) + if not (fs and fs.write and fs.rename and fs.remove) then return nil,"atomic pack publication is unavailable" end + local state,count="open",0 + local scope=Importers.pinScope(fs) + local root=assert(Importers.packRoot(id,pack)) + local api={} + local function object(bytes,extension) + if type(bytes)~="string" or #bytes>Importers.MAX_ENTRY_BYTES then return nil,"asset exceeds the pack read limit" end + local file="objects/"..MD5.new():update(bytes):final().."."..extension + local path=root.."/"..file + if Data.read(fs,path,#bytes)~=bytes then + local ok,why=publish(fs,path,bytes);if not ok then return nil,why end + end + return {file=file,size=#bytes} + end + local function refuse(reason) + state="failed";return nil,reason + end + function api:put(key,bytes,attributes,metadata,extension) + if state~="open" then return nil,"pack producer is "..state end + local inScope,scopeError=scope();if not inScope then return refuse(scopeError) end + if count>=20000 then return refuse("pack entry count limit") end + if type(key)~="string" or not key:match("^[a-z0-9_%-]+[a-z0-9_/%-]*$") or #key>512 then + return refuse("invalid native pack entry identity") + end + if manifest.entries[key] then return refuse("duplicate native pack entry") end + local attrs,why=literal(attributes or {}) + if not attrs then return refuse(why) end + extension=extension or (manifest.kind=="palette" and "lua" or "png") + if not (Importers.KINDS[manifest.kind].ext[extension]) then return refuse("asset extension does not match pack kind") end + attrs.file,attrs.size="entry."..extension,type(bytes)=="string" and #bytes or -1 + local metadataBytes + if metadata~=nil then + local decoded;decoded,metadataBytes=literal(metadata) + if not decoded then return refuse(metadataBytes) end + attrs.metadata={file="metadata.lua",size=#metadataBytes} + else attrs.metadata=nil end + local probe={format=manifest.format,importer=id,pack=pack,kind=manifest.kind, + version=version,source=manifest.source,entries={[key]=attrs}} + local checked;checked,why=Importers.validatePack(probe,id,pack) + if not checked then return refuse(why) end + local stored;stored,why=object(bytes,extension) + if not stored then return refuse(why) end + attrs.file,attrs.size=stored.file,stored.size + if metadataBytes then + attrs.metadata,why=object(metadataBytes,"lua") + if not attrs.metadata then return refuse(why) end + end + manifest.entries[key]=attrs;count=count+1 + return Data.copy(attrs) + end + function api:alias(key,existing) + if state~="open" then return nil,"pack producer is "..state end + local inScope,scopeError=scope();if not inScope then return refuse(scopeError) end + if count>=20000 then return refuse("pack entry count limit") end + if manifest.entries[key] or not manifest.entries[existing] then return refuse("invalid native pack alias") end + if type(key)~="string" or not key:match("^[a-z0-9_%-]+[a-z0-9_/%-]*$") or #key>512 then + return refuse("invalid native pack entry identity") + end + manifest.entries[key]=Data.copy(manifest.entries[existing]);count=count+1 + return Data.copy(manifest.entries[key]) + end + function api:finish() + if state~="open" then return nil,"pack producer is "..state end + local inScope,scopeError=scope();if not inScope then return refuse(scopeError) end + if count==0 then return refuse("native extraction produced no entries") end + local valid,why=Importers.validatePack(manifest,id,pack) + if not valid then return refuse(why) end + -- Recheck files at the publication boundary; an interrupted or externally + -- removed object must not turn a required pack into a ready one. + for _,entry in pairs(manifest.entries) do + for _,rec in ipairs({entry,entry.metadata or false}) do + if rec then + local info=fs.getInfo and fs.getInfo(root.."/"..rec.file,"file") + if not info or info.type~="file" or info.size~=rec.size then return refuse("native pack object is missing or changed") end + end + end + end + local normalized,encoded=literal(manifest) + if not normalized then return refuse(encoded) end + local ok;ok,why=publish(fs,assert(Importers.manifestPath(id,pack)),encoded) + if not ok then return refuse(why) end + state="published";Importers.invalidate(fs,id,pack) + return {importer=id,pack=pack,version=version,entries=count} + end + function api:cancel() if state=="open" then state="cancelled" end end + function api:state() return state end + return api +end +return Producer diff --git a/src/import/PackPublish.lua b/src/import/PackPublish.lua new file mode 100644 index 000000000..c50727e04 --- /dev/null +++ b/src/import/PackPublish.lua @@ -0,0 +1,27 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- A private staging name per Lua state plus a monotonic call sequence. Native +-- extractors and inbox installs share this publication path, including workers. +local Data=require("src.import.PackData") +local MD5=require("src.mods.StreamMD5") +local clock=love and love.timer and love.timer.getTime or os.clock +local owner={sequence=0} -- retained by the publisher; no gameplay RNG draws +local identity=table.concat({tostring(owner),tostring(os.time()),string.format("%.9f",clock())},":") +local nonce=MD5.new():update(identity):final() +return function(fs,path,bytes) + if not (fs and fs.write and fs.rename and fs.remove) then return nil,"atomic pack publication is unavailable" end + owner.sequence=owner.sequence+1 + local pending=path..".pending-"..nonce.."-"..owner.sequence + local called,ok,err=pcall(function() + local dir=path:match("^(.*)/[^/]+$") + if dir and fs.createDirectory then + local made,why=fs.createDirectory(dir);if not made then return nil,why end + end + local wrote,why=fs.write(pending,bytes);if not wrote then return nil,why end + local staged;staged,why=Data.read(fs,pending,#bytes) + if staged~=bytes then return nil,why or "pack staging write did not match" end + return fs.rename(pending,path) + end) + if not called then err,ok=tostring(ok),nil end + if not ok then pcall(fs.remove,pending);return nil,err or "pack publication failed" end + return true +end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index a563b22a2..edafdff47 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -9887,7 +9887,7 @@ function RomImporter:_stepAssetPacks() elseif coroutine.status(co)=="dead" then self._assetPackInstallJob=nil if value.found==0 then - self.modNotice={ok=false,text=Strings("Copy extracted packs into %s/imports/asset_packs///, then install again.", + self.modNotice={ok=false,text=Strings("Copy extracted packs into %s/imports/asset_packs///, or supported cartridges into imports/asset_sources/.", require("src.import.CacheFs").rootReport().path or love.filesystem.getSaveDirectory())} elseif #value.failed>0 then local first=value.failed[1] @@ -10423,7 +10423,7 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged) local top = y + headerH + 14 * s self.modAssetPacksRect = self:_glassyButton(x, top, w, btnH, - self._assetPackInstallJob and Strings("Installing asset packs...") or Strings("Install asset packs from inbox"), + self._assetPackInstallJob and Strings("Installing asset packs...") or Strings("Import asset packs from inbox"), self.saveBtnFont, not self._assetPackInstallJob and self.workState~="working") top = top + btnH + 8 * s diff --git a/src/import/lttp/LttpImport.lua b/src/import/lttp/LttpImport.lua new file mode 100644 index 000000000..57a709c1c --- /dev/null +++ b/src/import/lttp/LttpImport.lua @@ -0,0 +1,138 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native SNES graphics extraction. Only explicitly recognized cartridge +-- revisions are accepted; no copyrighted cartridge bytes are shipped. +local G=require("src.import.snes.Graphics") +local Lz=require("src.import.snes.Lz2") +local MD5=require("src.mods.StreamMD5") +local Producer=require("src.import.PackProducer") +local Importers=require("src.import.Importers") +local Writer=require("src.import.LuaWriter") +local T={IMPORTER="lttp",EXPORT_VERSION="1.1.0",SHEET_COUNT=223,SPRITE_FIRST=115, + RAW_FIRST=115,RAW_LAST=126,RAW_SIZE=1536,SHEET_TILES_WIDE=16, + COLORS_3BPP=7,COLORS_4BPP=15} +T.SOURCES={["608c22b8ff930c62dc2de54bcd6eba72"]={name="The Legend of Zelda: A Link to the Past (USA)",size=1048576}} +T.POINTERS={bank=0x4f80,high=0x505f,low=0x513e} +T.PLAYER={offset=0x80000,bytes=0x7000,bits=4,tiles=896} +T.PALETTE_SETS={ + {id="sprites_light",offset=0xdd218,rows=4,perRow=15}, + {id="sprites_dark",offset=0xdd290,rows=4,perRow=15}, + {id="armor",offset=0xdd308,rows=5,perRow=15}, + {id="sprites_aux3",offset=0xdd39e,rows=12,perRow=7}, + {id="bg_dungeon",offset=0xdd734,rows=16,perRow=15}, + {id="bg_overworld_aux3",offset=0xde604,rows=12,perRow=7}, +} +T.DEFAULT_PALETTE={sprites={set="sprites_light",row=1},tiles={set="bg_dungeon",row=1}} +function T.identify(bytes) + if type(bytes)~="string" or (#bytes~=1048576 and #bytes~=1049088) then return nil,"expected a supported 1 MiB SNES cartridge, with optional 512-byte copier header" end + if #bytes==1049088 then bytes=bytes:sub(513) end + local hash=MD5.new():update(bytes):final() + local known=T.SOURCES[hash] + if not known or known.size~=#bytes then return nil,"unsupported SNES cartridge revision ("..hash..")" end + return {name=known.name,md5=hash,size=#bytes},bytes +end +function T.sheetOffset(rom,index) + if type(index)~="number" or index<0 or index>=T.SHEET_COUNT or index%1~=0 then return nil end + local p=T.POINTERS + return G.loRomOffset(rom:byte(p.bank+index+1),rom:byte(p.high+index+1),rom:byte(p.low+index+1)) +end +function T.isRaw(index)return index>=T.RAW_FIRST and index<=T.RAW_LAST end +function T.readSheet(rom,index) + local offset=T.sheetOffset(rom,index) + if not offset or offset>=#rom then return nil,"graphics pointer is outside the cartridge" end + local bytes,err + if T.isRaw(index) then + bytes=rom:sub(offset+1,offset+T.RAW_SIZE) + if #bytes~=T.RAW_SIZE then return nil,"raw graphics block is truncated" end + else bytes,err=Lz.decompress(rom,offset) end + if not bytes then return nil,err end + return G.decodeSheet(bytes) +end +function T.readPlayer(rom) + local p=T.PLAYER + local bytes=rom:sub(p.offset+1,p.offset+p.bytes) + if #bytes~=p.bytes then return nil,"player graphics are truncated" end + return G.decodeTiles(bytes,p.bits) +end +function T.readPalettes(rom) + local all={} + for _,spec in ipairs(T.PALETTE_SETS) do + local colors,err=G.readPalette(rom,spec.offset,spec.rows*spec.perRow) + if not colors then return nil,err end + local rows={} + for row=1,spec.rows do + rows[row]={};for color=1,spec.perRow do rows[row][color]=colors[(row-1)*spec.perRow+color] end + end + all[spec.id]=rows + end + return all +end +local function png(raster) + assert(love and love.image,"native image encoding is unavailable") + local image=love.image.newImageData(raster.width,raster.height,"rgba8",raster.rgba) + local encoded + local ok,result=pcall(function()encoded=image:encode("png");return encoded:getString()end) + if encoded then encoded:release() end + image:release() + if not ok then error(result,0) end + return result +end +local function alreadyInstalled(source,fs) + local expected={tiles=116,sprites=109,palettes=6} + for pack,count in pairs(expected) do + local m=Importers.check({importer=T.IMPORTER,pack=pack,version="="..T.EXPORT_VERSION},fs) + if not m or m.source.md5:lower()~=source.md5 then return false end + local n=0;for _ in pairs(m.entries) do n=n+1 end + if n~=count then return false end + if pack=="tiles" then + if not m.entries.kakariko then return false end + for index=0,T.SPRITE_FIRST-1 do if not m.entries[("sheet_%03d"):format(index)] then return false end end + elseif pack=="sprites" then + if not m.entries.player then return false end + for index=T.SPRITE_FIRST,T.SHEET_COUNT-1 do if not m.entries[("sheet_%03d"):format(index)] then return false end end + else for _,spec in ipairs(T.PALETTE_SETS) do if not m.entries[spec.id] then return false end end end + end + return true +end +function T.job(rom,fs,options) + options=options or {} + return coroutine.create(function() + local source,normalized=T.identify(rom);assert(source,normalized);rom=normalized + fs=Importers.fs(fs) + if not options.force and alreadyInstalled(source,fs) then + return {source=source,packs={tiles=116,sprites=109,palettes=6},skipped={},reused=true} + end + local palettes=assert(T.readPalettes(rom)) + local writers={} + for _,pack in ipairs({"tiles","sprites","palettes"}) do writers[pack]=assert(Producer.new(T.IMPORTER,pack,T.EXPORT_VERSION,source,fs)) end + for index=0,T.SHEET_COUNT-1 do + local tiles,bits=T.readSheet(rom,index);assert(tiles,bits) + local pack=index=3 and slot<=6 then + local override=byte(rom,0x5d97+auxiliary*4+slot-3) + if override~=0 then index=override end + end + out[slot+1]=index + end + return out +end +function O.palettes(rom,area) + local group=byte(rom,0x7d1c+area) + local selectors={byte(rom,0x75504+group*4),byte(rom,0x75505+group*4),byte(rom,0x75506+group*4)} + local result={} + local function install(row,column,address) + local colors=assert(G.readPalette(rom,address,7)) + result[row]=result[row] or {} + for i=1,7 do result[row][column+i-1]=colors[i] end + end + local main=area<64 and 0 or 1 + local mainOffset=word(rom,0xdec3b+main*2) + for row=2,6 do install(row,1,0xde6c8+mainOffset+(row-2)*14) end + local first=word(rom,0xdec13+selectors[1]*2) + local second=word(rom,0xdec13+selectors[2]*2) + for row=2,4 do install(row,9,0xde86c+first+(row-2)*14) end + for row=5,7 do install(row,9,0xde86c+second+(row-5)*14) end + install(7,1,0xde604+byte(rom,0xdebc6+selectors[3])) + return result +end +function O.raster(rom,readSheet,progress) + local area=0x18 + local sheets={} + for slot,index in ipairs(O.sheets(rom,area)) do sheets[slot]=assert(readSheet(rom,index)) end + local palettes=O.palettes(rom,area) + local rgb={G.color(0x2669)} + local background=string.char(math.floor(rgb[1]+0.5),math.floor(rgb[2]+0.5),math.floor(rgb[3]+0.5),255) + local packed={} + for row,colors in pairs(palettes) do + packed[row]={} + for index,c in pairs(colors) do packed[row][index]=string.char(c[1],c[2],c[3],255) end + end + local high={[0]=true,[3]=true,[4]=true,[5]=true} + local pixels={} + for definition=0,4095 do + local tx,ty=definition%64*16,math.floor(definition/64)*16 + for quadrant=0,3 do + local descriptor=word(rom,0x78000+definition*8+quadrant*2) + local tileIndex=descriptor%1024 + local slot=math.floor(tileIndex/64) + local tile=sheets[slot+1] and sheets[slot+1][tileIndex%64+1] + local palette=packed[math.floor(descriptor/1024)%8] + local flipX,flipY=math.floor(descriptor/16384)%2==1,descriptor>=32768 + for y=0,7 do + for x=0,7 do + local sx,sy=flipX and 7-x or x,flipY and 7-y or y + local index=tile and tile[sy*8+sx+1] or 0 + local color=index~=0 and palette and palette[index+(high[slot] and 8 or 0)] or nil + local dx,dy=tx+quadrant%2*8+x,ty+math.floor(quadrant/2)*8+y + pixels[dy*1024+dx+1]=color or background + end + end + end + if progress and definition%128==127 then progress(definition+1,4096) end + end + return {width=1024,height=1024,rgba=table.concat(pixels)} +end +function O.image(rom,readSheet) + local result=O.raster(rom,readSheet) + return love.image.newImageData(result.width,result.height,"rgba8",result.rgba) +end +return O diff --git a/src/import/lttp/SnesGfx.lua b/src/import/lttp/SnesGfx.lua new file mode 100644 index 000000000..b7d73b52e --- /dev/null +++ b/src/import/lttp/SnesGfx.lua @@ -0,0 +1,2 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +return require("src.import.snes.Graphics") diff --git a/src/import/snes/Graphics.lua b/src/import/snes/Graphics.lua new file mode 100644 index 000000000..82a192439 --- /dev/null +++ b/src/import/snes/Graphics.lua @@ -0,0 +1,84 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local G={TILE_PIXELS=8} +local floor=math.floor +function G.loRomOffset(bank,high,low) + if type(bank)~="number" or type(high)~="number" or type(low)~="number" then return nil end + if bank<0 or bank>255 or high<128 or high>255 or low<0 or low>255 + or bank%1~=0 or high%1~=0 or low%1~=0 then return nil end + return bank%128*32768+(high*256+low)%32768 +end +function G.color(word) + return word%32*255/31,floor(word/32)%32*255/31,floor(word/1024)%32*255/31 +end +function G.readPalette(bytes,offset,count) + if type(bytes)~="string" or type(offset)~="number" or offset<0 or offset%1~=0 + or type(count)~="number" or count<0 or count%1~=0 or offset+count*2>#bytes then return nil,"palette is outside the cartridge" end + local colors={} + for i=0,count-1 do + local a,b=bytes:byte(offset+i*2+1,offset+i*2+2) + local r,g,blue=G.color(a+b*256) + colors[i+1]={floor(r+0.5),floor(g+0.5),floor(blue+0.5)} + end + return colors +end +local function tile(bytes,index,bits) + local stride=bits*8 + if type(bytes)~="string" or type(index)~="number" or index<0 or index%1~=0 + or (index+1)*stride>#bytes then return nil,"tile is outside the graphics block" end + local pixels={} + for y=0,7 do + for x=0,7 do + local color=0 + for plane=0,bits-1 do + local position + if bits==3 and plane==2 then position=16+y + else position=floor(plane/2)*16+y*2+plane%2 end + local byte=bytes:byte(index*stride+position+1) + color=color+floor(byte/2^(7-x))%2*2^plane + end + pixels[y*8+x+1]=color + end + end + return pixels +end +function G.decodeTile2bpp(bytes,index)return tile(bytes,index,2)end +function G.decodeTile3bpp(bytes,index)return tile(bytes,index,3)end +function G.decodeTile4bpp(bytes,index)return tile(bytes,index,4)end +function G.decodeTiles(bytes,bits) + if bits~=2 and bits~=3 and bits~=4 then return nil,"unsupported bit-plane format" end + if type(bytes)~="string" or #bytes==0 or #bytes%(bits*8)~=0 then return nil,"incomplete graphics tiles" end + local out={} + for i=0,#bytes/(bits*8)-1 do out[i+1]=assert(tile(bytes,i,bits)) end + return out +end +function G.sheetFormat(size) + if type(size)~="number" or size<=0 or size>65536 then return nil end + if size%24==0 then return 3,size/24 end + if size%16==0 then return 2,size/16 end +end +function G.decodeSheet(bytes) + local bits=type(bytes)=="string" and G.sheetFormat(#bytes) + if not bits then return nil,"unsupported graphics sheet length" end + local pixels,err=G.decodeTiles(bytes,bits) + if not pixels then return nil,err end + return pixels,bits +end +function G.sheet(tiles,palette,columns) + columns=columns or 16 + local width,height=columns*8,math.ceil(#tiles/columns)*8 + assert(width>0 and height>0 and width*height<=4*1024*1024,"graphics sheet exceeds image bounds") + local colors={[0]=string.char(0,0,0,0)} + for i,rgb in ipairs(palette) do colors[i]=string.char(rgb[1],rgb[2],rgb[3],255) end + local rows={} + for y=0,height-1 do + local pixels={} + for x=0,width-1 do + local source=tiles[floor(y/8)*columns+floor(x/8)+1] + local index=source and source[y%8*8+x%8+1] or 0 + pixels[x+1]=assert(colors[index],"graphics palette is incomplete") + end + rows[y+1]=table.concat(pixels) + end + return {width=width,height=height,rgba=table.concat(rows)} +end +return G diff --git a/src/import/snes/Lz2.lua b/src/import/snes/Lz2.lua new file mode 100644 index 000000000..f78eccca1 --- /dev/null +++ b/src/import/snes/Lz2.lua @@ -0,0 +1,51 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- SNES graphics command stream: literal, byte/word fill, ascending fill and +-- absolute output copies. Graphics copy offsets are little endian. +local Lz={MAX_OUTPUT=65536} +local unpack=table.unpack or unpack +function Lz.decompress(bytes,offset,limit) + if type(bytes)~="string" or type(offset)~="number" or offset<0 or offset%1~=0 then return nil,"invalid compressed graphics source" end + limit=limit or Lz.MAX_OUTPUT + if type(limit)~="number" or limit<0 or limit>Lz.MAX_OUTPUT or limit%1~=0 then return nil,"invalid graphics expansion limit" end + local p=offset+1 + local output={} + local function nextByte() + local value=bytes:byte(p);p=p+1 + if not value then error("truncated compressed graphics",0) end + return value + end + local ok,body=pcall(function() + while true do + local head=nextByte() + if head==255 then break end + local command,length + if head>=224 then + command=math.floor(head/4)%8 + length=(head%4)*256+nextByte()+1 + else command=math.floor(head/32);length=head%32+1 end + if #output+length>limit then error("graphics expansion exceeds its limit",0) end + if command==0 then + for _=1,length do output[#output+1]=nextByte() end + elseif command==1 then + local byte=nextByte();for _=1,length do output[#output+1]=byte end + elseif command==2 then + local a,b=nextByte(),nextByte() + for i=1,length do output[#output+1]=i%2==1 and a or b end + elseif command==3 then + local first=nextByte();for i=0,length-1 do output[#output+1]=(first+i)%256 end + else + local low=nextByte();local start=low+256*nextByte() + if start>=#output then error("graphics copy points outside prior output",0) end + for i=1,length do output[#output+1]=assert(output[start+i],"invalid graphics copy") end + end + end + local chunks={} + for first=1,#output,4096 do + chunks[#chunks+1]=string.char(unpack(output,first,math.min(first+4095,#output))) + end + return table.concat(chunks) + end) + if not ok then return nil,tostring(body) end + return body,p-offset-1 +end +return Lz diff --git a/tests/engine/native_pack_producers.lua b/tests/engine/native_pack_producers.lua new file mode 100644 index 000000000..9f3e2e4ec --- /dev/null +++ b/tests/engine/native_pack_producers.lua @@ -0,0 +1,150 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local F=require('tests.pack_sources.fixture') +local Lz=require('src.import.snes.Lz2') +local G=require('src.import.snes.Graphics') +local Producer=require('src.import.PackProducer') +local I=require('src.import.Importers') +local Packs=require('src.mods.AssetPacks') +local MD5=require('src.mods.StreamMD5') +local LT=require('src.import.lttp.LttpImport') +local b=string.char + +for _,v in ipairs({{b(2)..'abc'..b(255),'abc'}, {b(0x22,7,255),b(7,7,7)}, + {b(0x44,1,2,255),b(1,2,1,2,1)},{b(0x63,254,255),b(254,255,0,1)}, + {b(1)..'ab'..b(0x83,0,0,255),'ababab'}, + {b(0xe1,0)..string.rep('A',256)..'B'..b(0x80,0,1,255),string.rep('A',256)..'BB'}}) do + local out,consumed=Lz.decompress(v[1],0) + T.eq(out,v[2],'command stream matches explicit byte vector') + T.eq(consumed,#v[1],'decoder reports exact consumed source length') +end +for _,head in ipairs({0x80,0xa0,0xc0}) do + T.eq(Lz.decompress(b(0)..'Z'..b(head,0,0,255),0),'ZZ','copy opcode aliases preserve output addressing') +end +T.eq(Lz.decompress(b(0)..'Z'..b(0xfc,0,0,0,255),0),'ZZ','extended copy opcode is decoded') +for _,bad in ipairs({b(0xe0),b(4)..'x',b(0x40,1),b(0x80,0),b(0x80,0,0,255),b(0x20,1)}) do + T.eq(Lz.decompress(bad,0),nil,'truncated or invalid stream is rejected') +end +T.eq(Lz.decompress(b(0xe7,255,1,255),0,10),nil,'expansion budget is checked before output growth') +T.eq(Lz.decompress(b(255),0,math.huge),nil,'caller cannot remove expansion ceiling') +T.eq(Lz.decompress(b(255),-1),nil,'negative source offset is rejected') +T.eq(#assert(Lz.decompress(string.rep(b(0xe7,255,42),64)..b(255),0)),65536,'maximum allowed expansion succeeds') +T.eq(Lz.decompress(string.rep(b(0xe7,255,42),65)..b(255),0),nil,'expansion beyond maximum is rejected') +T.eq(G.loRomOffset(0x92,0xa0,0),0x92000,'LoROM mirrors high banks') +T.eq(G.loRomOffset(0x12,0x20,0),nil,'LoROM lower bank addresses are not cartridge data') +for _,bits in ipairs({2,3,4}) do + local bytes={};for i=1,bits*8 do bytes[i]=0 end + for plane=0,bits-1 do + local at=bits==3 and plane==2 and 17 or math.floor(plane/2)*16+plane%2+1 + bytes[at]=128 + end + local unpack=table.unpack or unpack + local tiles=assert(G.decodeTiles(b(unpack(bytes)),bits)) + T.eq(tiles[1][1],2^bits-1,'all bit planes contribute to the top-left pixel') + T.eq(tiles[1][2],0,'adjacent clear pixel stays transparent') + T.eq(tiles[1][9],0,'row stride remains independent of bit depth') +end +T.eq(G.decodeTiles('x',3),nil,'incomplete tile cannot be decoded') +T.eq(G.decodeTiles(string.rep('\0',32),8),nil,'unsupported bit depth cannot be guessed') +T.same(G.readPalette(b(31,0,0,124),0,2),{{255,0,0},{0,0,255}},'SNES palette primaries are correctly expanded') +T.eq(G.readPalette('x',0,1),nil,'truncated palette is rejected') +local synthetic=F.cartridge() +local digest=MD5.new():update(synthetic):final() +T.eq(LT.identify(synthetic),nil,'generated format bytes are not accepted as an original cartridge') +LT.SOURCES[digest]={name='Generated format fixture, not a cartridge',size=#synthetic} +local source,normalized=LT.identify(string.rep('H',512)..synthetic) +T.eq(normalized,synthetic,'copier header is removed without changing normalized bytes') +T.eq(source.md5,digest,'source hash identifies normalized bytes') +T.eq(LT.sheetOffset(synthetic,0),0x92000,'compressed sheet uses native source pointer') +T.eq(LT.sheetOffset(synthetic,115),0x93000,'raw sprite sheet uses its own source pointer') +T.eq(LT.sheetOffset(synthetic,223),nil,'out-of-table sheet is rejected') +local sheet,bits=LT.readSheet(synthetic,0) +T.eq(bits,3,'compressed graphics retain their three bit planes') +T.eq(#sheet,64,'sheet geometry derives from decoded data length') +T.eq(sheet[1][1],1,'compressed sheet decoded pixel matches format vector') +T.eq(assert(LT.readSheet(synthetic,115))[1][1],1,'raw sprite sheet bypasses decompression') +local player=assert(LT.readPlayer(synthetic)) +T.eq(#player,896,'player has its separate native tile count') +T.eq(player[1][1],8,'player uses its fourth bit plane') +local palettes=assert(LT.readPalettes(synthetic)) +T.same(palettes.armor[1][8],{0,0,255},'player selects the armor palette row') +local raster=G.sheet(player,palettes.armor[1]) +T.same({raster.width,raster.height},{128,448},'player sheet size is computed from native tile units') +T.eq(raster.rgba:sub(1,4),b(0,0,255,255),'player raster preserves the fourth-plane color') +T.eq(raster.rgba:sub(5,8),b(0,0,0,0),'palette index zero remains transparent') +local world=require('src.import.lttp.Overworld').raster(synthetic,LT.readSheet) +local function pixel(x,y)return world.rgba:sub((y*world.width+x)*4+1,(y*world.width+x+1)*4)end +T.same({world.width,world.height},{1024,1024},'overworld atlas contains 4096 sixteen-pixel metatiles') +for _,xy in ipairs({{0,0},{15,0},{0,15},{15,15}}) do + T.eq(pixel(xy[1],xy[2]),b(255,0,0,255),'metatile quadrant flip preserves the correct palette pixel') +end +T.eq(pixel(1,0),b(74,156,74,255),'transparent metatile pixels reveal the native background color') +LT.SOURCES[digest]=nil + +local fs,files=F.memory() +local provenance={name='Generated test source',md5=string.rep('0',32)} +local producer=assert(Producer.new('lttp','sprites','1.0.0',provenance,fs)) +local entry=assert(producer:put('hero','PNG!',{width=16,height=24,sprite={anchorY=24}},{frames={1,2}})) +T.eq(I.readPack('lttp','sprites',fs),nil,'native extraction stays unpublished until complete') +T.check(producer:alias('hero_female','hero')~=nil,'native provider can reuse identical gender artwork') +entry.sprite.anchorY=1 +T.eq(assert(producer:finish()).entries,2,'finished native extraction publishes its full entry set') +local api=Packs.new({required_assets={{importer='lttp',pack='sprites'}}},fs) +T.eq(api:read('lttp','sprites','hero'),'PNG!','native provider output feeds the real pack consumer') +T.eq(api:entry('lttp','sprites','hero').sprite.anchorY,24,'producer views do not mutate committed anchors') +T.same(api:metadata('lttp','sprites','hero').frames,{1,2},'provider animation metadata is literal data') +local path=I.manifestPath('lttp','sprites');local prior=files[path] +for _,fault in ipairs({'failWrite','shortWrite','failRename','throwRename','failDirectory'}) do + producer=assert(Producer.new('lttp','sprites','2.0.0',provenance,fs));fs[fault]=true + T.eq(producer:put('hero','NEW!',{}),nil,'native provider propagates '..fault) + fs[fault]=false + T.eq(producer:finish(),nil,'failed native provider cannot publish a partial pack') + T.eq(files[path],prior,'native publication failure preserves the previous descriptor') + T.eq(api:read('lttp','sprites','hero'),'PNG!','native publication failure preserves previous assets') +end +producer=assert(Producer.new('lttp','sprites','2.0.0',provenance,fs)) +assert(producer:put('hero','OTHER',{}));producer:cancel() +T.eq(producer:finish(),nil,'cancelled extraction cannot publish') +T.eq(files[path],prior,'cancelled extraction leaves prior descriptor untouched') +producer=assert(Producer.new('lttp','sprites','2.0.0',provenance,fs)) +entry=assert(producer:put('hero','OTHER',{}));fs.remove(I.assetPath('lttp','sprites',entry.file)) +T.eq(producer:finish(),nil,'missing object prevents native publication') +T.eq(files[path],prior,'missing-object failure preserves prior descriptor') +producer=assert(Producer.new('lttp','sprites','2.0.0',provenance,fs)) +T.eq(producer:put(nil,'X',{}),nil,'invalid entry identity cannot reach storage') +T.eq(producer:finish(),nil,'invalid native entry does not publish') +T.eq(Producer.new('unknown','sprites','1.0.0',provenance,fs),nil,'unknown provider identity is rejected') +producer=assert(Producer.new('lttp','sprites','2.0.0',provenance,fs)) +T.eq(producer:put('hero',string.rep('x',8*1024*1024+1),{}),nil,'native extraction cannot exceed consumer read limit') +T.check(fs.maxRead<=8*1024*1024,'all physical native-provider reads stay bounded') +local pending=0;for name in pairs(files) do if name:find('.pending-',1,true) then pending=pending+1 end end +T.eq(pending,0,'native failures leave no staging files') +local publishName='src.import.PackPublish' +local originalPublisher=package.loaded[publishName] +math.randomseed(12345);local expectedRandom=math.random();math.randomseed(12345) +package.loaded[publishName]=nil;local firstPublisher=require(publishName) +T.eq(math.random(),expectedRandom,'publisher initialization does not consume the gameplay random stream') +package.loaded[publishName]=nil;local secondPublisher=require(publishName) +package.loaded[publishName]=originalPublisher +local names={};local concurrentFs=F.memory();local write=concurrentFs.write +concurrentFs.write=function(p,bytes)names[#names+1]=p;return write(p,bytes)end +T.check(firstPublisher(concurrentFs,'packs/pack.lua','FIRST'),'first independent publisher works') +T.check(secondPublisher(concurrentFs,'packs/pack.lua','SECOND'),'second independent publisher works') +T.neq(names[1],names[2],'independent Lua states do not share a numbered staging filename') +local scope='first-folder';fs.getWriteRoot=function()return scope end +producer=assert(Producer.new('lttp','sprites','2.0.0',provenance,fs)) +assert(producer:put('hero','ROOT!',{}));scope='second-folder' +T.eq(producer:finish(),nil,'folder change prevents a native descriptor spanning two roots') +T.eq(files[path],prior,'folder change leaves previous native pack descriptor intact') +scope='first-folder' +local inboxRoot='imports/test';local W=require('src.import.LuaWriter') +fs.write(inboxRoot..'/pack.lua',W.encode({format=1,importer='lttp',pack='sprites',kind='sprite',version='2.0.0', + source=provenance,entries={hero={file='a.png',size=1},other={file='b.png',size=1}}})) +fs.write(inboxRoot..'/a.png','A');fs.write(inboxRoot..'/b.png','B') +local importing=I.install('lttp','sprites',fs,inboxRoot,fs) +T.check(coroutine.resume(importing),'portable inbox can yield after an entry') +scope='second-folder' +T.check(not coroutine.resume(importing),'folder change stops a portable inbox operation too') +T.eq(files[path],prior,'portable folder change preserves previous descriptor') +T.finish('Native pack producers and SNES graphics') diff --git a/tests/native_pack_runtime/conf.lua b/tests/native_pack_runtime/conf.lua new file mode 100644 index 000000000..74500596c --- /dev/null +++ b/tests/native_pack_runtime/conf.lua @@ -0,0 +1,6 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +function love.conf(t) + t.identity=assert(os.getenv('PACK_QA_IDENTITY'),'an isolated PACK_QA_IDENTITY is required') + assert(t.identity:match('^oasc%-') and t.identity:find('%-qa'), 'only an owned QA identity is allowed') + t.window=false;t.modules.graphics=false;t.modules.audio=false;t.modules.sound=false +end diff --git a/tests/native_pack_runtime/main.lua b/tests/native_pack_runtime/main.lua new file mode 100644 index 000000000..f8f44862c --- /dev/null +++ b/tests/native_pack_runtime/main.lua @@ -0,0 +1,101 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local root=assert(os.getenv('PACK_ENGINE_ROOT'),'PACK_ENGINE_ROOT is required') +package.path=root..'/?.lua;'..root..'/?/init.lua;'..package.path +function love.load() + local ok,err=pcall(function() + local F=require('tests.pack_sources.fixture') + local LT=require('src.import.lttp.LttpImport') + local I=require('src.import.Importers') + local Packs=require('src.mods.AssetPacks') + local Inbox=require('src.import.PackInbox') + local MD5=require('src.mods.StreamMD5') + local Data=require('src.import.PackData') + local source=F.cartridge() + local hash=MD5.new():update(source):final() + assert(not LT.identify(source),'format fixture must not identify as a real cartridge') + LT.SOURCES[hash]={name='Generated format fixture, not cartridge evidence',size=#source} + local fs=I.fs() + assert(fs.getWriteRoot()==love.filesystem.getSaveDirectory(),'QA data must stay in its owned identity') + assert(not fs.getInfo(Inbox.SOURCES..'/lttp.sfc'),'use a fresh empty QA identity; never overwrite unrelated source files') + for _,pack in ipairs({'tiles','sprites','palettes'}) do + assert(not fs.getInfo(I.manifestPath('lttp',pack)),'use a fresh empty QA identity for full extraction evidence') + end + assert(fs.createDirectory(Inbox.SOURCES)) + local input=Inbox.SOURCES..'/lttp.sfc' + assert(fs.write(input,source)) + local function drain(co) + local yielded=0 + while true do + local resumed,value=coroutine.resume(co);assert(resumed,value) + if coroutine.status(co)=='dead' then return value,yielded end + yielded=yielded+1;assert(value.total>=value.done and value.done>=0) + end + end + local result,yields=drain(Inbox.start(fs)) + assert(result.found==1 and #result.installed==3 and #result.failed==0) + assert(yields>=231,'extraction must yield progress') + local api=Packs.new({required_assets={{importer='lttp',pack='sprites'}, + {importer='lttp',pack='tiles'},{importer='lttp',pack='palettes'}}},fs) + assert(api:info('lttp','sprites').entries==109) + assert(api:info('lttp','tiles').entries==116) + assert(api:info('lttp','palettes').entries==6) + local function image(pack,id,width,height) + local bytes=assert(api:read('lttp',pack,id)) + local file=love.filesystem.newFileData(bytes,id..'.png') + local decoded=love.image.newImageData(file);file:release() + assert(decoded:getWidth()==width and decoded:getHeight()==height) + return decoded + end + local sheet=image('sprites','sheet_115',128,32) + local r,g,b,a=sheet:getPixel(0,0);assert(r==1 and g==0 and b==0 and a==1);sheet:release() + sheet=image('tiles','sheet_000',128,32) + r,g,b,a=sheet:getPixel(0,0);assert(r==0 and g==1 and b==0 and a==1);sheet:release() + sheet=image('sprites','player',128,448) + r,g,b,a=sheet:getPixel(0,0);assert(r==0 and g==0 and b==1 and a==1) + r,g,b,a=sheet:getPixel(1,0);assert(a==0);sheet:release() + sheet=image('tiles','kakariko',1024,1024) + for _,xy in ipairs({{0,0},{15,0},{0,15},{15,15}}) do + r,g,b,a=sheet:getPixel(xy[1],xy[2]);assert(r==1 and g==0 and b==0 and a==1) + end + sheet:release() + local palettes=assert(Data.decode(assert(api:read('lttp','palettes','armor')))) + assert(#palettes==5 and #palettes[1]==15 and palettes[1][8][3]==255) + assert(fs.read(input)==source,'source file must be preserved') + local before={} + for _,pack in ipairs({'tiles','sprites','palettes'}) do before[pack]=fs.read(I.manifestPath('lttp',pack)) end + local reused,count=drain(LT.job(source,fs)) + assert(reused.reused and count==0,'existing complete packs must skip extraction') + for _,pack in ipairs({'tiles','sprites','palettes'}) do assert(fs.read(I.manifestPath('lttp',pack))==before[pack]) end + local failing=setmetatable({rename=function(from,to) + if to:match('/pack%.lua$') then return nil,'injected descriptor failure' end + return fs.rename(from,to) + end},{__index=fs}) + local failed=LT.job(source,failing,{force=true}) + local failure + while true do + local resumed,value=coroutine.resume(failed) + if not resumed then failure=value;break end + assert(coroutine.status(failed)~='dead','injected descriptor failure must propagate') + end + assert(tostring(failure):find('injected descriptor failure',1,true)) + for _,pack in ipairs({'tiles','sprites','palettes'}) do assert(fs.read(I.manifestPath('lttp',pack))==before[pack]) end + assert(api:read('lttp','sprites','player')) + local Writer=require('src.import.LuaWriter') + local override=Inbox.ROOT..'/lttp/palettes' + local paletteBytes=Writer.encode({{{1,2,3}}}) + assert(fs.createDirectory(override));assert(fs.write(override..'/armor.lua',paletteBytes)) + assert(fs.write(override..'/pack.lua',Writer.encode({format=1,importer='lttp',pack='palettes',kind='palette',version='9.0.0', + source={md5=string.rep('0',32),name='Generated explicit override'},entries={armor={file='armor.lua',size=#paletteBytes}}}))) + result=drain(Inbox.start(fs)) + assert(result.found==2 and #result.installed==3 and #result.failed==0,'source plus explicit override must count distinct packs') + assert(api:info('lttp','palettes').version=='9.0.0' and api:read('lttp','palettes','armor')==paletteBytes) + assert(fs.write(input,'Unsupported generated source')) + result=drain(Inbox.start(fs)) + assert(#result.failed==1 and #result.installed==1,'bad source must not prevent independent explicit pack installation') + assert(api:read('lttp','sprites','player') and fs.read(input)=='Unsupported generated source') + LT.SOURCES[hash]=nil + print('Native SNES extractor: 223 sheets, player, 4096 metatiles, 6 palettes, 3 packs, native PNG round trips, reuse and atomic failure: PASS') + print('Evidence uses generated format data. Real-cartridge pixel parity and device gameplay remain unverified.') + end) + if ok then love.event.quit(0) else io.stderr:write(tostring(err)..'\n');love.event.quit(1) end +end diff --git a/tests/pack_sources/fixture.lua b/tests/pack_sources/fixture.lua new file mode 100644 index 000000000..6c00d5127 --- /dev/null +++ b/tests/pack_sources/fixture.lua @@ -0,0 +1,62 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Generated format vectors; no cartridge bytes or extracted graphics. +local F={} +function F.memory(initial) + local data,times={},{};local clock=0 + for p,b in pairs(initial or {}) do data[p]=b;times[p]=0 end + local fs={maxRead=0,writes=0} + function fs.getInfo(p,kind) + if data[p] then return {type='file',size=#data[p],modtime=times[p]} end + if kind~='file' then for key in pairs(data) do if key:sub(1,#p+1)==p..'/' then return {type='directory'} end end end + end + function fs.read(p,n)return data[p] and data[p]:sub(1,n or -1)end + function fs.readRange(p,at,n)fs.maxRead=math.max(fs.maxRead,n);return data[p] and data[p]:sub(at+1,at+n)end + function fs.write(p,b) + fs.writes=fs.writes+1 + if fs.failWrite then return nil,'injected write failure' end + clock=clock+1;data[p]=fs.shortWrite and b:sub(1,1) or b;times[p]=clock;return true + end + function fs.rename(a,b) + if fs.throwRename then error('injected replacement exception') end + if fs.failRename then return nil,'injected replacement failure' end + if not data[a] then return nil,'missing staging data' end + clock=clock+1;data[b],times[b]=data[a],clock;data[a],times[a]=nil,nil;return true + end + function fs.createDirectory()return not fs.failDirectory end + function fs.remove(p)data[p],times[p]=nil,nil;return true end + return fs,data +end +function F.cartridge() + local patches={} + local function add(at,bytes)patches[#patches+1]={at=at,bytes=bytes}end + local function word(n)return string.char(n%256,math.floor(n/256))end + local banks,highs,lows={},{},{} + for i=0,222 do + local at=i>=115 and i<=126 and 0x93000 or 0x92000 + banks[#banks+1]=string.char(math.floor(at/32768)) + highs[#highs+1]=string.char(128+math.floor(at%32768/256)) + lows[#lows+1]=string.char(at%256) + end + add(0x4f80,table.concat(banks));add(0x505f,table.concat(highs));add(0x513e,table.concat(lows)) + local raw=string.char(128)..string.rep('\0',1535) + add(0x92000,string.char(0xe3,255)..raw:sub(1,1024)..string.char(0xe1,255)..raw:sub(1025)..string.char(255)) + add(0x93000,raw) + add(0x80000,string.rep('\0',17)..string.char(128)..string.rep('\0',0x7000-18)) + for _,spec in ipairs({{0xdd218,60,31},{0xdd290,60,31},{0xdd308,75,31}, + {0xdd39e,84,31},{0xdd734,240,992},{0xde604,84,31}}) do + local colors={} + for i=1,spec[2] do colors[i]=word(spec[1]==0xdd308 and i==8 and 31744 or spec[3]) end + add(spec[1],table.concat(colors)) + end + add(0xde86c,word(31)..string.rep('\0',40)) + add(0x78000,word(0x0800)..word(0x4800)..word(0x8800)..word(0xc800)) + table.sort(patches,function(a,b)return a.at=at,'overlapping generated test vectors') + chunks[#chunks+1]=string.rep('\0',p.at-at);chunks[#chunks+1]=p.bytes;at=p.at+#p.bytes + end + chunks[#chunks+1]=string.rep('\0',1048576-at) + return table.concat(chunks) +end +return F From b5f256874c1f0778995cefc5fb675b1375f47dfd Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Sun, 25 Oct 2026 20:19:51 +0100 Subject: [PATCH 046/138] Document original producer contribution rights and integration credit --- LICENSE-ROXAS2712.md | 88 ++++++++++++++++++++++++++++++++++++++++++++ README.md | 5 +++ 2 files changed, 93 insertions(+) create mode 100644 LICENSE-ROXAS2712.md diff --git a/LICENSE-ROXAS2712.md b/LICENSE-ROXAS2712.md new file mode 100644 index 000000000..8931eb41f --- /dev/null +++ b/LICENSE-ROXAS2712.md @@ -0,0 +1,88 @@ +# Roxas2712 — Fork Contributions + +Version 1.0 · Effective 25 October 2026 + +Copyright © 2026 Roxas2712, to the extent of rights held. +All rights reserved except as expressly granted below or already granted. + +## 1. Scope and ownership + +This notice applies only to original contributions made by Roxas2712 +(GitHub: Roxas2712) to `Roxas2712/Gen2Recomped`, including original additions +within modified files, insofar as he owns the relevant rights. Git history, +pull-request diffs and source provenance identify contributions; a commit +author or a whole-file change alone does not establish ownership of every +part of that file. + +It does not claim ownership of Cedric's work, third-party engine work, +other contributors' work, third-party dependencies, or ROM-derived +content. Their applicable licences and notices remain in force. It does not +grant permission to distribute the complete combined fork where another +rights holder's permission is required. + +## 2. General permission and reserved rights + +For contributions covered by this notice and not already licensed more +broadly, you may read and study the published source and make private, +non-commercial modifications and builds for your own use. This grants no +general permission to redistribute, sublicense, sell, or offer those +contributions as a hosted service. Other uses require Roxas2712's +written permission, except for the specific permission below and rights +available independently under applicable law. + +Keep the relevant copyright, licence and attribution notices. Do not +represent Roxas2712's contributions as exclusively authored by someone +else. Public source availability alone does not put them in the public +domain or grant an open-source licence. + +## 3. Specific permission for Cedric / UNDERdecoded + +Roxas2712 expressly grants Cedric (Ced; GitHub: UNDERdecoded / +UNDERdecodedHD) a non-exclusive, worldwide, perpetual, royalty-free +permission to use, reproduce, integrate, modify and distribute the covered +contributions that Roxas2712 submits or expressly offers through pull +requests to `UNDERdecoded/Gen2Recomped`, for Cedric's Gen2Recomped / +RetroRecompiled project. This includes its renamed continuation maintained +by Cedric, its official builds, and its commercial operation. + +Cedric may license the incorporated contributions to recipients as part of +that project, subject to preserving this permission's applicable credit and +notice requirements. This permission is not a copyright assignment, is not +exclusive, and does not cover unpublished work, separate repositories, +ModCompass service operation, credentials, or content not offered in those +pull requests. Roxas2712 retains his own rights to his contributions. + +For uses relying on this permission: + +- retain the relevant source copyright and licence notices; +- give visible credit in the project's README or equivalent public project + page, and in the application's existing About / Credits view, if present; +- use at least the following credit, or a substantially equivalent one: + + > Contributions by Roxas2712 — + > https://github.com/Roxas2712 + +A maintained consolidated contributor credit is sufficient; a separate +credit on every screen or for every change is not required. Adapted +contributions must not be attributed exclusively to another author. Other +contributors' credits must also remain intact. + +## 4. Earlier licences and permissions remain effective + +This notice does not revoke, narrow, or retrospectively add conditions to +any permission already validly granted, including MIT / GPL grants, +separate agreements, or permissions arising from previously submitted +pull requests. Where an earlier grant permits a use independently of this +notice, its existing terms govern that use. For such earlier uses, the +visible credit above is requested to the extent it is not already required. + +For subsequent contributions, this is the default statement of Roxas2712's own reserved rights and the specific PR permission, subject to any +applicable existing licence obligations or separately agreed terms. It +does not change another rights holder's licence or guarantee compatibility +of a particular combination of code. + +## 5. No warranty + +To the extent permitted by applicable law, these contributions are provided +as is, without warranty. No support obligation, endorsement, or rights to +third-party code, game assets, names, or trademarks are granted. diff --git a/README.md b/README.md index 4cd31e7c0..8711558c1 100644 --- a/README.md +++ b/README.md @@ -605,6 +605,11 @@ platform, renderer, and tooling are theirs; the Generation II import, script VM, world, and battle work are this fork's. See [LICENSE.MD](LICENSE.MD) for the full copyright split. +Contributions by **Roxas2712** — . Original native +asset-producer additions are covered by [the contribution terms](LICENSE-ROXAS2712.md), +including the specific integration permission for Cedric / UNDERdecoded. Earlier +grants and other contributors' rights remain effective. + ## Licence in one paragraph Two parts. **Everything inherited from Gen1Recomp, and every shared engine From c08f801196334aae6b91f0e47ee01e265d5c8ab9 Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 20:36:02 +0100 Subject: [PATCH 047/138] Import audited terrain palettes and refresh native rendering with bounded caches --- docs/native-terrain-palettes.md | 31 +++ src/import/Gen3PalettePrograms.lua | 140 ++++++++++ src/import/RomExtractorGen3.lua | 36 ++- src/render/Gen3PaletteCycles.lua | 139 ++++++++++ src/render/Gen3PaletteFrameCache.lua | 56 ++++ src/render/Gen3PaletteSamples.lua | 127 +++++++++ src/render/Gen3Tiles.lua | 26 ++ src/render/TileRenderer.lua | 120 ++++++++- tests/engine/gen3_palette_frame_cache.lua | 48 ++++ .../engine/gen3_palette_program_failures.lua | 97 +++++++ tests/engine/gen3_palette_program_import.lua | 118 ++++++++ tests/engine/gen3_palette_renderer.lua | 255 ++++++++++++++++++ tests/engine/gen3_palette_samples.lua | 83 ++++++ tests/engine/gen3_static_palette_reuse.lua | 57 ++++ tests/palette_programs/fixture.lua | 75 ++++++ tests/test_gen3_palette_profiles.py | 43 +++ tools/gen3_discover.py | 7 + tools/gen3_palette_profiles.py | 14 + tools/gen3_palette_program_profiles.json | 155 +++++++++++ tools/native-palettes/fixture.lua | 60 +++++ tools/rom_manifest_emerald.json | 115 ++++++++ tools/rom_manifest_firered.json | 38 +++ 22 files changed, 1824 insertions(+), 16 deletions(-) create mode 100644 docs/native-terrain-palettes.md create mode 100644 src/import/Gen3PalettePrograms.lua create mode 100644 src/render/Gen3PaletteCycles.lua create mode 100644 src/render/Gen3PaletteFrameCache.lua create mode 100644 src/render/Gen3PaletteSamples.lua create mode 100644 tests/engine/gen3_palette_frame_cache.lua create mode 100644 tests/engine/gen3_palette_program_failures.lua create mode 100644 tests/engine/gen3_palette_program_import.lua create mode 100644 tests/engine/gen3_palette_renderer.lua create mode 100644 tests/engine/gen3_palette_samples.lua create mode 100644 tests/engine/gen3_static_palette_reuse.lua create mode 100644 tests/palette_programs/fixture.lua create mode 100644 tests/test_gen3_palette_profiles.py create mode 100644 tools/gen3_palette_profiles.py create mode 100644 tools/gen3_palette_program_profiles.json create mode 100644 tools/native-palettes/fixture.lua diff --git a/docs/native-terrain-palettes.md b/docs/native-terrain-palettes.md new file mode 100644 index 000000000..56768a3d7 --- /dev/null +++ b/docs/native-terrain-palettes.md @@ -0,0 +1,31 @@ +# Native terrain palette programs + +Normal GBA imports now classify terrain palette callbacks and preserve original +BGR555 palette words beside existing RGB data. Exact-ROM profiles cover Emerald +and FireRed Rev 0. Their pinned source references and digests are recorded in +`tools/gen3_palette_program_profiles.json`; unknown revisions or unproved +programs remain explicitly unavailable. No callback code executes. Bounded +read-only initializer analysis only identifies an audited runtime program. + +Emerald's Battle Dome program supplies four unfaded palettes on a four-tick +cadence. FireRed's audited terrain callbacks are tile-only. Palette frame records +retain both native RGB and original words; existing RGB values are unchanged. +The stage authenticates the actual cartridge bytes, clears stale success on +failure and writes diagnostics to the existing tileset cache. The other three +GBA editions and arbitrary modified cartridges are not certified by these +profiles. Existing imports need a cache refresh before the new metadata exists; +automatic upgrading remains separate. + +The native decoder observes independent palette ticks. The renderer refreshes +both terrain layers, cached batches, borders and reflection covers while keeping +their GPU texture identities. A bounded CPU picture cache avoids rebaking +repeating palettes; decoder samples retain at most four pictures and invalidate +on in-place source edits. Missing or invalid metadata retains base rendering. +No additional simulation, RNG, save field or mod manifest change is introduced. + +ROM-free tests cover real synthetic headers and automatic stage serialization, +initializer/recipe failures, mutable source invalidation, cache limits, native +sheet/border/reflection pixels, unchanged legacy rendering and upload failure +cleanup. Run the six `gen3_palette_*` / `gen3_static_palette_reuse` engine suites +and `python3 tests/test_gen3_palette_profiles.py`. These checks do not establish +full-game, GPU, performance, cartridge fading or map-local reset-phase parity. diff --git a/src/import/Gen3PalettePrograms.lua b/src/import/Gen3PalettePrograms.lua new file mode 100644 index 000000000..7ebe45574 --- /dev/null +++ b/src/import/Gen3PalettePrograms.lua @@ -0,0 +1,140 @@ +-- Read-only, bounded analysis of Gen3 tileset initializers. Runtime program +-- recipes are source-pinned in the manifest and require an exact ROM digest. +-- This is not an ARM emulator: RAM is never read/written and no code runs. +local Rom = require("src.import.RomGba") +local M = {} +local BASE = 0x08000000 +local function integer(n,lo,hi) + return type(n)=="number" and n==math.floor(n) and n>=lo and n<=hi +end +local function ram(n,size) + return type(n)=="number" and ((n>=0x02000000 and n+size<=0x02040000) + or (n>=0x03000000 and n+size<=0x03008000)) and n%size==0 +end +local function offset(rom,address,size) + if not integer(address,BASE,BASE+rom.size-size) then error("ROM pointer out of bounds") end + return address-BASE +end + +-- These leaf initializers only assign counters and a callback pointer. +-- Unknown halfword loads remain unknown; their counter values are irrelevant +-- to classifying the installed callback. Unknown callback stores decline. +local function initializer(rom,address) + if not integer(address,BASE+1,BASE+rom.size-2) or address%2~=1 then error("invalid initializer pointer") end + local start=address-BASE-1 + local reg,stores={},{} + for index=0,63 do + local pc=start+index*2 + local h=rom:u16(pc) + local d=h%8 + if h==0x4770 then + if #stores~=1 then error("initializer must install exactly one callback") end + return stores[1] + elseif h>=0x4800 and h<0x5000 then + local at=pc+4;at=at-at%4+(h%256)*4 + reg[math.floor(h/256)%8]=rom:u32(at) + elseif h>=0x2000 and h<0x2800 then + reg[math.floor(h/256)%8]=h%256 + elseif h<0x1000 then + local src=reg[math.floor(h/8)%8] + local shift=math.floor(h/64)%32 + if h<0x0800 then reg[d]=src and (src*2^shift)%4294967296 + else reg[d]=src and (shift==0 and 0 or math.floor(src/2^shift)) end + elseif h>=0x1800 and h<0x2000 then + local a=reg[math.floor(h/8)%8] + local b=math.floor(h/64)%8 + if math.floor(h/1024)%2==0 then b=reg[b] end + local subtract=math.floor(h/512)%2==1 + reg[d]=a and b and (a+(subtract and -b or b))%4294967296 + elseif h>=0x3000 and h<0x4000 then + local rd=math.floor(h/256)%8 + reg[rd]=reg[rd] and (reg[rd]+(h<0x3800 and h%256 or -(h%256)))%4294967296 + elseif h>=0x4600 and h<0x4700 then + local rd=d+math.floor(h/128)%2*8 + if rd>=13 then error("unsupported initializer register") end + reg[rd]=reg[math.floor(h/8)%16] + elseif h>=0x6000 and h<0x6800 then + local base=reg[math.floor(h/8)%8] + local dest=base and base+math.floor(h/64)%32*4 + if not ram(dest,4) or reg[d]==nil then error("unproven callback store") end + stores[#stores+1]=reg[d] + elseif h>=0x8000 and h<0x9000 then + local base=reg[math.floor(h/8)%8] + local dest=base and base+math.floor(h/64)%32*2 + if not ram(dest,2) then error("unproven counter access") end + if h>=0x8800 then reg[d]=nil end + else + error(("unsupported initializer instruction %04x"):format(h)) + end + end + error("initializer instruction limit exceeded") +end + +local function recipe(rom,record,profile,layout) + if type(record.secondary)~="boolean" then error("tileset bank unavailable") end + if record.callback==0 then return {},"no-callback" end + local target=initializer(rom,record.callback) + if target==0 then return {},"null-callback" end + if not integer(target,BASE+1,BASE+rom.size-2) or target%2~=1 then error("invalid runtime callback") end + local program=profile.programs[tostring(target-BASE-1)] + if type(program)~="table" or program.secondary~=record.secondary then error("unknown runtime palette program") end + if program.kind=="static" then return {},program.id end + local split=type(layout)=="table" and layout.palettesInPrimary + if not integer(split,1,15) or program.kind~="frames" + or not integer(program.frames,2,256) or not integer(program.count,1,256) + or not integer(program.first,0,255) or program.first+program.count>256 + or not integer(program.step,1,65535) or (program.phase~=nil and not integer(program.phase,0,65535)) + or not integer(program.table,0,rom.size-program.frames*4) + or program.table%4~=0 or program.frames*program.count*3>262144 then error("invalid palette recipe") end + local lo,hi=record.secondary and split*16 or 0,record.secondary and 256 or split*16 + if program.firsthi then error("palette recipe crosses bank") end + local frames,sourceFrames={},{} + for frame=0,program.frames-1 do + local at=offset(rom,rom:u32(program.table+frame*4),program.count*2) + if at%2~=0 then error("unaligned palette frame") end + local rgb,raw={},{} + for color=0,program.count-1 do + local word=rom:u16(at+color*2) + if word>=0x8000 then error("invalid BGR555 palette frame") end + rgb[#rgb+1]=string.char(Rom.bgr555(word)) + raw[#raw+1]=string.char(word%256,math.floor(word/256)) + end + frames[#frames+1]=table.concat(rgb) + sourceFrames[#sourceFrames+1]=table.concat(raw) + end + return {{kind="frames",first=program.first,count=program.count,step=program.step, + phase=program.phase or 0,frames=frames,sourceFrames=sourceFrames}},program.id +end + +function M.extract(rom,sets,manifest,actualSha1) + local profile=manifest and manifest.terrainPalettePrograms + local identity=type(profile)=="table" and getmetatable(profile)==nil and profile.version==1 + and type(actualSha1)=="string" and #actualSha1==40 + and actualSha1==manifest.romSha1 and actualSha1==profile.romSha1 + and type(profile.programs)=="table" and getmetatable(profile.programs)==nil + local report={version=1,static=0,animated=0,unavailable=0} + local keys={} + for key,value in pairs(sets) do + if key~="_romInfo" and type(value)=="table" then keys[#keys+1]=key end + end + table.sort(keys) + for _,key in ipairs(keys) do + local record=sets[key] + -- A rerun after a source/profile change must never retain old success. + record.paletteCycles=nil + local ok,cycles,id + if identity then ok,cycles,id=pcall(recipe,rom,record,profile,manifest.layout or {}) end + if ok then + record.paletteCycles=cycles + local status=#cycles>0 and "animated" or "static" + record.paletteProgram={version=1,status=status,id=id,romSha1=actualSha1} + report[status]=report[status]+1 + else + record.paletteProgram={version=1,status="unavailable", + reason=identity and tostring(cycles) or "ROM palette program identity unavailable"} + report.unavailable=report.unavailable+1 + end + end + return report +end +return M diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index c2ce0b55e..8ca751e33 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -22,6 +22,8 @@ -- Nothing here hardcodes an address; if the manifest lacks a symbol, the -- stage that needs it says so and is skipped rather than reading whatever -- happens to be at offset zero. +-- Terrain palette callback classifications are a separately identified, +-- source-pinned manifest profile, gated again on the actual full ROM digest. -- -- The CONTRACT is the same as Gen 2's, because the runtime's is: `new` takes -- (romData, version, manifest, progress), `run` walks DATA_STAGES then @@ -53,7 +55,7 @@ RomExtractorGen3.__index = RomExtractorGen3 -- The progress denominator. Kept honest with the two stage lists below: a -- mismatch does not break anything, it just makes the bar lie, which is the -- kind of small wrongness that survives for months. -local STAGE_COUNT = 94 +local STAGE_COUNT = 95 -- --------------------------------------------------------------------------- -- The stage tables below are keyed to Emerald's ROM. A sibling cartridge @@ -27068,9 +27070,11 @@ function RomExtractorGen3:extractTilesets() local ok, err = pcall(function() local raw = compressed and rom:lz77(tilesAt) or rom:bytes(tilesAt, 512 * 32) - local palettes = {} + local palettes, sourcePalettes = {}, {} for p = 0, 15 do - palettes[p + 1] = RomGba.palette(rom:bytes(palAt + p * 32, 32)) + local rawPalette = rom:bytes(palAt + p * 32, 32) + palettes[p + 1] = RomGba.palette(rawPalette) + for _, value in ipairs(rawPalette) do sourcePalettes[#sourcePalettes + 1] = string.char(value) end end -- metatile composition and attributes, as byte strings for the same -- reason the blockdata is: 73 tilesets come to about 26,000 metatiles @@ -27122,6 +27126,7 @@ function RomExtractorGen3:extractTilesets() -- owns its own bank. Which half to read is the pair's business, not -- the tileset's -- see palettesInPrimary in the manifest. palettes = palettes, + sourcePaletteBytes = table.concat(sourcePalettes), tiles = table.concat(pixels), metatiles = table.concat(comp), attributes = table.concat(attrs), @@ -40984,7 +40989,7 @@ RomExtractorGen3.DATA_STAGES = { "extractMoveAnimations", "extractStarterSelect", "extractNamingKeyboard", "extractBattleMenu", "extractSummaryText", - "extractHoennDex", "extractTilesetAnimations", "extractSafari", + "extractHoennDex", "extractTilesetAnimations", "extractTilesetPalettePrograms", "extractSafari", "extractTrainerMemo", "extractAbilityDescriptions", "extractMonAnims", "extractCries", "extractReflections", "extractElevation", "extractBattleWindows", @@ -42519,6 +42524,29 @@ function RomExtractorGen3:thumbLength(fn, limit) return nil end +function RomExtractorGen3:extractTilesetPalettePrograms() + self:beginStage("Gen3 tileset palette programs") + if type(self._tilesets)~="table" then + Logger.warn("gen3 palette programs: no tilesets -- run extractTilesets first") + return + end + -- Authenticate the actual bytes, not just the manifest/selected game label. + -- The application has verified these too, but a standalone stage must not + -- reinterpret another revision through a known program-address catalog. + local ok,digest=pcall(require("src.import.RomVerification").sha1,self.rom.data) + local report=require("src.import.Gen3PalettePrograms").extract( + self.rom,self._tilesets,self.manifest,ok and digest or nil) + self._tilesets._romInfo=self._tilesets._romInfo or {} + self._tilesets._romInfo.palettePrograms=report + self:write("map_tilesets",self._tilesets) + Logger.info("Gen3 palette programs: %d static, %d animated, %d unavailable", + report.static,report.animated,report.unavailable) + if report.unavailable>0 then + Logger.warn("gen3 palette programs: incomplete sources retain native base palettes; " + .. "check map_tilesets.paletteProgram diagnostics and the ROM revision") + end +end + function RomExtractorGen3:extractTilesetAnimations() self:beginStage("Gen3 tileset animations") local A = RomExtractorGen3.ANIM diff --git a/src/render/Gen3PaletteCycles.lua b/src/render/Gen3PaletteCycles.lua new file mode 100644 index 000000000..dc7e1574b --- /dev/null +++ b/src/render/Gen3PaletteCycles.lua @@ -0,0 +1,139 @@ +-- Bounded, pure palette animation. Records use absolute BG colour offsets +-- (slot * 16 + colour), even in the secondary bank, just like metatile words. +-- No callback execution, cartridge IO, clock advancement or retained cache. +local M = {} +local names = { "primary", "secondary" } +local function integer(n, lo, hi) + return type(n) == "number" and n == math.floor(n) and n >= lo and n <= hi +end +local function dense(t, max) + if type(t) ~= "table" or getmetatable(t) ~= nil or #t > max then return false end + local n = 0 + for k in pairs(t) do + if not integer(k, 1, #t) then return false end + n = n + 1 + end + return n == #t +end + +-- The native renderer owns its last sample. Static banks need no new colour +-- tables or packed strings every terrain tick, but source tables are mutable: +-- compare their complete shape and RGB bytes before reusing that sample. +-- In particular, empty metadata is static; missing metadata is still unknown. +function M.staticUnchanged(pair, layout, ticks, previous) + if not previous or not previous.complete + or not integer(ticks, 0, 9007199254740991) + or not integer(layout.palettesInPrimary, 1, 15) then return false end + for _, name in ipairs(names) do + local bank = pair[name] + local selected = previous[name .. "Frames"] + if type(bank) ~= "table" or getmetatable(bank) ~= nil + or not dense(bank.paletteCycles, 0) + or type(selected) ~= "table" or #selected ~= 0 then return false end + end + -- Reject either animated bank before scanning any colours. Emerald often + -- combines a static primary with an animated secondary. + for _, name in ipairs(names) do + local bank = pair[name] + local packed = previous[name .. "RGB8"] + if type(packed) ~= "string" or #packed ~= 768 + or not dense(bank.palettes, 16) or #bank.palettes ~= 16 then return false end + local at = 1 + for p = 1, 16 do + local palette = bank.palettes[p] + if not dense(palette, 16) or #palette ~= 16 then return false end + for c = 1, 16 do + local rgb = palette[c] + if not dense(rgb, 3) or #rgb ~= 3 then return false end + local r, g, b = packed:byte(at, at + 2) + if rgb[1] ~= r or rgb[2] ~= g or rgb[3] ~= b then return false end + at = at + 3 + end + end + end + return true +end + +-- Missing metadata is unknown, not evidence that an old cache is static. +function M.sample(pair, layout, ticks) + if not integer(ticks, 0, 9007199254740991) then return nil, "terrain clock unavailable" end + local split = type(layout) == "table" and layout.palettesInPrimary + if not integer(split, 1, 15) then return nil, "palette animation data unavailable" end + local runs, bytes = 0, 0 + for _, name in ipairs(names) do + local bank = pair[name] + local list = type(bank) == "table" and getmetatable(bank) == nil and bank.paletteCycles + if not dense(list, 32) then return nil, "palette animation data unavailable" end + local lo, hi = name == "primary" and 0 or split * 16, name == "primary" and split * 16 or 256 + for _, a in ipairs(list) do + runs = runs + 1 + if runs > 32 or type(a) ~= "table" or getmetatable(a) ~= nil + or not integer(a.first, lo, hi - 1) or not integer(a.count, 1, hi - lo) + or a.first + a.count > hi or not integer(a.step, 1, 65535) + or (a.phase ~= nil and not integer(a.phase, 0, 65535)) then + return nil, "palette animation data unavailable" + end + if a.kind == "frames" then + if not dense(a.frames, 256) or #a.frames < 2 then return nil, "palette animation data unavailable" end + for _, f in ipairs(a.frames) do + if type(f) ~= "string" or #f ~= a.count * 3 then return nil, "palette animation data unavailable" end + bytes = bytes + #f + if bytes > 262144 then return nil, "palette animation data unavailable" end + end + elseif a.kind ~= "rotate" or a.count < 2 or (a.direction ~= 1 and a.direction ~= -1) then + return nil, "palette animation data unavailable" + end + end + end + local out = { ticks = ticks, primary = {}, secondary = {}, complete = true } + local key = { "gen3-palette-v1" } + for _, name in ipairs(names) do + local src, dst = pair[name], out[name] + if not dense(src.palettes, 16) or #src.palettes ~= 16 then return nil, "palette animation data unavailable" end + for p = 1, 16 do + local palette = src.palettes[p] + if not dense(palette, 16) or #palette ~= 16 then return nil, "palette animation data unavailable" end + dst[p] = {} + for c = 1, 16 do + local rgb = palette[c] + if not dense(rgb, 3) or #rgb ~= 3 then return nil, "palette animation data unavailable" end + dst[p][c] = {} + for channel = 1, 3 do + if not integer(rgb[channel], 0, 255) then return nil, "palette animation data unavailable" end + dst[p][c][channel] = rgb[channel] + end + end + end + local selected = {} + for i, a in ipairs(src.paletteCycles) do + -- Reduce before adding phase: ticks + phase could exceed exact doubles. + local length = a.kind == "frames" and #a.frames or a.count + local period = length * a.step + local f = math.floor(((ticks % period) + (a.phase or 0)) % period / a.step) + selected[i] = f + local frame = a.kind == "frames" and a.frames[f + 1] + for c = 0, a.count - 1 do + local at = a.first + c + local rgb + if frame then + rgb = { frame:byte(c * 3 + 1, c * 3 + 3) } + else + local from = a.first + (c - a.direction * f) % a.count + local source = src.palettes[math.floor(from / 16) + 1][from % 16 + 1] + rgb = { source[1], source[2], source[3] } + end + dst[math.floor(at / 16) + 1][at % 16 + 1] = rgb + end + end + out[name .. "Frames"] = selected + local packed = {} + for _, p in ipairs(dst) do + for _, rgb in ipairs(p) do packed[#packed + 1] = string.char(rgb[1], rgb[2], rgb[3]) end + end + out[name .. "RGB8"] = table.concat(packed) + key[#key + 1] = out[name .. "RGB8"] + end + out.contentKey = table.concat(key) + return out +end +return M diff --git a/src/render/Gen3PaletteFrameCache.lua b/src/render/Gen3PaletteFrameCache.lua new file mode 100644 index 000000000..6daf84939 --- /dev/null +++ b/src/render/Gen3PaletteFrameCache.lua @@ -0,0 +1,56 @@ +-- CPU atlas pictures only. Owners keep their existing GPU textures/quads. +-- One global byte budget and a per-owner entry limit bound arbitrary cycles. +local M = {} +function M.release(surfaces) + for _,data in ipairs(surfaces) do if data.release then data:release() end end +end +function M.new(budget,perOwner) + local owners,bytes,count,clock,hits,misses={},0,0,0,0,0,0 + local cache={} + local function remove(owner,key) + local list=owners[owner];local entry=list and list[key] + if not entry then return end + list[key]=nil;bytes=bytes-entry.bytes;count=count-1 + M.release(entry.surfaces) + if next(list)==nil then owners[owner]=nil end + end + local function oldest(only) + local owner,key,at + for candidate,list in pairs(owners) do + if not only or candidate==only then + for k,v in pairs(list) do + if not at or v.usedbudget then return false end + remove(owner,key) + local n=0;for _ in pairs(owners[owner] or {}) do n=n+1 end + if n>=perOwner then local o,k=oldest(owner);remove(o,k) end + while bytes+size>budget do local o,k=oldest();remove(o,k) end + clock=clock+1;owners[owner]=owners[owner] or {} + owners[owner][key]={surfaces=surfaces,bytes=size,used=clock} + bytes=bytes+size;count=count+1 + return true + end + function cache:drop(owner) + while owners[owner] do local key=next(owners[owner]);remove(owner,key) end + end + function cache:clear() + while next(owners) do self:drop(next(owners)) end + end + function cache:stats()return {bytes=bytes,entries=count,hits=hits,misses=misses, + budget=budget,perOwner=perOwner} end + return cache +end +return M diff --git a/src/render/Gen3PaletteSamples.lua b/src/render/Gen3PaletteSamples.lua new file mode 100644 index 000000000..6066e0d17 --- /dev/null +++ b/src/render/Gen3PaletteSamples.lua @@ -0,0 +1,127 @@ +-- Native-only palette samples. The public sampler remains detached and pure. +-- Each decoder retains at most four owned pictures; mutable source tables are +-- checked by value before reuse, never merely by identity or current RGB output. +local Cycles = require("src.render.Gen3PaletteCycles") +local M = {} +M.__index = M +local banks = {"primary", "secondary"} +local fields = {"kind", "first", "count", "step", "phase"} +local function dense(t, size) + if type(t) ~= "table" or getmetatable(t) ~= nil or #t ~= size then return false end + local n = 0 + for k in pairs(t) do + if type(k) ~= "number" or k ~= math.floor(k) or k < 1 or k > size then return false end + n = n + 1 + end + return n == size +end + +-- Called only after the full sampler has validated the complete source. +local function snapshot(pair, layout) + local out = {split = layout.palettesInPrimary} + for _, name in ipairs(banks) do + local src, bytes, programs = pair[name], {}, {} + for _, p in ipairs(src.palettes) do + for _, rgb in ipairs(p) do bytes[#bytes + 1] = string.char(rgb[1], rgb[2], rgb[3]) end + end + for i, rule in ipairs(src.paletteCycles) do + local copy = {} + for _, key in ipairs(fields) do copy[key] = rule[key] end + if rule.kind == "frames" then + copy.frames = {} + for j, frame in ipairs(rule.frames) do copy.frames[j] = frame end + else + copy.direction = rule.direction + end + programs[i] = copy + end + out[name] = {rgb = table.concat(bytes), programs = programs} + end + return out +end + +local function unchanged(pair, layout, source) + if layout.palettesInPrimary ~= source.split then return false end + for _, name in ipairs(banks) do + local bank, old = pair[name], source[name] + if type(bank) ~= "table" or getmetatable(bank) ~= nil + or not dense(bank.paletteCycles, #old.programs) + or not dense(bank.palettes, 16) then return false end + for i, rule in ipairs(bank.paletteCycles) do + local saved = old.programs[i] + if type(rule) ~= "table" or getmetatable(rule) ~= nil then return false end + for _, key in ipairs(fields) do if rule[key] ~= saved[key] then return false end end + if saved.frames then + if not dense(rule.frames, #saved.frames) then return false end + for j, bytes in ipairs(saved.frames) do if rule.frames[j] ~= bytes then return false end end + elseif rule.direction ~= saved.direction then + return false + end + end + local at = 1 + for p = 1, 16 do + local palette = bank.palettes[p] + if not dense(palette, 16) then return false end + for c = 1, 16 do + local rgb = palette[c] + if not dense(rgb, 3) then return false end + local r, g, b = old.rgb:byte(at, at + 2) + if rgb[1] ~= r or rgb[2] ~= g or rgb[3] ~= b then return false end + at = at + 3 + end + end + end + return true +end + +local function selected(sample, source, ticks) + for _, name in ipairs(banks) do + local indices = sample[name .. "Frames"] + for i, rule in ipairs(source[name].programs) do + local length = rule.kind == "frames" and #rule.frames or rule.count + local period = length * rule.step + local index = math.floor(((ticks % period) + (rule.phase or 0)) % period / rule.step) + if indices[i] ~= index then return false end + end + end + return true +end + +function M.new(pair, layout, first) + return setmetatable({source = snapshot(pair, layout), pictures = {first}, + hits = 0, misses = 1, invalidations = 0}, M) +end + +function M:sample(pair, layout, ticks) + if type(ticks) ~= "number" or ticks ~= math.floor(ticks) or ticks < 0 or ticks > 9007199254740991 then + self.source, self.pictures = nil, {} + return nil, "terrain clock unavailable" + end + local same = self.source and unchanged(pair, layout, self.source) + if same then + for i = #self.pictures, 1, -1 do + local hit = self.pictures[i] + if selected(hit, self.source, ticks) then + table.remove(self.pictures, i);self.pictures[#self.pictures + 1] = hit + hit.ticks = ticks;self.hits = self.hits + 1 + return hit + end + end + else + self.source, self.pictures = nil, {} + self.invalidations = self.invalidations + 1 + end + self.misses = self.misses + 1 + local sample, reason = Cycles.sample(pair, layout, ticks) + if not sample then self.source, self.pictures = nil, {};return nil, reason end + if not self.source then self.source = snapshot(pair, layout) end + self.pictures[#self.pictures + 1] = sample + if #self.pictures > 4 then table.remove(self.pictures, 1) end + return sample +end + +function M:stats() + return {entries = #self.pictures, hits = self.hits, misses = self.misses, + invalidations = self.invalidations, limit = 4} +end +return M diff --git a/src/render/Gen3Tiles.lua b/src/render/Gen3Tiles.lua index 1a59d1898..e9941ac05 100644 --- a/src/render/Gen3Tiles.lua +++ b/src/render/Gen3Tiles.lua @@ -119,11 +119,37 @@ function Gen3Tiles:tilePixels(index) end function Gen3Tiles:palette(index) + if self.paletteState then + local name = index < self.palettesInPrimary and "primary" or "secondary" + return self.paletteState[name][index + 1] + end local record = self:paletteBank(index) local pals = record and record.palettes return pals and pals[index + 1] or nil end +-- Independent palette cadence; does not alter the tile animation picture. +-- Missing legacy metadata retains the original base-palette rendering. +function Gen3Tiles:setPaletteTicks(ticks) + local cycles = require("src.render.Gen3PaletteCycles") + if cycles.staticUnchanged(self, self, ticks, self.paletteState) then + self.paletteState.ticks = ticks + return false + end + local state, reason + if self.paletteSampler then + state, reason = self.paletteSampler:sample(self, self, ticks) + else + state, reason = cycles.sample(self, self, ticks) + if state and (#state.primaryFrames > 0 or #state.secondaryFrames > 0) then + self.paletteSampler = require("src.render.Gen3PaletteSamples").new(self, self, state) + end + end + local changed = (state and state.contentKey) ~= (self.paletteState and self.paletteState.contentKey) + self.paletteState = state + return changed, reason +end + -- The colour the backdrop shows where nothing is drawn: palette 0, colour 0. function Gen3Tiles:backdrop() local p = self:palette(0) diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index b82fc4cfb..785b6d057 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -995,6 +995,16 @@ end -- pair is dropped. Twelve is comfortably more than a town, its interiors and -- the routes either side of it, which is the working set walking produces. TileRenderer.GEN3_SHEET_CACHE = 12 +local PaletteFrameCache = require("src.render.Gen3PaletteFrameCache") +local paletteFrames = PaletteFrameCache.new(32*1024*1024,4) +function TileRenderer.gen3PaletteCacheStats() return paletteFrames:stats() end + +local function palettePictureKey(tiles) + local state=tiles.paletteState + if state and (#state.primaryFrames>0 or #state.secondaryFrames>0) then + return state.contentKey + end +end local function gen3Evict() local count = 0 @@ -1015,7 +1025,8 @@ local function gen3Evict() end if not oldest then return end local record = gen3Sheets[oldest] - for _, image in ipairs({ record.bottom, record.top }) do + paletteFrames:drop(record) + for _, image in ipairs({ record.bottom, record.top, record.cover }) do if image and image.release then pcall(image.release, image) end end gen3Sheets[oldest] = nil @@ -1103,7 +1114,7 @@ local function pixelCanvas(w, h) return plot, finish end -local function buildReflectionCover(record, tiles, statics, frames) +local function bakeReflectionCover(record, tiles, statics, frames) if not (love.image and love.image.newImageData) then return end frames = math.max(1, frames or 1) local ids = {} @@ -1120,7 +1131,7 @@ local function buildReflectionCover(record, tiles, statics, frames) local w = cols * 16 local h = math.ceil(#ids * frames / cols) * 16 local plot, finish = pixelCanvas(w, h) - local quads = {} + local quads = record.coverQuads or {} for f = 0, frames - 1 do tiles:setAnimFrame(f) for i, id in ipairs(ids) do @@ -1128,13 +1139,16 @@ local function buildReflectionCover(record, tiles, statics, frames) local ox, oy = (slot % cols) * 16, math.floor(slot / cols) * 16 tiles:drawLayer(id, 2, ox, oy, plot) quads[id] = quads[id] or {} - quads[id][f + 1] = love.graphics.newQuad(ox, oy, 16, 16, w, h) + if not quads[id][f + 1] then + quads[id][f + 1] = love.graphics.newQuad(ox, oy, 16, 16, w, h) + end end end tiles:setAnimFrame(0) - record.cover = love.graphics.newImage(finish()) - record.coverQuads = quads + local surface = finish() + record.coverQuads = record.coverQuads or quads record.coverFrames = frames + return surface,w*h*4 end function TileRenderer.gen3SheetsFor(tilesetDef, data, layout) @@ -1160,6 +1174,10 @@ function TileRenderer.gen3SheetsFor(tilesetDef, data, layout) primary = primary, secondary = tilesetDef.secondaryKey and store[tilesetDef.secondaryKey] or nil, }, layout) + -- Legacy records have no paletteCycles: their original bake stays intact. + if primary.paletteCycles ~= nil or (tiles.secondary and tiles.secondary.paletteCycles ~= nil) then + tiles:setPaletteTicks(animFrame) + end -- ---- WHAT MOVES, AND WHERE ITS OTHER PICTURES GO ------------------------ -- @@ -1194,7 +1212,7 @@ function TileRenderer.gen3SheetsFor(tilesetDef, data, layout) end end - local built = {} + local built,initialSurfaces = {},{} for layer = 1, 2 do local plot, finish = pixelCanvas(w, h) -- the static sheet holds FRAME ZERO, not the tileset's shipped bytes: the @@ -1208,7 +1226,8 @@ function TileRenderer.gen3SheetsFor(tilesetDef, data, layout) tiles:bakeMetatileInto(id, layer, slotOf[id][f], plot) end end - built[layer] = love.graphics.newImage(finish()) + initialSurfaces[layer]=finish() + built[layer] = love.graphics.newImage(initialSurfaces[layer]) end tiles:setAnimFrame(0) @@ -1216,7 +1235,54 @@ function TileRenderer.gen3SheetsFor(tilesetDef, data, layout) tiles = tiles, metatiles = statics, slots = slots, animSlots = (frames > 1) and slotOf or nil, animFrames = frames, animStep = tiles:animStep() } - buildReflectionCover(record, tiles, statics, frames) + local coverData,coverBytes=bakeReflectionCover(record, tiles, statics, frames) + if coverData then record.cover=love.graphics.newImage(coverData) end + initialSurfaces[3]=coverData + local pictureBytes=w*h*8+(coverBytes or 0) + local initialKey=palettePictureKey(tiles) + if not initialKey or not paletteFrames:put(record,initialKey,initialSurfaces,pictureBytes) then + PaletteFrameCache.release(initialSurfaces) + end + -- Update the existing textures, including every tile-animation variant and + -- reflection cover, only when palette pixels change. Batches/quads retain + -- their image identity; independent cycles never multiply the baked atlas. + record.recolor = function() + local oldFrame = tiles.animFrame or 0 + local pictureKey=palettePictureKey(tiles) + if not pictureKey then paletteFrames:drop(record) end + local hit=pictureKey and paletteFrames:get(record,pictureKey) + if hit then + record.bottom:replacePixels(hit[1]);record.top:replacePixels(hit[2]) + if hit[3] then record.cover:replacePixels(hit[3]) end + return + end + local surfaces,retained={},false + local ok,err=xpcall(function() + for layer, image in ipairs({ record.bottom, record.top }) do + local plot, finish = pixelCanvas(w, h) + tiles:setAnimFrame(0) + tiles:bakeLayer(layer, plot) + for f = 2, frames do + tiles:setAnimFrame(f - 1) + for _, id in ipairs(movers) do + tiles:bakeMetatileInto(id, layer, slotOf[id][f], plot) + end + end + surfaces[layer]=finish() + image:replacePixels(surfaces[layer]) + end + local coverSurface,size=bakeReflectionCover(record, tiles, statics, frames) + surfaces[3]=coverSurface + if coverSurface then record.cover:replacePixels(coverSurface) end + if pictureKey then + retained=paletteFrames:put(record,pictureKey,surfaces,w*h*8+(size or 0)) + end + end,debug.traceback) + tiles:setAnimFrame(oldFrame) + if not retained then PaletteFrameCache.release(surfaces) end + if not ok then error(err,0) end + end + record.paletteTicks = animFrame gen3Sheets[key] = record gen3Evict() Probe.say("tilebake", "%s: movers=%d frames=%d step=%s primaryAnims=%s " @@ -1258,8 +1324,24 @@ function TileRenderer.gen3SheetsFor(tilesetDef, data, layout) return record end +function TileRenderer:syncGen3Palettes() + local record = self.gen3 + if not record or record.paletteTicks == animFrame then return end + record.paletteTicks = animFrame + local tiles = record.tiles + if not tiles or (tiles.primary.paletteCycles == nil + and (not tiles.secondary or tiles.secondary.paletteCycles == nil) + and not tiles.paletteState) then return end + local changed = tiles:setPaletteTicks(animFrame) + if changed and record.recolor then + record.recolor() + record.paletteRevision = (record.paletteRevision or 0) + 1 + end +end + -- a re-import replaces the data the sheets were baked from function TileRenderer.releaseGen3Sheets() + paletteFrames:clear() gen3Sheets = {} gen3Used, gen3Clock = {}, 0 end @@ -1537,7 +1619,8 @@ local function bakeGen3BorderFill(self) if not (self.gen3 and self.gen3Quads) then return end local border = self.map.def and self.map.def.border if type(border) ~= "string" or #border < 8 then return end - local canvas = require("src.render.PixelCanvas").new(32, 32) + local canvas = self.gen3BorderCanvas or require("src.render.PixelCanvas").new(32, 32) + self.gen3BorderCanvas = canvas love.graphics.push("all") love.graphics.setCanvas(canvas) -- black, not white: a patch with a transparent metatile in it is a hole @@ -1556,7 +1639,9 @@ local function bakeGen3BorderFill(self) end love.graphics.setCanvas() love.graphics.pop() - local img = love.graphics.newImage(canvas:newImageData()) + local img = self.borderFill + if img then img:replacePixels(canvas:newImageData()) + else img = love.graphics.newImage(canvas:newImageData()) end img:setWrap("repeat", "repeat") img:setFilter("nearest", "nearest") self.borderFill = img @@ -1591,6 +1676,7 @@ end -- choice changes. OVERWORLD "black" leaves borderFill nil and draws a -- solid clear; "water" keeps the live hshift textures instead of a bake. function TileRenderer:ensureBorderFill() + self:syncGen3Palettes() local block = borderBlockFor(self.map) -- a Gen 3 map paints its void from its OWN border patch, so it is always -- "map" -- the VOID FILL option picks between three Gen 1 tileset blocks @@ -1603,7 +1689,13 @@ function TileRenderer:ensureBorderFill() local ready = (mode == "black") or (mode == "water" and self.borderWaterTextures) or (mode ~= "water" and mode ~= "black" and self.borderFill) - if self.borderFillMode == mode and ready then return end + if self.borderFillMode == mode and ready then + if self.gen3 and self.borderPaletteRevision ~= (self.gen3.paletteRevision or 0) then + local ok = pcall(bakeGen3BorderFill, self) + if ok then self.borderPaletteRevision = self.gen3.paletteRevision or 0 end + end + return + end if self.borderFill and self.borderFill.release then pcall(self.borderFill.release, self.borderFill) end @@ -1620,6 +1712,7 @@ function TileRenderer:ensureBorderFill() end if self.gen3 then pcall(bakeGen3BorderFill, self) + self.borderPaletteRevision = self.gen3.paletteRevision or 0 return end pcall(bakeBorderFill, self, block) @@ -1793,6 +1886,7 @@ function TileRenderer:setWin(tx0, ty0, tx1, ty1) end function TileRenderer:ensureWindow(camX, camY, vw, vh) + self:syncGen3Palettes() local W, H = self.bodyTilesW, self.bodyTilesH vw = vw or W * 8 -- a nil view (headless draw) means the whole body vh = vh or H * 8 @@ -2069,6 +2163,7 @@ function TileRenderer:reflectionCoverInfo(cx, cy) end function TileRenderer:drawReflectionCover(cx, cy, camX, camY) + self:syncGen3Palettes() local record = self.gen3 if not (record and record.tiles and self.map and self.map.blockAt) then return end local id = self.map:blockAt(cx, cy) @@ -2274,6 +2369,7 @@ end -- instances keep the batches they already built; MapLoader.invalidateAll -- is what drops those (14 §cache-invalidation contract). function TileRenderer.invalidate() + paletteFrames:clear() imageCache = {} shiftVariants = {} frameImages = {} diff --git a/tests/engine/gen3_palette_frame_cache.lua b/tests/engine/gen3_palette_frame_cache.lua new file mode 100644 index 000000000..6fca0262d --- /dev/null +++ b/tests/engine/gen3_palette_frame_cache.lua @@ -0,0 +1,48 @@ +package.path="./?.lua;./?/init.lua;"..package.path +local T=require("tests.harness") +local eq,check=T.eq,T.check +local FrameCache=require("src.render.Gen3PaletteFrameCache") +local released={} +local function value(name) + local data={name=name} + function data:release()released[name]=(released[name] or 0)+1 end + return {data} +end +local cache=FrameCache.new(100,2) +local a,b={},{} +local a1,a2,a3,b1=value('a1'),value('a2'),value('a3'),value('b1') +check(cache:put(a,'one',a1,30),'first owner stores picture') +check(cache:put(a,'two',a2,30),'owner stores second picture') +eq(cache:get(a,'one'),a1,'return visits update LRU') +check(cache:put(a,'three',a3,30),'per-owner limit evicts oldest') +eq(released.a2,1,'cold owner picture released once') +eq(cache:get(a,'two'),nil,'evicted picture unavailable') +check(cache:put(b,'one',b1,50),'global byte pressure stores another owner') +eq(released.a1,1,'global eviction follows actual LRU') +eq(cache:get(b,'one'),b1,'owner namespaces separate equal keys') +eq(cache:stats().bytes,80,'total byte accounting exact') +local big=value('oversize') +check(not cache:put(a,'large',big,101),'oversize picture cannot evict everything or exceed budget') +eq(released.oversize,nil,'rejected picture remains caller-owned') +FrameCache.release(big);eq(released.oversize,1,'caller can release rejected picture') +cache:drop(a);eq(released.a3,1,'owner release frees only its own pictures') +eq(cache:get(b,'one'),b1,'other owner survives release') +cache:clear();eq(released.b1,1,'global reset frees remaining picture') +eq(cache:stats().bytes,0,'global reset removes byte accounting') +eq(cache:stats().entries,0,'global reset removes entries') +cache:clear();cache:drop(a);eq(released.b1,1,'repeated reset never double-releases') +local prior=value('prior');local replacement=value('replacement') +cache:put(a,'same',prior,25);cache:put(a,'same',replacement,40) +eq(released.prior,1,'replacing picture releases old storage') +eq(cache:stats().bytes,40,'replacement size is accounted') +cache:clear();eq(released.replacement,1,'replacement released on reset') +for i=1,200 do + local owner=i%2==0 and a or b + local picture=value('cycle-'..i) + cache:put(owner,tostring(i%7),picture,25) + check(cache:stats().bytes<=100,'many cycles remain within global budget') + check(cache:stats().entries<=4,'many cycles remain within both owner limits') +end +cache:clear() +for i=1,200 do eq(released['cycle-'..i],1,'every retained picture released exactly once') end +T.finish() diff --git a/tests/engine/gen3_palette_program_failures.lua b/tests/engine/gen3_palette_program_failures.lua new file mode 100644 index 000000000..fb59eb3b1 --- /dev/null +++ b/tests/engine/gen3_palette_program_failures.lua @@ -0,0 +1,97 @@ +package.path="./?.lua;./?/init.lua;"..package.path +local T=require("tests.modkit") +local F=require("tests.palette_programs.fixture") +local M=require("src.import.Gen3PalettePrograms") +local Copy=require("src.mods.Merge").deepCopy +local Verify=require("src.import.RomVerification") +local Extractor=require("src.import.RomExtractorGen3") +for _,game in ipairs({"emerald","firered"}) do + local f=F.new(game) + local original=Copy(f.sets) + local manifest=Copy(f.manifest) + local bytes=f.rom.data + local function reset() + f.sets=Copy(original);f.manifest=Copy(manifest) + f.rom=require("src.import.RomGba").new(bytes) + end + local first + for k,v in pairs(original) do if v.callback~=0 and k~="unknown" and k~="null_callback" then first=k;break end end + local function reject(change) + reset();change(f.sets[first],f) + f.sets[first].paletteCycles={{stale=true}} + local ok,report=pcall(M.extract,f.rom,f.sets,f.manifest,f.manifest.romSha1) + T.eq(ok,true,"bad program cannot abort the import") + T.check(report and report.unavailable>0,"bad program explicitly counted unavailable") + T.eq(f.sets[first].paletteCycles,nil,"bad program cannot retain previous successful cycles") + T.eq(f.sets[first].paletteProgram.status,"unavailable","record contains failure diagnostic") + end + for _,bad in ipairs({false,"1",-1,0.5,math.huge,0/0,0x08000000,0x08002000,0x0A000001}) do + reject(function(r) r.callback=bad end) + end + reject(function(r) r.callback=nil end) + reject(function(r) r.secondary=nil end) + reject(function(r) r.secondary=not r.secondary end) + for _,h in ipairs({0xE000,0xD000,0xF000,0xDF00,0xB500,0xFFFF,0x6000,0x4770,0x46FF}) do + reject(function(r,f) f.change(r.callback-0x08000001,F.le(h,2)) end) + end + reject(function(r,f) f.change(r.callback-0x08000001,string.rep(F.le(0x46C0,2),64)) end) + reject(function(r,f) f.change(r.callback-0x08000001+44,F.le(0x08100000,4)) end) + reject(function(r,f) f.change(r.callback-0x08000001+40,F.le(0x06000000,4)) end) + reject(function(r,f) f.change(r.callback-0x08000001+40,F.le(0x03000021,4)) end) + reject(function(r,f) f.change(r.callback-0x08000001+32,F.le(0x08000000,4)) end) + reject(function(r,f) f.change(r.callback-0x08000001+20,F.le(0x6008,2)..F.le(0x4770,2)) end) + reset() + local calls=0 + local oldRead=f.rom.u16 + f.rom.u16=function(...) calls=calls+1;return oldRead(...) end + for _,digest in ipairs({false,"",string.rep("0",40),"f3ae088181bf583e55daf962a92bb46f4f1d07b7x"}) do + for _,r in pairs(f.sets) do r.paletteCycles={{stale=true}} end + local report=M.extract(f.rom,f.sets,f.manifest,digest) + T.eq(report.animated,0,"unknown revision cannot authorize palette reads") + T.eq(report.static,0,"identity failure cannot certify static programs") + T.eq(calls,0,"no initializer reads before actual identity matches") + for _,r in pairs(f.sets) do T.eq(r.paletteCycles,nil,"identity failure removes all stale metadata") end + end + reset() + f.manifest.terrainPalettePrograms=nil + T.eq(M.extract(f.rom,f.sets,f.manifest,manifest.romSha1).animated,0,"legacy manifest declines safely") + reset();f.manifest.terrainPalettePrograms.version=2 + T.eq(M.extract(f.rom,f.sets,f.manifest,manifest.romSha1).static,0,"unknown profile version declines") + -- Hashing is a stage requirement. Digest failure never silently falls back + -- to trusting the launcher's selected game name or the manifest's hash. + reset() + local stage=setmetatable({rom=f.rom,manifest=f.manifest,_tilesets=f.sets},{__index=Extractor}) + stage.beginStage=function() end;stage.write=function() end + local hash=Verify.sha1;Verify.sha1=function() error("hash unavailable") end + stage:extractTilesetPalettePrograms();Verify.sha1=hash + T.eq(f.sets[first].paletteProgram.status,"unavailable","hash failure persists useful status") + T.eq(f.sets[first].paletteCycles,nil,"hash failure cannot retain plausible cycles") +end +do + local f=F.new("emerald") + local original=f.rom.data + local function reject(change) + local probe=F.new("emerald");change(probe) + local report=M.extract(probe.rom,probe.sets,probe.manifest,probe.manifest.romSha1) + T.eq(report.animated,0,"malformed palette recipe/data never partially succeeds") + T.eq(probe.sets["battle-dome-floor"].paletteCycles,nil,"no partially read frames published") + end + for _,field in ipairs({"first","count","frames","step","phase","table"}) do + for _,bad in ipairs({false,"1",-1,0.5,math.huge,0/0}) do reject(function(p) p.program[field]=bad end) end + end + reject(function(p) p.program.step=0 end) + reject(function(p) p.program.frames=1 end) + reject(function(p) p.program.frames=257 end) + reject(function(p) p.program.count=0 end) + reject(function(p) p.program.first=95 end) + reject(function(p) p.program.first=250 end) + reject(function(p) p.program.table=p.rom.size-8 end) + reject(function(p) p.program.table=p.program.table+1 end) + reject(function(p) p.change(p.program.table,F.le(0x02000000,4)) end) + reject(function(p) p.change(p.program.table,F.le(0x08000000+p.rom.size-16,4)) end) + reject(function(p) p.change(p.program.table,F.le(0x08006001,4)) end) + reject(function(p) p.change(0x6000,F.le(0x8000,2)) end) + reject(function(p) p.change(p.program.table+12,F.le(0,4)) end) + T.eq(f.rom.data,original,"all malformed probes leave independent fixture intact") +end +T.finish("ROM palette program identity, bounds and unsupported callbacks") diff --git a/tests/engine/gen3_palette_program_import.lua b/tests/engine/gen3_palette_program_import.lua new file mode 100644 index 000000000..055d2b452 --- /dev/null +++ b/tests/engine/gen3_palette_program_import.lua @@ -0,0 +1,118 @@ +package.path="./?.lua;./?/init.lua;"..package.path +local T=require("tests.modkit") +local F=require("tests.palette_programs.fixture") +local M=require("src.import.Gen3PalettePrograms") +local Extractor=require("src.import.RomExtractorGen3") +local Verify=require("src.import.RomVerification") +local Writer=require("src.import.LuaWriter") +local Copy=require("src.mods.Merge").deepCopy +for _,game in ipairs({"emerald","firered"}) do + local f=F.new(game) + local original=f.rom.data + local report=M.extract(f.rom,f.sets,f.manifest,f.manifest.romSha1) + T.eq(report.animated,game=="emerald" and 1 or 0,"Smaragd palette program versus FireRed tile-only callbacks") + T.eq(report.static,game=="emerald" and 21 or 8,"all known tile-only/null programs positively classified") + T.eq(report.unavailable,1,"unknown runtime remains explicitly incomplete") + T.eq(f.sets.unknown.paletteCycles,nil,"no invented static array for unknown program") + for id,record in pairs(f.sets) do + if id~="unknown" then + T.check(type(record.paletteCycles)=="table","each supported source explicitly declares cycles") + T.eq(record.paletteProgram.romSha1,f.manifest.romSha1,"per-record provenance pins identity") + end + end + if game=="emerald" then + local run=f.sets["battle-dome-floor"].paletteCycles[1] + T.eq(run.first,128,"BG palette 8 absolute destination") + T.eq(run.count,16,"complete 16-colour palette copied") + T.eq(run.step,4,"source callback cadence") + T.eq(#run.frames,4,"four palette pointers, not inferred from tile VRAM") + T.same(run.frames,f.frames,"all colours use exact independent BGR555 floor decoding") + T.eq(#run.sourceFrames,4,"source palette words retained for every picture") + for frame=0,3 do + local at=f.rom:u32(f.program.table+frame*4)-0x08000000 + local raw=f.rom:bytes(at,32) + for color=0,15 do + local lo,hi=run.sourceFrames[frame+1]:byte(color*2+1,color*2+2) + T.eq(lo+hi*256,raw[color*2+1]+raw[color*2+2]*256, + "original BGR555 words survive independently of RGB rounding") + end + end + end + T.eq(f.rom.data,original,"analysis cannot modify cartridge bytes") + -- Exercise the actual automatic stage, real ROM reader and real Lua cache + -- serializer; only hash and file destination are injected for synthetic ROMs. + local stages,writes,hashes=0,0,0 + local stage=setmetatable({rom=f.rom,manifest=f.manifest,_tilesets=Copy(f.sets)}, {__index=Extractor}) + stage.beginStage=function(_,name) stages=stages+1;T.eq(name,"Gen3 tileset palette programs","automatic stage name") end + stage.write=function(_,name,value) + writes=writes+1;T.eq(name,"map_tilesets","uses existing cache namespace") + stage.serialized=Writer.encode(value) + end + local hash=Verify.sha1 + Verify.sha1=function(bytes) hashes=hashes+1;T.eq(bytes,f.rom.data,"authenticates the actual stage ROM bytes");return f.manifest.romSha1 end + local rand,gpu=math.random,love.graphics.newImage + math.random=function() error("import cannot consume gameplay RNG") end + love.graphics.newImage=function() error("data import cannot allocate graphics") end + local before=package.loaded["src.core.Game"] + stage:extractTilesetPalettePrograms() + math.random,love.graphics.newImage=rand,gpu + Verify.sha1=hash + T.eq(stages,1,"stage runs once");T.eq(writes,1,"one cache write after complete analysis");T.eq(hashes,1,"digest once per stage") + T.eq(package.loaded["src.core.Game"],before,"import never bootstraps player Game") + local loaded=assert(loadstring(stage.serialized))() + T.same(loaded._romInfo.palettePrograms,report,"persisted summary matches all callback classifications") + T.same(loaded[game=="emerald" and "battle-dome-floor" or "general"].paletteCycles, + f.sets[game=="emerald" and "battle-dome-floor" or "general"].paletteCycles,"binary palette frames survive actual cache serialization") + local tileStage,paletteStage + for i,name in ipairs(Extractor.DATA_STAGES) do + if name=="extractTilesetAnimations" then tileStage=i end + if name=="extractTilesetPalettePrograms" then paletteStage=i end + end + T.eq(paletteStage,tileStage+1,"normal import automatically reads palette programs after tile animations") + -- Import actual synthetic Tileset headers too. FireRed swaps the callback + -- and attribute pointers relative to Emerald; this catches a program + -- reader which works with hand-built records but loses real header data. + local headerRom=F.new(game) + for i,name in ipairs({"general",game=="emerald" and "battle-dome-floor" or "celadon"}) do + local at=i*0x100 + local gfx=0x8000+(i-1)*0x8000 + local pals=gfx+0x4000 + local meta=gfx+0x5000 + local callback=headerRom.sets[name].callback + local attrs=0x08000000+meta+48 + headerRom.change(at,string.char(0,i-1,0,0)..F.le(0x08000000+gfx,4)..F.le(0x08000000+pals,4) + ..F.le(0x08000000+meta,4)..F.le(game=="emerald" and attrs or callback,4) + ..F.le(game=="emerald" and callback or attrs,4)) + end + local captured={} + local headers=setmetatable({rom=headerRom.rom,manifest=headerRom.manifest,layout=headerRom.manifest.layout, + stage=0,_tilesetAddresses={[0x100]=true,[0x200]=true},_layoutRecords={}}, {__index=Extractor}) + headers.write=function(_,name,value) captured[name]=value end + headers.saveImage=function() end;headers.tilesetSheet=function() return {} end + -- These independent behaviour derivations need full map/script datasets; + -- they are outside this header/palette test and remain covered elsewhere. + for _,name in ipairs({"counterBehaviour","pcBehaviour","wallBehaviours","warpBehaviours","diveTerrain", + "waterfallTerrain","iceBehaviours","bikeBehaviours","escalatorBehaviours","currentBehaviours", + "forcedMovementBehaviours","lavaridgeWarps","noRunBehaviours","checkBehaviourNames"}) do + headers[name]=function() end + end + headers:extractTilesets() + local primary,secondary=headers._tilesets.TILESET_0000100,headers._tilesets.TILESET_0000200 + T.check(primary~=nil and secondary~=nil,"real header stage imports both source banks") + for _,record in ipairs({primary,secondary})do + T.eq(#record.sourcePaletteBytes,512,"complete original palette bank retained") + local raw=headerRom.rom:bytes(assert(headerRom.rom:pointer(record.address+8)),512) + for i=1,512 do T.eq(record.sourcePaletteBytes:byte(i),raw[i],"header-selected palette bytes preserved")end + end + T.eq(primary.callback,headerRom.sets.general.callback,"primary callback comes from title-specific header slot") + T.eq(secondary.callback,headerRom.sets[game=="emerald" and "battle-dome-floor" or "celadon"].callback, + "secondary callback comes from title-specific header slot") + local oldHash=Verify.sha1 + Verify.sha1=function(b) T.eq(b,headerRom.rom.data,"header-import palette stage verifies same bytes");return headerRom.manifest.romSha1 end + headers:extractTilesetPalettePrograms();Verify.sha1=oldHash + T.eq(captured.map_tilesets._romInfo.palettePrograms.unavailable,0,"actual header-to-program path is complete") + T.eq(primary.paletteProgram.status,"static","imported primary tile-only program classified") + T.eq(secondary.paletteProgram.status,game=="emerald" and "animated" or "static","imported secondary classified") + if game=="emerald" then T.same(secondary.paletteCycles[1].frames,headerRom.frames,"header path reads all frame bytes correctly") end +end +T.finish("Automatic Gen3 ROM palette program data import") diff --git a/tests/engine/gen3_palette_renderer.lua b/tests/engine/gen3_palette_renderer.lua new file mode 100644 index 000000000..b139d5c1b --- /dev/null +++ b/tests/engine/gen3_palette_renderer.lua @@ -0,0 +1,255 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local F = require("tools.native-palettes.fixture") +local Copy = require("src.mods.Merge").deepCopy +local TR = require("src.render.TileRenderer") +local Tiles = require("src.render.Gen3Tiles") +-- Pixel-bearing texture/Canvas stand-ins: run the real sheet bake, refresh, +-- cached batch and border paths without a window, GPU, cartridge or save. +local original={image=love.image,newImage=love.graphics.newImage,newCanvas=love.graphics.newCanvas, + draw=love.graphics.draw,clear=love.graphics.clear} +local allocations,updates,canvases,surfaceReleases=0,0,0,0 +local function surface(w,h,format,bytes) + local data={w=w,h=h,bytes=bytes or string.rep("\0",w*h*4)} + function data:release() + T.check(not self.released,"CPU atlas released once") + self.released=true;self.bytes=nil;surfaceReleases=surfaceReleases+1 + end + return data +end +local function image(data) + T.check(not data.released,"image upload receives live CPU pixels") + allocations=allocations+1 + local out={w=data.w,h=data.h,bytes=data.bytes,updates=0} + function out:replacePixels(next) + T.check(not next.released,"palette upload never uses evicted CPU pixels") + T.eq(next.w,self.w,"palette refresh preserves image width") + T.eq(next.h,self.h,"palette refresh preserves image height") + self.bytes=next.bytes;self.updates=self.updates+1;updates=updates+1 + end + function out:setWrap() end + function out:setFilter() end + return out +end +love.image={newImageData=surface} +love.graphics.newImage=image +love.graphics.newCanvas=function(w,h) + canvases=canvases+1 + local c={w=w,h=h,pixels={}} + function c:newImageData() return surface(w,h,"rgba8",table.concat(self.pixels)) end + return c +end +love.graphics.clear=function(r,g,b,a) + local canvas=love.graphics.getCanvas() + if not canvas then return end + local p=string.char(r*255,g*255,b*255,(a or 1)*255) + for i=1,canvas.w*canvas.h do canvas.pixels[i]=p end +end +love.graphics.draw=function(img,quad,ox,oy) + local canvas=love.graphics.getCanvas() + if not canvas then return end + for y=0,quad.h-1 do for x=0,quad.w-1 do + local at=((quad.y+y)*img.w+quad.x+x)*4+1 + local px=img.bytes:sub(at,at+3) + if px:byte(4)==255 then canvas.pixels[(y+oy)*canvas.w+x+ox+1]=px end + end end +end +local function expectedSheet(decoder,record,layer) + local pixels={} + for i=1,record.width*record.height do pixels[i]="\0\0\0\0" end + local function plot(x,y,r,g,b) pixels[y*record.width+x+1]=string.char(r,g,b,255) end + decoder:setAnimFrame(0);decoder:bakeLayer(layer,plot) + for mid,slots in pairs(record.animSlots or {}) do + for frame=2,record.animFrames do + decoder:setAnimFrame(frame-1);decoder:bakeMetatileInto(mid,layer,slots[frame],plot) + end + end + return table.concat(pixels) +end +for _,version in ipairs({"emerald","firered"}) do + TR.releaseGen3Sheets() + local f=F.new(version,2,true) + local s=f.state + local layout=s.game.data.constants.gen3Layout + local split=layout.palettesInPrimary + f.records.P.paletteCycles={ {kind="rotate",first=17,count=4,step=3,direction=1} } + f.records.S.paletteCycles={ {kind="rotate",first=split*16+1,count=4,step=5,direction=-1} } + local originalP,originalS=Copy(f.records.P.palettes),Copy(f.records.S.palettes) + local record=assert(TR.gen3SheetsFor(s.map.tileset,s.game.data,layout)) + local renderer=setmetatable({gen3=record,map=s.map,gen3Key=s.map.tileset.id, + gen3Quads={},bodyTilesW=6,bodyTilesH=4,blockTiles=2}, {__index=TR}) + -- The real sheet column count, not a guessed source-layout dimension. + for id=0,record.slots-1 do + renderer.gen3Quads[id]=love.graphics.newQuad(id%Tiles.SHEET_COLS*16,math.floor(id/Tiles.SHEET_COLS)*16,16,16) + end + s.map.def.border=string.rep("\0",8) + renderer:ensureWindow(0,0,48,32) + renderer:ensureBorderFill() + local bottom,top,cover,border=record.bottom,record.top,record.cover,renderer.borderFill + T.check(cover~=nil,"fixture exercises reflection-cover refresh") + T.check(border~=nil,"fixture exercises border refresh") + local batch=renderer.winBatch + local baseAllocations=allocations + local baseCanvases=canvases + for step=0,12 do + local priorKey=record.tiles.paletteState.contentKey + local priorUpdates=bottom.updates+top.updates+(cover and cover.updates or 0) + renderer:ensureWindow(0,0,48,32) + renderer:ensureBorderFill() + local ticks=TR.animFrame() + local p,q=Copy(f.records.P),Copy(f.records.S) + for c=0,3 do + p.palettes[2][c+2]=Copy(originalP[2][(c-math.floor(ticks/3))%4+2]) + q.palettes[split+1][c+2]=Copy(originalS[split+1][(c+math.floor(ticks/5))%4+2]) + end + local expected=Tiles.new({primary=p,secondary=q},layout) + T.eq(record.bottom.bytes,expectedSheet(expected,record,1),"native bottom atlas uses independently expected palette") + T.eq(record.top.bytes,expectedSheet(expected,record,2),"native top atlas and every tile frame recolored") + if cover then + for mid,quads in pairs(record.coverQuads) do + for frame,quad in ipairs(quads) do + expected:setAnimFrame(frame-1) + local pixels={} + for i=1,256 do pixels[i]="\0\0\0\0" end + expected:drawLayer(mid,2,0,0,function(x,y,r,g,b) pixels[y*16+x+1]=string.char(r,g,b,255) end) + local got={} + for y=0,15 do + local start=((quad.y+y)*cover.w+quad.x)*4+1 + got[#got+1]=cover.bytes:sub(start,start+63) + end + T.eq(table.concat(got),table.concat(pixels),"reflection cover uses current palette in every tile frame") + end + end + end + local borderPixels={} + for y=0,31 do for x=0,31 do + local at=((y%16)*bottom.w+x%16)*4+1 + local a,b=bottom.bytes:sub(at,at+3),top.bytes:sub(at,at+3) + borderPixels[#borderPixels+1]=b:byte(4)==255 and b or (a:byte(4)==255 and a or "\0\0\0\255") + end end + T.eq(border.bytes,table.concat(borderPixels),"map border actually receives current atlas colours") + T.eq(record.bottom,bottom,"native bottom texture retained") + T.eq(record.top,top,"native top texture retained") + T.eq(record.cover,cover,"reflection texture retained") + T.eq(renderer.borderFill,border,"border texture retained") + T.eq(renderer.winBatch,batch,"cached batch survives palette updates") + T.eq(allocations,baseAllocations,"no GPU-image churn during palette cycle") + T.eq(canvases,baseCanvases,"no border canvas churn during palette cycle") + local count=updates + renderer:drawReflectionCover(0,0,0,0) + renderer:syncGen3Palettes() + T.eq(updates,count,"multiple passes in one tick never repaint twice") + if priorKey==record.tiles.paletteState.contentKey then + T.eq(priorUpdates,bottom.updates+top.updates+(cover and cover.updates or 0),"stable picture avoids sheet upload") + end + if step==0 then + local second=setmetatable({gen3=record,map=s.map,gen3Quads=renderer.gen3Quads},{__index=TR}) + second:ensureBorderFill() + local old=second.borderFill + for _=1,5 do TR.tick(1/60) end + renderer:syncGen3Palettes() + local prior=updates + second:ensureBorderFill() + T.eq(second.borderFill,old,"shared pair refresh retains each renderer's border image") + T.check(updates>prior,"second renderer refreshes border even after pair was already sampled") + baseAllocations=allocations + baseCanvases=canvases + else TR.tick(1/60) end + end + T.check(bottom.updates>0 and top.updates>0 and cover.updates>0,"both layers and covers receive changed colours") + T.check(border.updates>0,"palette changes reach map border") + T.same(f.records.P.palettes,originalP,"native cycle never modifies source primary") + T.same(f.records.S.palettes,originalS,"native cycle never modifies source secondary") + -- Invalid/removed metadata returns to base colors instead of leaving a + -- formerly animated palette frozen in the texture. + f.records.P.paletteCycles=nil + TR.tick(1/60);renderer:syncGen3Palettes() + T.eq(record.tiles.paletteState,nil,"incomplete source metadata leaves native base fallback") + local base=Tiles.new({primary=f.records.P,secondary=f.records.S},layout) + T.eq(record.bottom.bytes,expectedSheet(base,record,1),"base fallback actually reaches existing atlas") + local before=updates + TR.tick(1/60);renderer:syncGen3Palettes() + T.eq(updates,before,"invalid metadata does not repeatedly upload unchanged base") + f.run.release() +end +-- Legacy/no-mod source remains pixel-identical and performs no new upload. +TR.releaseGen3Sheets() +local f=F.new("emerald",2) +local rec=assert(TR.gen3SheetsFor(f.state.map.tileset,f.state.game.data,f.state.game.data.constants.gen3Layout)) +local r=setmetatable({gen3=rec},{__index=TR}) +local before=updates +for _=1,20 do TR.tick(1/60);r:syncGen3Palettes() end +T.eq(updates,before,"legacy metadata-free native rendering has no palette uploads") +T.eq(rec.tiles.paletteState,nil,"legacy decoder retains original palettes") +f.run.release() + +-- The repeating four-picture program must stop baking after warmup, while +-- every restored atlas/animation/cover remains pixel-identical to decoding. +TR.releaseGen3Sheets() +f=F.new("emerald",2) +local frames={} +for i=1,4 do frames[i]=string.rep(string.char(i*29,i*43,i*53),4) end +f.records.P.paletteCycles={{kind="frames",first=17,count=4,step=1,frames=frames}} +f.records.S.paletteCycles={} +rec=assert(TR.gen3SheetsFor(f.state.map.tileset,f.state.game.data,f.state.game.data.constants.gen3Layout)) +r=setmetatable({gen3=rec},{__index=TR}) +local bakes=0 +local bake=rec.tiles.bakeLayer +rec.tiles.bakeLayer=function(self,... )bakes=bakes+1;return bake(self,...)end +local originalBottom,originalTop,originalCover=rec.bottom,rec.top,rec.cover +for n=1,40 do + TR.tick(1/60);r:syncGen3Palettes() + local expected=Tiles.new({primary=f.records.P,secondary=f.records.S},f.state.game.data.constants.gen3Layout) + expected:setPaletteTicks(TR.animFrame()) + T.eq(rec.bottom.bytes,expectedSheet(expected,rec,1),"cached bottom equals independent current decode") + T.eq(rec.top.bytes,expectedSheet(expected,rec,2),"cached top/animation slots equal current decode") + if rec.cover then + for mid,quads in pairs(rec.coverQuads) do + for frame,quad in ipairs(quads) do + expected:setAnimFrame(frame-1) + local pixels={};for i=1,256 do pixels[i]="\0\0\0\0" end + expected:drawLayer(mid,2,0,0,function(x,y,rr,gg,bb)pixels[y*16+x+1]=string.char(rr,gg,bb,255)end) + local got={} + for y=0,15 do local at=((quad.y+y)*rec.cover.w+quad.x)*4+1;got[#got+1]=rec.cover.bytes:sub(at,at+63)end + T.eq(table.concat(got),table.concat(pixels),"cached reflection cover equals independent decode") + end + end + end + T.eq(rec.bottom,originalBottom,"cache hit preserves bottom GPU identity") + T.eq(rec.top,originalTop,"cache hit preserves top GPU identity") + T.eq(rec.cover,originalCover,"cache hit preserves reflection GPU identity") +end +T.eq(bakes,6,"four-picture cycle bakes only three missing two-layer pictures") +T.eq(TR.gen3PaletteCacheStats().entries,4,"four-picture cycle retains four pictures") +local warmed=bakes +for _=1,100 do TR.tick(1/60);r:syncGen3Palettes() end +T.eq(bakes,warmed,"many repeated palette cycles perform no full atlas bake") +-- A new colour picture fails its upload: no partial picture enters the cache, +-- temporary CPU pixels are released and the tile-animation selection survives. +local savedUpload=rec.bottom.replacePixels +rec.bottom.replacePixels=function()error("intentional atlas upload failure")end +for i=1,4 do frames[i]=string.rep(string.char(250-i,240-i,230-i),4) end +local releasedBefore=surfaceReleases +local frameBefore=rec.tiles.animFrame +TR.tick(1/60) +T.check(not pcall(r.syncGen3Palettes,r),"upload failure propagates") +T.eq(surfaceReleases,releasedBefore+1,"failed upload releases its temporary surface") +T.eq(rec.tiles.animFrame,frameBefore,"failed bake restores animation selection") +T.eq(TR.gen3PaletteCacheStats().entries,4,"failed picture is never cached") +rec.bottom.replacePixels=savedUpload +releasedBefore=surfaceReleases +TR.releaseGen3Sheets() +T.eq(TR.gen3PaletteCacheStats().bytes,0,"source/cache reset releases all CPU atlas bytes") +T.eq(TR.gen3PaletteCacheStats().entries,0,"source/cache reset drops prior colour pictures") +T.eq(surfaceReleases-releasedBefore,4*(rec.cover and 3 or 2),"reset releases every cached layer once") +local fresh=assert(TR.gen3SheetsFor(f.state.map.tileset,f.state.game.data,f.state.game.data.constants.gen3Layout)) +T.check(fresh~=rec,"source reset creates a new owner even with the same tileset id") +T.eq(TR.gen3PaletteCacheStats().entries,1,"source rebuild primes only its own current picture") +TR.invalidate() +T.eq(TR.gen3PaletteCacheStats().bytes,0,"asset invalidation also releases CPU picture storage") +T.eq(TR.gen3PaletteCacheStats().entries,0,"asset invalidation cannot retain source pictures") +T.check(fresh.bottom.bytes~=nil,"asset invalidation preserves live GPU resources") +f.run.release() +love.image=original.image +for _,name in ipairs({"newImage","newCanvas","draw","clear"}) do love.graphics[name]=original[name] end +T.finish("Native palette sheet, batch, border and reflection refresh") diff --git a/tests/engine/gen3_palette_samples.lua b/tests/engine/gen3_palette_samples.lua new file mode 100644 index 000000000..ce086699c --- /dev/null +++ b/tests/engine/gen3_palette_samples.lua @@ -0,0 +1,83 @@ +package.path="./?.lua;./?/init.lua;"..package.path +local T=require("tests.modkit") +local F=require("tools.native-palettes.fixture") +local Tiles=require("src.render.Gen3Tiles") +local Cycles=require("src.render.Gen3PaletteCycles") +local Copy=require("src.mods.Merge").deepCopy +local raw=Cycles.sample +local samples=0 +Cycles.sample=function(...) samples=samples+1;return raw(...) end +for _,version in ipairs({"emerald","firered"})do + local f=F.new(version,2,true);local layout=f.state.game.data.constants.gen3Layout + local pair={primary=f.records.P,secondary=f.records.S} + pair.primary.paletteCycles={} + local first=layout.palettesInPrimary*16 + local frames={string.char(1,2,3,4,5,6),string.char(6,7,8,9,10,11),string.char(21,22,23,24,25,26),string.char(31,32,33,34,35,36)} + pair.secondary.paletteCycles={{kind="frames",first=first,count=2,step=4,phase=1,frames=frames}} + local original=Copy(pair);local d=Tiles.new(pair,layout) + for tick=0,31 do + d:setPaletteTicks(tick) + T.same(d.paletteState,assert(raw(pair,layout,tick)),"native sample equals independent pure sampler") + end + local count=samples;local cache=d.paletteSampler;local before=cache:stats() + for tick=32,255 do + d:setPaletteTicks(tick) + T.same(d.paletteState,assert(raw(pair,layout,tick)),"warmed sample preserves all RGB bytes and phase indices") + end + T.eq(samples,count,"four repeating states cause zero full samples after warmup") + T.eq(cache:stats().entries,4,"native sample pictures bounded to four") + T.check(cache:stats().hits>before.hits,"warmed states reused") + T.same(pair,original,"shared source tables remain unchanged") + local held=d.paletteState + pair.primary.palettes[16][16][1]=(pair.primary.palettes[16][16][1]+1)%256 + d:setPaletteTicks(256) + T.check(d.paletteState~=held,"same-table RGB edit invalidates retained states") + T.same(d.paletteState,assert(raw(pair,layout,256)),"source edit outside active program also observed") + pair.secondary.paletteCycles[1].frames[2]=string.char(101,102,103,104,105,106) + for tick=257,272 do d:setPaletteTicks(tick);T.same(d.paletteState,assert(raw(pair,layout,tick)),"same-table frame edit invalidates even before frame selected") end + pair.secondary.paletteCycles[1].step=3;pair.secondary.paletteCycles[1].phase=2 + d:setPaletteTicks(273);T.same(d.paletteState,assert(raw(pair,layout,273)),"cadence and phase edits invalidate state selection") + -- Multiple independent programs produce more than four combinations. + pair.primary.paletteCycles={{kind="rotate",first=17,count=4,step=3,direction=-1}} + for tick=0,95 do + d:setPaletteTicks(tick);T.same(d.paletteState,assert(raw(pair,layout,tick)),"bounded eviction preserves independent program phases") + T.check(cache:stats().entries<=4,"phase combinations cannot grow retained picture count") + end + pair.primary.paletteCycles[1].direction=1 + d:setPaletteTicks(96);T.same(d.paletteState,assert(raw(pair,layout,96)),"rotate direction edit observed") + local function reject(edit) + d.primary,d.secondary=Copy(original.primary),Copy(original.secondary) + d:setPaletteTicks(0);edit(d) + local _,reason=d:setPaletteTicks(1) + T.eq(d.paletteState,nil,"invalid source cannot reuse previous colours") + T.eq(reason,"palette animation data unavailable","malformed metadata still declines") + T.eq(cache:stats().entries,0,"invalid source drops retained samples") + end + reject(function(t)t.primary.paletteCycles=nil end) + reject(function(t)t.secondary.paletteCycles.extra=true end) + reject(function(t)t.secondary.paletteCycles[1].frames[3]="short" end) + reject(function(t)t.secondary.paletteCycles[1].frames=setmetatable(Copy(frames),{}) end) + reject(function(t)t.primary.palettes[1][1][2]=256 end) + reject(function(t)t.primary.palettes[1].extra=true end) + reject(function(t)t.secondary=setmetatable(t.secondary,{}) end) + d.primary,d.secondary=Copy(original.primary),Copy(original.secondary);d:setPaletteTicks(0) + for _,tick in ipairs({-1,0/0,math.huge,9007199254740992})do + local _,reason=d:setPaletteTicks(tick) + T.eq(d.paletteState,nil,"invalid clock clears cached output") + T.eq(reason,"terrain clock unavailable","clock rejection preserved") + T.eq(cache:stats().entries,0,"invalid clock drops native pictures") + d:setPaletteTicks(0) + end + d.primary.paletteCycles={};d.secondary.paletteCycles={};d:setPaletteTicks(0) + local still=d.paletteState;d:setPaletteTicks(1) + T.eq(d.paletteState,still,"animated-to-static transition keeps static reuse") + local static=Tiles.new({primary=d.primary,secondary=d.secondary},layout);static:setPaletteTicks(0) + T.eq(static.paletteSampler,nil,"initially static FireRed/legacy terrain gets no sample cache") + d.primary,d.secondary=Copy(original.primary),Copy(original.secondary);d:setPaletteTicks(9007199254740991) + T.same(d.paletteState,assert(raw(d,layout,9007199254740991)),"maximum exact tick phase precision preserved") + local detached=assert(raw(d,layout,9007199254740991));detached.primary[1][1][1]=-1 + T.check(d.paletteState.primary[1][1][1]>=0,"public samples remain detached from native retained state") + f.run.release() +end +Cycles.sample=raw +T.finish("Native animated palette sample reuse, eviction and source invalidation") diff --git a/tests/engine/gen3_static_palette_reuse.lua b/tests/engine/gen3_static_palette_reuse.lua new file mode 100644 index 000000000..da2d72ae6 --- /dev/null +++ b/tests/engine/gen3_static_palette_reuse.lua @@ -0,0 +1,57 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local F = require("tools.native-palettes.fixture") +local Tiles = require("src.render.Gen3Tiles") +local Copy = require("src.mods.Merge").deepCopy +for _, version in ipairs({"emerald", "firered"}) do + local f = F.new(version, 2, true) + local layout = f.state.game.data.constants.gen3Layout + local pair = {primary=f.records.P, secondary=f.records.S} + pair.primary.paletteCycles={};pair.secondary.paletteCycles={} + local decoder = Tiles.new(pair, layout) + T.eq(decoder:setPaletteTicks(0), true, "first static picture installed") + local first, original = decoder.paletteState, Copy(pair) + for tick=1,600 do + T.eq(decoder:setPaletteTicks(tick), false, "static terrain never recolours") + T.eq(decoder.paletteState, first, "static picture retains its owned colour tables") + end + T.eq(first.ticks,600,"static clock still advances") + T.same(pair,original,"sampling does not mutate shared cartridge data") + -- In-place edits, including entries outside the active bank split, must + -- invalidate a picture; identity-only source caching would miss these. + pair.secondary.palettes[1][1][2]=(pair.secondary.palettes[1][1][2]+1)%256 + T.eq(decoder:setPaletteTicks(601),true,"same-table source edit is observed") + T.check(decoder.paletteState~=first,"changed source receives fresh owned picture") + local changed=decoder.paletteState + T.eq(first.secondary[1][1][2],original.secondary.palettes[1][1][2],"old picture remains detached") + pair.primary.paletteCycles={{kind="rotate",first=17,count=4,step=1,direction=1}} + T.eq(decoder:setPaletteTicks(602),true,"static-to-animated transition is sampled") + T.check(#decoder.paletteState.primaryFrames==1,"animation resumes") + pair.primary.paletteCycles={} + T.eq(decoder:setPaletteTicks(603),true,"animated-to-static transition restores base") + local function reject(edit) + pair.primary=Copy(original.primary);pair.secondary=Copy(original.secondary) + decoder.primary,decoder.secondary=pair.primary,pair.secondary + assert(decoder:setPaletteTicks(0)~=nil) + edit(pair) + local _,reason=decoder:setPaletteTicks(1) + T.eq(decoder.paletteState,nil,"invalid source cannot reuse stale colours") + T.eq(reason,"palette animation data unavailable","invalid static source declines") + end + reject(function(p)p.primary.paletteCycles=nil end) + reject(function(p)p.secondary.paletteCycles.extra=true end) + reject(function(p)p.primary.palettes[2].extra=true end) + reject(function(p)p.secondary.palettes[16][16][3]=256 end) + reject(function(p)p.primary.palettes[1][1][1]=0/0 end) + reject(function(p)p.primary.palettes[1][1]=setmetatable({1,2,3},{}) end) + decoder.primary,decoder.secondary=original.primary,original.secondary + decoder:setPaletteTicks(0) + local _,reason=decoder:setPaletteTicks(-1) + T.eq(decoder.paletteState,nil,"invalid clock cannot retain static picture") + T.eq(reason,"terrain clock unavailable","clock rejection preserved") + decoder:setPaletteTicks(0);decoder.palettesInPrimary=0 + decoder:setPaletteTicks(1) + T.eq(decoder.paletteState,nil,"invalid split cannot reuse static picture") + f.run.release() +end +T.finish("Native static palette reuse and mutable-source invalidation") diff --git a/tests/palette_programs/fixture.lua b/tests/palette_programs/fixture.lua new file mode 100644 index 000000000..ea676df13 --- /dev/null +++ b/tests/palette_programs/fixture.lua @@ -0,0 +1,75 @@ +-- Original synthetic GBA data and tiny Thumb leaf initializers. No cartridge +-- code, palette bytes, ROM images or player files are distributed by this test. +local F={} +local Json=require("src.link.Json") +local Rom=require("src.import.RomGba") +local function le(n,count) + local s={};for i=1,count do s[i]=string.char(n%256);n=math.floor(n/256) end + return table.concat(s) +end +function F.new(game) + local file=assert(io.open("tools/rom_manifest_"..game..".json","rb")) + local manifest=Json.decode(file:read("*a"));file:close() + -- This synthetic cartridge contains palette programs, not the independent + -- retail terrain-flag table. Its source-byte/hash contract is tested by + -- gen3_source_flags and the real-cartridge integration host. + manifest.metatileBehaviorFlags=nil + local chunks={} + local out={manifest=manifest,frames={},initializers={},sets={}} + local function put(at,bytes) chunks[at]=bytes end + local function init(at,target,readCounter) + -- LDR literal / MOV immediate / counter halfword store; optional unknown + -- RAM read mirrors initializers that inherit the primary counter. + local h={0x4907,0x2000,0x8008,0x4907,0x2080,0x0080,0x8008, + 0x4906,0x4806,0x6008,0x4770} + if readCounter then h[2]=0x8808 end + local bytes={};for i,w in ipairs(h) do bytes[i]=le(w,2) end + put(at,table.concat(bytes)) + -- Pools reflect each LDR's aligned PC, not its apparent instruction PC. + put(at+32,le(0x03000010,4)) + put(at+36,le(0x03000012,4)) + put(at+40,le(0x03000020,4)) + put(at+44,le(target,4)) + out.initializers[#out.initializers+1]=at + return 0x08000000+at+1 + end + local profile=manifest.terrainPalettePrograms + local keys={};for key in pairs(profile.programs) do keys[#keys+1]=key end + table.sort(keys,function(a,b) return tonumber(a)=position,"overlapping synthetic writes") + pieces[#pieces+1]=string.rep("\0",at-position);pieces[#pieces+1]=chunks[at];position=at+#chunks[at] + end + pieces[#pieces+1]=string.rep("\0",0x530000-position) + out.rom=Rom.new(table.concat(pieces)) + function out.change(at,bytes) + out.rom=Rom.new(out.rom.data:sub(1,at)..bytes..out.rom.data:sub(at+#bytes+1)) + end + out.init=init + return out +end +F.le=le +return F diff --git a/tests/test_gen3_palette_profiles.py b/tests/test_gen3_palette_profiles.py new file mode 100644 index 000000000..b8f39ef38 --- /dev/null +++ b/tests/test_gen3_palette_profiles.py @@ -0,0 +1,43 @@ +import importlib.util +import json +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("palette_profiles", ROOT / "tools/gen3_palette_profiles.py") +PROFILES = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(PROFILES) + + +class PaletteProfiles(unittest.TestCase): + def test_generated_manifest_profiles_match_catalog(self): + for game in ("emerald", "firered"): + manifest = json.loads((ROOT / f"tools/rom_manifest_{game}.json").read_text()) + profile = PROFILES.profile_for_sha1(manifest["romSha1"]) + self.assertEqual(profile, manifest["terrainPalettePrograms"]) + self.assertEqual(profile["game"], game) + self.assertEqual(profile["romSha1"], manifest["romSha1"]) + self.assertIn("github.com/pret/", profile["source"]) + self.assertIn("terrainPalettePrograms", (ROOT / "tools/gen3_discover.py").read_text()) + + def test_exact_hash_and_detached_recipe(self): + sha1 = "f3ae088181bf583e55daf962a92bb46f4f1d07b7" + self.assertIsNone(PROFILES.profile_for_sha1("0" * 40)) + profile = PROFILES.profile_for_sha1(sha1) + profile["programs"].clear() + self.assertEqual(len(PROFILES.profile_for_sha1(sha1)["programs"]), 20) + + def test_pinned_cartridge_program_semantics(self): + emerald = PROFILES.profile_for_sha1("f3ae088181bf583e55daf962a92bb46f4f1d07b7") + firered = PROFILES.profile_for_sha1("41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc") + palette = emerald["programs"][str(0xA1658)] + self.assertEqual((palette["table"], palette["first"], palette["count"], palette["frames"], palette["step"]), + (0x52487C, 128, 16, 4, 4)) + self.assertEqual(sum(p["kind"] == "frames" for p in emerald["programs"].values()), 1) + self.assertEqual(sum(p["kind"] == "static" for p in emerald["programs"].values()), 19) + self.assertEqual(len(firered["programs"]), 6) + self.assertTrue(all(p["kind"] == "static" for p in firered["programs"].values())) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/gen3_discover.py b/tools/gen3_discover.py index b3467fc64..40213a883 100644 --- a/tools/gen3_discover.py +++ b/tools/gen3_discover.py @@ -10,11 +10,15 @@ and then checked against a fact the table itself has to satisfy. Nothing here is trusted because it is "the address everyone quotes". +The separate terrainPalettePrograms field is an explicitly source-pinned +callback profile for an exact full ROM hash, not a structural discovery. + Usage: python3 gen3_discover.py [-o rom_manifest_emerald.json] """ import argparse import collections, hashlib, json, sys +from gen3_palette_profiles import profile_for_sha1 BASE = 0x08000000 NUM_SPECIES = 412 # named species slots, INCLUDING the 25 unused @@ -5447,6 +5451,9 @@ def main(): 'generation': 3, 'version': 'emerald', 'romSha1': sha1, + # This field is deliberately source-pinned and exact-hash gated, + # rather than a structural guess about callback side effects. + 'terrainPalettePrograms': profile_for_sha1(sha1), 'romHeader': {'title': data[0xA0:0xAC].decode('ascii', 'replace'), 'gameCode': code, 'makerCode': data[0xB0:0xB2].decode('ascii'), 'revision': data[0xBC]}, diff --git a/tools/gen3_palette_profiles.py b/tools/gen3_palette_profiles.py new file mode 100644 index 000000000..1f62e0e4a --- /dev/null +++ b/tools/gen3_palette_profiles.py @@ -0,0 +1,14 @@ +"""Curated, source-pinned terrain palette profiles, keyed by exact ROM SHA1. + +Unlike the structural table discovery, these audited callback classifications +are specific to a matching build. No palette bytes or executable code here. +""" +import copy +import json +from pathlib import Path + + +def profile_for_sha1(sha1): + path = Path(__file__).with_name("gen3_palette_program_profiles.json") + profiles = json.loads(path.read_text()) + return copy.deepcopy(profiles.get(sha1)) diff --git a/tools/gen3_palette_program_profiles.json b/tools/gen3_palette_program_profiles.json new file mode 100644 index 000000000..2de4134c8 --- /dev/null +++ b/tools/gen3_palette_program_profiles.json @@ -0,0 +1,155 @@ +{ + "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc": { + "game": "firered", + "programs": { + "459040": { + "id": "general", + "kind": "static", + "secondary": false + }, + "459180": { + "id": "celadon", + "kind": "static", + "secondary": true + }, + "459300": { + "id": "silph", + "kind": "static", + "secondary": true + }, + "459420": { + "id": "ember", + "kind": "static", + "secondary": true + }, + "459524": { + "id": "vermilion-gym", + "kind": "static", + "secondary": true + }, + "459624": { + "id": "celadon-gym", + "kind": "static", + "secondary": true + } + }, + "romSha1": "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc", + "source": "https://github.com/pret/pokefirered/blob/d58bf398b2b83a326d23d14d531de7f45a1058bb/asm/tileset_anims.s", + "version": 1 + }, + "f3ae088181bf583e55daf962a92bb46f4f1d07b7": { + "game": "emerald", + "programs": { + "658288": { + "id": "general", + "kind": "static", + "secondary": false + }, + "658356": { + "id": "building", + "kind": "static", + "secondary": false + }, + "659516": { + "id": "rustboro", + "kind": "static", + "secondary": true + }, + "659640": { + "id": "dewford", + "kind": "static", + "secondary": true + }, + "659664": { + "id": "slateport", + "kind": "static", + "secondary": true + }, + "659688": { + "id": "mauville", + "kind": "static", + "secondary": true + }, + "659804": { + "id": "lavaridge", + "kind": "static", + "secondary": true + }, + "659848": { + "id": "ever-grande", + "kind": "static", + "secondary": true + }, + "659964": { + "id": "pacifidlog", + "kind": "static", + "secondary": true + }, + "660012": { + "id": "sootopolis", + "kind": "static", + "secondary": true + }, + "660036": { + "id": "underwater", + "kind": "static", + "secondary": true + }, + "660064": { + "id": "cave", + "kind": "static", + "secondary": true + }, + "660092": { + "id": "frontier-west", + "kind": "static", + "secondary": true + }, + "660116": { + "id": "frontier-east", + "kind": "static", + "secondary": true + }, + "660928": { + "id": "mauville-gym", + "kind": "static", + "secondary": true + }, + "660952": { + "id": "sootopolis-gym", + "kind": "static", + "secondary": true + }, + "660976": { + "id": "elite-four", + "kind": "static", + "secondary": true + }, + "661020": { + "id": "bike-shop", + "kind": "static", + "secondary": true + }, + "661044": { + "id": "battle-pyramid", + "kind": "static", + "secondary": true + }, + "661080": { + "count": 16, + "excludes": "cartridge fade and battle-transition task state", + "first": 128, + "frames": 4, + "id": "battle-dome-floor", + "kind": "frames", + "phase": 0, + "secondary": true, + "step": 4, + "table": 5392508 + } + }, + "romSha1": "f3ae088181bf583e55daf962a92bb46f4f1d07b7", + "source": "https://github.com/pret/pokeemerald/blob/3ec4d98de95a2aeb12f3c738b3496d0b3a830793/src/tileset_anims.c", + "version": 1 + } +} diff --git a/tools/native-palettes/fixture.lua b/tools/native-palettes/fixture.lua new file mode 100644 index 000000000..3cd984cbf --- /dev/null +++ b/tools/native-palettes/fixture.lua @@ -0,0 +1,60 @@ +-- Generated native data only; no ROM, mod render DTO, player or file cache. +local F={} +local function le(n,size) + local s={};for i=1,size do s[i]=string.char(n%256);n=math.floor(n/256)end + return table.concat(s) +end +function F.bytes(seed,count) + local out={} + for i=0,count*32-1 do + local value=seed+math.floor(i/32)*7 + out[#out+1]=string.char((value+i*3)%16+((value+i*5+1)%16)*16) + end + return table.concat(out) +end +function F.new(version) + local T=require('tests.modkit') + require('src.core.GameVersion').set(version) + local fr=version=='firered' + local split,palSplit=fr and 640 or 512,fr and 7 or 6 + local data={maps={},map_tilesets={},constants={gen3Layout={tilesInPrimary=split, + metatilesInPrimary=split,palettesInPrimary=palSplit,metatileLayers=2}}} + for index,name in ipairs({'P','S','T'})do + local palettes,entries,attrs={},{},{} + for p=0,15 do + palettes[p+1]={} + for c=0,15 do + local word=(c+p)%32+(p*2%32)*32+c*1024 + local r,g,b=require('src.import.RomGba').bgr555(word) + palettes[p+1][c+1]={(r+index*11)%256,(g+index*23)%256,(b+index*37)%256} + end + end + for mid=0,2 do + for k=0,7 do + entries[#entries+1]=le((k%2==0 and 0 or split)+k%2+(k%4)*1024 + +(k%2==0 and 1 or palSplit)*4096,2) + end + attrs[#attrs+1]=le(mid*4096+0x42,2) + end + data.map_tilesets[name]={address=index*0x1000,metatileCount=3, + tiles=string.rep(string.char(0x21),32)..string.rep(string.char(0x43),32), + palettes=palettes,metatiles=table.concat(entries),attributes=table.concat(attrs)} + end + local records=data.map_tilesets + records.P.animations={{tile=0,count=1,step=8, + frames={F.bytes(1,1),F.bytes(3,1),F.bytes(5,1),F.bytes(7,1)}}} + records.S.animations={{tile=0,count=2,step=12, + frames={F.bytes(2,2),F.bytes(4,2),F.bytes(6,2)}}} + records.T.animations={{tile=1,count=1,step=4,frames={F.bytes(9,1),F.bytes(11,1)}}} + local def={id='PALETTE_TEST',width=3,height=2,blocks=''} + for i,mid in ipairs({0,split,1,2,split+1,split+2})do + def.blocks=def.blocks..le(mid+(i%4)*1024+0x6000,2) + end + data.maps[def.id]=def + local map=require('src.world.Map').new(def,{id='palette-test',blockTiles=2, + blockCells=1,primaryKey='P',secondaryKey='S'}) + local game={data=data} + local run=T.sdk.loadNone({data=T.fixtures.fresh()}) + return {state={map=map,game=game},records=records,run=run} +end +return F diff --git a/tools/rom_manifest_emerald.json b/tools/rom_manifest_emerald.json index a790baac4..cdea4f54c 100644 --- a/tools/rom_manifest_emerald.json +++ b/tools/rom_manifest_emerald.json @@ -1,4 +1,119 @@ { + "terrainPalettePrograms": { + "version": 1, + "game": "emerald", + "romSha1": "f3ae088181bf583e55daf962a92bb46f4f1d07b7", + "source": "https://github.com/pret/pokeemerald/blob/3ec4d98de95a2aeb12f3c738b3496d0b3a830793/src/tileset_anims.c", + "programs": { + "658288": { + "id": "general", + "kind": "static", + "secondary": false + }, + "658356": { + "id": "building", + "kind": "static", + "secondary": false + }, + "659516": { + "id": "rustboro", + "kind": "static", + "secondary": true + }, + "659640": { + "id": "dewford", + "kind": "static", + "secondary": true + }, + "659664": { + "id": "slateport", + "kind": "static", + "secondary": true + }, + "659688": { + "id": "mauville", + "kind": "static", + "secondary": true + }, + "659804": { + "id": "lavaridge", + "kind": "static", + "secondary": true + }, + "659848": { + "id": "ever-grande", + "kind": "static", + "secondary": true + }, + "659964": { + "id": "pacifidlog", + "kind": "static", + "secondary": true + }, + "660012": { + "id": "sootopolis", + "kind": "static", + "secondary": true + }, + "660036": { + "id": "underwater", + "kind": "static", + "secondary": true + }, + "660064": { + "id": "cave", + "kind": "static", + "secondary": true + }, + "660092": { + "id": "frontier-west", + "kind": "static", + "secondary": true + }, + "660116": { + "id": "frontier-east", + "kind": "static", + "secondary": true + }, + "660928": { + "id": "mauville-gym", + "kind": "static", + "secondary": true + }, + "660952": { + "id": "sootopolis-gym", + "kind": "static", + "secondary": true + }, + "660976": { + "id": "elite-four", + "kind": "static", + "secondary": true + }, + "661020": { + "id": "bike-shop", + "kind": "static", + "secondary": true + }, + "661044": { + "id": "battle-pyramid", + "kind": "static", + "secondary": true + }, + "661080": { + "id": "battle-dome-floor", + "kind": "frames", + "secondary": true, + "table": 5392508, + "frames": 4, + "first": 128, + "count": 16, + "step": 4, + "phase": 0, + "excludes": "cartridge fade and battle-transition task state" + } + } + }, "bagSprite": { "female": 14260736, "frameHeight": 64, diff --git a/tools/rom_manifest_firered.json b/tools/rom_manifest_firered.json index 5cb450f25..bdc7ba0b9 100644 --- a/tools/rom_manifest_firered.json +++ b/tools/rom_manifest_firered.json @@ -1,4 +1,42 @@ { + "terrainPalettePrograms": { + "version": 1, + "game": "firered", + "romSha1": "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc", + "source": "https://github.com/pret/pokefirered/blob/d58bf398b2b83a326d23d14d531de7f45a1058bb/asm/tileset_anims.s", + "programs": { + "459040": { + "id": "general", + "kind": "static", + "secondary": false + }, + "459180": { + "id": "celadon", + "kind": "static", + "secondary": true + }, + "459300": { + "id": "silph", + "kind": "static", + "secondary": true + }, + "459420": { + "id": "ember", + "kind": "static", + "secondary": true + }, + "459524": { + "id": "vermilion-gym", + "kind": "static", + "secondary": true + }, + "459624": { + "id": "celadon-gym", + "kind": "static", + "secondary": true + } + } + }, "bagSprite": { "female": 15220156, "frameHeight": 64, From 01b6b2480e75e2604c6a07625ed922ed847aa8db Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:34:14 +0100 Subject: [PATCH 048/138] perf(A1): instrument world, presentation and Nitro audio phases --- docs/frame-profile-sections.md | 34 ++++++++++++++++++++++++++ mods/DRAMATIC_SHAPE/lib/VoxelScene.lua | 11 +++++++++ src/audio/NitroAudio.lua | 3 +++ src/render/Renderer.lua | 6 +++++ src/render/TileRenderer.lua | 6 +++++ src/world/OverworldController.lua | 25 +++++++++++++++++++ 6 files changed, 85 insertions(+) create mode 100644 docs/frame-profile-sections.md diff --git a/docs/frame-profile-sections.md b/docs/frame-profile-sections.md new file mode 100644 index 000000000..7814e4ce3 --- /dev/null +++ b/docs/frame-profile-sections.md @@ -0,0 +1,34 @@ +# Frame-profile sections for world, presentation and DS audio + +F11 enables the existing `FrameProfile` report. These additional sections use +its shared no-op closer when profiling is disabled; there is no new setting. +All labels begin with spaces, so nested timings do not get counted again as +top-level frame time. Multiple spans with the same label accumulate. + +| Label | Work measured | +| --- | --- | +| `world: tiles` | Initial map/neighbour draws, declined-pipeline fallback and the flat upper layer | +| `world: entities` | Flat ghosts, main and above-layer entities, or tilted billboards | +| `world: FX` | Flat/tilted effect groups, pipeline `ctx.drawFx` and final map overlays | +| `tiles: ensureWindow` | Window validation/refill, including cached and missing-art returns | +| `renderer: zone blit` | Actual `blitCanvas` calls, including the unshaded fallback | +| `renderer: present pipeline` | Present processing and final native output | +| `voxel: sun pass` | Shadow preparation, including cache-hit/rate-limit decisions | +| `voxel: terrain` | Main and neighbouring terrain submission, per eye | +| `voxel: water` | Water draw-list construction, planar reflection and water submission, per eye | +| `voxel: cards` | Main character and staged-battle card submission, per eye | +| `audio: Nitro render` | One DS mixer block, including SoundData creation | + +These are wall-clock submission timings, not GPU execution times. Reflection +copies of cards contribute to the water section; the cards section measures +the visible scene's card pass. The world labels describe selected drawing +phases rather than an exhaustive partition of `drawWorld`; setup, sorting and +other work remain visible in the enclosing section. Voxel timings run inside +the selected world pipeline. Hook names, arguments and dispatch order are +unchanged. An exception still takes its existing cleanup/error path; an +unfinished span is discarded by the existing profiler contract. + +A1 adds observability and makes no performance claim. Desktop before/after +frame timings and ARM/device timings: **not measured**. Capture the same scene, +viewport, camera, mod configuration and profile window when measuring later +optimizations. Profiling itself is disabled by F11 / `FrameProfile.setEnabled(false)`. diff --git a/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua b/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua index 888e4e994..dc5a0786c 100644 --- a/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua +++ b/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua @@ -14,6 +14,7 @@ local V = ... local Mat4 = V.require("Mat4") local Perf = V.require("Perf") +local FrameProfile = require("src.core.FrameProfile") -- The PERFORMANCE tier's ceilings (lib/Tier.lua). Top-level here rather -- than lazily as in ShadowMap/Water/AntiAlias: Tier requires nothing from -- this mod, so there is no load order to protect, and the sun's rate @@ -2669,8 +2670,10 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- reaches far north and barely south, which is right for every rung -- but a head free to face south. local shCx, shCy = FirstPerson.shadowCenter(cx, cy, vh) + local closeSun = FrameProfile.section(" voxel: sun pass") castShadows(state, terrain, nbMesh, posed, shCx, shCy, vw, vh, atlasFor, water, nbWater, battleCards, battleToken) + closeSun() -- Everything between beginScene and endScene, as one function: the flat -- path runs it once, a VR frame runs it once PER EYE -- same posed @@ -2678,12 +2681,15 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- about anything but their viewpoint. local function drawScene() + local closeTerrain = FrameProfile.section(" voxel: terrain") Voxel3D.draw(terrain, atlasFor(state.map), nil) for i, nb in ipairs(state.neighbors or {}) do Voxel3D.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy)) end + closeTerrain() + -- Without a shadow map (headless, or a driver that could not make the -- canvas) the old flat decals stand in: ground-only, characters only, -- but better than a world with nothing under anybody. They go down @@ -2768,6 +2774,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- lake would otherwise wear one as a black smear. Water covers them, -- which is the same answer the shadow map's own pass gives (see -- ShadowMap.sprites) -- people do not shadow water either way. + local closeWater = FrameProfile.section(" voxel: water") local waterDraws = {} if water then waterDraws[#waterDraws + 1] = { water, atlasFor(state.map), nil } @@ -2803,6 +2810,8 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) planarTex, planarY) end + closeWater() + -- AND THE RIPPLE RINGS ON TOP OF IT, BEFORE ANYBODY IS STANDING IN IT. -- -- THIS LINE'S POSITION IS THE WHOLE FIX. Reported from play: "the ripples @@ -2864,10 +2873,12 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- drawEntity resolves the lean-over-the-wall-in-front case, and a -- character genuinely behind a building is far deeper and loses the -- test, so buildings and trees really occlude. + local closeCards = FrameProfile.section(" voxel: cards") drawCast(state, posed, atlasFor) -- and the staged fight's mons over them, exactly as they were painted -- into the water's reflection copy a few lines up (see drawBattleCards) drawBattleCards() + closeCards() -- tall grass last, pulled camera-ward exactly as far as the characters -- were (same per-vertex shader bias, so grass never drifts either): -- relative depth between a walker and the tuft row south of their feet diff --git a/src/audio/NitroAudio.lua b/src/audio/NitroAudio.lua index c27fff829..81b0caf87 100644 --- a/src/audio/NitroAudio.lua +++ b/src/audio/NitroAudio.lua @@ -1,5 +1,6 @@ -- Nintendo DS SSEQ transport over the ROM's SBNK/SWAR instruments. -- PCM/ADPCM mixing is local; no replacement MIDI soundfont is used. +local FrameProfile = require("src.core.FrameProfile") local Sdat=require('src.import.Gen4Sdat') local Audio={};local Player={};Player.__index=Player local archives,players={},setmetatable({}, {__mode="k"}) @@ -194,6 +195,7 @@ function Player:tick() for _,v in ipairs(self.voices) do v.ticks=v.ticks-1;if v.ticks<=0 and v.release==0 then v.release=math.floor(RATE*0.04) end end end function Player:render() + local closeRender=FrameProfile.section(" audio: Nitro render") local sound=love.sound.newSoundData(BLOCK,RATE,16,2) for i=0,BLOCK-1 do self.tickPhase=self.tickPhase+self.tempo*48/60/RATE @@ -215,6 +217,7 @@ function Player:render() sound:setSample(i,1,math.max(-1,math.min(1,left))) sound:setSample(i,2,math.max(-1,math.min(1,right))) end + closeRender() return sound end function Player:update() diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 7cff87285..07fafd583 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -6,6 +6,7 @@ -- neighbor filtering. -- Spec: docs/new-features.md (survey zoom) +local FrameProfile = require("src.core.FrameProfile") local Zoom = require("src.render.Zoom") local Tilt = require("src.render.Tilt") local PaletteFX = require("src.render.PaletteFX") @@ -686,11 +687,13 @@ end -- render.compose mod can composite the world/UI canvases into its own layout. function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY) + local closeBlit = FrameProfile.section(" renderer: zone blit") local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil if not shader then love.graphics.setScissor(boxX, boxY, boxW, boxH) love.graphics.draw(canvas, bx, by, 0, sx, sy) love.graphics.setScissor() + closeBlit() return end love.graphics.setShader(shader) @@ -713,6 +716,7 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, end love.graphics.setScissor() love.graphics.setShader() + closeBlit() end -- THE BARS, FILLED WITH THE EDGE THEY FRAME. @@ -1393,6 +1397,7 @@ function Renderer:endFrame(zones, worldZones) end if present then + local closePresent = FrameProfile.section(" renderer: present pipeline") love.graphics.setCanvas() -- Post-process pipelines run over the finished composite -- world, UI -- and all -- and before GBC FX, so a blur or colour grade is what the @@ -1410,6 +1415,7 @@ function Renderer:endFrame(zones, worldZones) love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(composed, 0, 0) end + closePresent() end self.worldActive = false self.uprightActive = false diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index b82fc4cfb..c55510fc7 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -8,6 +8,7 @@ -- a single static SpriteBatch covering the map plus a border-block ring -- (the ring plays the role of the GB border blocks around small maps). +local FrameProfile = require("src.core.FrameProfile") local Assets = require("src.render.Assets") local Logger = require("src.core.Logger") local Probe = require("src.core.Probe") @@ -1793,6 +1794,7 @@ function TileRenderer:setWin(tx0, ty0, tx1, ty1) end function TileRenderer:ensureWindow(camX, camY, vw, vh) + local closeWindow = FrameProfile.section(" tiles: ensureWindow") local W, H = self.bodyTilesW, self.bodyTilesH vw = vw or W * 8 -- a nil view (headless draw) means the whole body vh = vh or H * 8 @@ -1844,6 +1846,7 @@ function TileRenderer:ensureWindow(camX, camY, vw, vh) end if win and tx0 >= win.tx0 and ty0 >= win.ty0 and tx1 <= win.tx1 and ty1 <= win.ty1 then + closeWindow() return -- still inside the last fill end -- refill with margin so the next few scrolled pixels stay covered @@ -1920,6 +1923,7 @@ function TileRenderer:ensureWindow(camX, camY, vw, vh) cells, drew, W, H, tostring(self.gen3.slots)) end self:setWin(tx0, ty0, tx1, ty1) + closeWindow() return end @@ -1928,6 +1932,7 @@ function TileRenderer:ensureWindow(camX, camY, vw, vh) -- player, and leave the reason in the log (gen3SheetsFor already logged it). if not self.image then self:setWin(tx0, ty0, tx1, ty1) + closeWindow() return end if not self.winBatch then @@ -1970,6 +1975,7 @@ function TileRenderer:ensureWindow(camX, camY, vw, vh) end end self:setWin(tx0, ty0, tx1, ty1) + closeWindow() end -- draw the static tile window, then its animated overdraw, at the camera offset diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 05f6dc9d0..5d5d65ae8 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -9,6 +9,7 @@ -- surfing, Cut trees, trainer sight lines, and dispatches interactions to -- map scripts (data/scripts/), marts, nurses or extracted text. +local FrameProfile = require("src.core.FrameProfile") local Assets = require("src.render.Assets") local Camera = require("src.render.Camera") local Collision = require("src.world.Collision") @@ -16678,6 +16679,7 @@ function OverworldState:drawWorld() -- path below on the vanilla flat/tilt draw local override if not pipelineId then + local closeTiles = FrameProfile.section(" world: tiles") self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh) self.map.renderer:draw(cam.x, bgY, vw, vh) for _, nb in ipairs(self.neighbors) do @@ -16687,6 +16689,7 @@ function OverworldState:drawWorld() if self.g3MirageTower then require("src.world.Gen3MirageTower").drawTower(self, cam) end + closeTiles() end -- per-billboard SGB palette source; only needed (and only paid for) when -- tilting. nil headless / on stale palettes -> billboards go uncolorized. @@ -17401,6 +17404,7 @@ function OverworldState:drawWorld() -- Deliberately unscaled by depth, like :billboard: an effect keeps its -- crisp authored size and only its anchor moves. ctx.drawFx = function(project, scale) + local closeFx = FrameProfile.section(" world: FX") scale = scale or pscale local colors = ctx.spriteColors() local function at(drawFn, wx, wy) @@ -17471,6 +17475,7 @@ function OverworldState:drawWorld() if pt.active then radarOne = pt; at(fxRadarOne, pt.x * 16 + 8, pt.y * 16 + 16) end end radarOne = nil + closeFx() end override = Pipelines.drawWorld(pipelineId, ctx) -- world post-processes (a miniature-diorama blur, a colour grade) fold @@ -17484,11 +17489,13 @@ function OverworldState:drawWorld() -- was retired). The ground pass was skipped on its behalf above, so -- draw it now and fall through to the flat path below rather than -- compositing an empty canvas. + local closeTiles = FrameProfile.section(" world: tiles") self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh) self.map.renderer:draw(cam.x, bgY, vw, vh) for _, nb in ipairs(self.neighbors) do nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh) end + closeTiles() end end @@ -17581,6 +17588,7 @@ function OverworldState:drawWorld() -- The COLLISION GRID IS UNTOUCHED by any of this. It is in map pixels and -- stays there; only the picture is projected, which is why a compressed -- world still walks the same. + local closeGhosts = FrameProfile.section(" world: entities") for _, g in ipairs(self.ghosts) do -- A GHOST GOES THROUGH THE FREE CAMERA TOO. -- @@ -17606,6 +17614,7 @@ function OverworldState:drawWorld() end if not drawn then g.npc:draw(cam.x - g.ox, cam.y - g.oy) end end + closeGhosts() -- HIDDEN MEANS HIDDEN, for the player as much as for anybody. -- -- Two Gen 3 things write this and neither had a reader: the @@ -17909,6 +17918,7 @@ function OverworldState:drawWorld() and groundForRise:beginSprites() then spriteDepthGround = groundForRise end + local closeEntities = FrameProfile.section(" world: entities") for _, e in ipairs(self.entities) do if self:gen3AboveTopLayer(e) then onTop = onTop or {} @@ -17925,6 +17935,7 @@ function OverworldState:drawWorld() spriteDepthGround:endSprites() spriteDepthGround = nil end + closeEntities() -- The Gen 3 top layer goes on AFTER the entity pass and before the -- field effects: it is the half of every metatile the player walks -- behind -- treetops, upper storeys, the far rail of a bridge. On a @@ -17956,6 +17967,7 @@ function OverworldState:drawWorld() self:eachEmote(fxEmote) emoteEarly = true end + local closeTiles = FrameProfile.section(" world: tiles") aim(self.map.renderer, 0, 0) self.map.renderer:drawAbove(cam.x, bgY, vw, vh) aim(self.map.renderer) @@ -17966,8 +17978,12 @@ function OverworldState:drawWorld() aim(nb.map.renderer) end end + closeTiles() + closeEntities = FrameProfile.section(" world: entities") for _, e in ipairs(onTop or {}) do drawEntity(e) end + closeEntities() + local closeFx = FrameProfile.section(" world: FX") fxHeal() fxDust() fxCutTree() @@ -17988,6 +18004,7 @@ function OverworldState:drawWorld() fxRod() -- Smoke keeps the template's priority 0, above the ship and map layers. fxSSAnneSmoke() + closeFx() else -- === TILT PATH: ground-hugging FX stay on the projected ground, all -- standing things billboard upright over it in a separate pass. ====== @@ -17997,6 +18014,7 @@ function OverworldState:drawWorld() -- draws them last, over the sprites, in the same canvas; here the two -- layers are separate and composited ground-under-upright, so drawing -- them now into the still-active ground canvas is order-equivalent. + local closeFx = FrameProfile.section(" world: FX") fxHeal() fxDust() fxCutTree() @@ -18004,6 +18022,7 @@ function OverworldState:drawWorld() -- ...and the gates with them: they are behind every upright sprite (see -- drawGen3Gates), so the ground canvas is exactly where they belong self:drawGen3Gates(cam) + closeFx() Game.renderer:beginUprightPass() @@ -18024,6 +18043,7 @@ function OverworldState:drawWorld() end table.sort(items, function(a, b) return a.y < b.y end) + local closeEntities = FrameProfile.section(" world: entities") for _, it in ipairs(items) do if it.kind == "ghost" then -- ghosts billboard just like real entities (foot offset folds in the @@ -18059,6 +18079,8 @@ function OverworldState:drawWorld() end end + closeEntities() + closeFx = FrameProfile.section(" world: FX") -- Standing world FX: each billboards at the ground foot of the -- character it belongs to, so it stays upright over the tilted ground. -- emote bubble -> the spotting NPC's foot (rides above its head) @@ -18100,6 +18122,7 @@ function OverworldState:drawWorld() radarOne = nil Game.renderer:endUprightPass() + closeFx() end -- LAST IN THE WORLD PASS, over everything the cave contains: the black @@ -18107,6 +18130,7 @@ function OverworldState:drawWorld() -- drawUI because it is anchored to the PLAYER, not to the screen -- the -- cartridge can put it at a fixed (120,80) because its camera never lets -- the player leave the middle, and this port's does at a map edge. + local closeFx = FrameProfile.section(" world: FX") self:drawGen3Flash(cam, vw, vh) -- the Mirage Tower's falling fossil and ceiling crumbs: OAM priority 0 if self.g3MirageTower or self.g3MirageCrumbles then @@ -18118,6 +18142,7 @@ function OverworldState:drawWorld() -- in the world pass rather than the UI pass for the same reason the flash -- is: it is anchored to a CELL, not to the screen. self:drawGen3Decorate(cam) + closeFx() end -- The cursor the DECORATE row hands the player: the decoration's own From 9206da3e5e108f8c5cb276b5fed40fe18bfcf50d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:46:17 +0100 Subject: [PATCH 049/138] test(A2): authenticate external mod fixtures and run ROM-free gallery acceptance --- .github/workflows/api3-contract.yml | 8 +- .gitignore | 6 + tests/engine/example_gallery_acceptance.lua | 2 + tests/fixtures/upstream_mods/README.md | 38 ++++ tests/fixtures/upstream_mods/data.lua | 31 +++ tests/fixtures/upstream_mods/index.json | 239 ++++++++++++++++++++ tests/fixtures/upstream_mods/worker.lua | 71 ++++++ tests/mod_examples_tests.lua | 38 +--- tools/api3-contract-check/check.py | 10 +- tools/check_external_mod_fixtures.py | 68 ++++++ 10 files changed, 479 insertions(+), 32 deletions(-) create mode 100644 tests/engine/example_gallery_acceptance.lua create mode 100644 tests/fixtures/upstream_mods/README.md create mode 100644 tests/fixtures/upstream_mods/data.lua create mode 100644 tests/fixtures/upstream_mods/index.json create mode 100644 tests/fixtures/upstream_mods/worker.lua create mode 100644 tools/check_external_mod_fixtures.py diff --git a/.github/workflows/api3-contract.yml b/.github/workflows/api3-contract.yml index fe5b47952..4da038a33 100644 --- a/.github/workflows/api3-contract.yml +++ b/.github/workflows/api3-contract.yml @@ -23,6 +23,10 @@ concurrency: jobs: compare: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + reference: [79ea62f45fb2ea8f65d081a109431e4eb1bbdb36, 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91] steps: - uses: actions/checkout@v7 with: @@ -35,7 +39,7 @@ jobs: - uses: actions/checkout@v7 with: repository: bryanthaboi/gen1recomp - ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + ref: ${{ matrix.reference }} path: gen1-reference sparse-checkout: | src @@ -51,6 +55,6 @@ jobs: --report "${{ runner.temp }}/api3-report.json" - uses: actions/upload-artifact@v7 with: - name: api3-contract-report + name: api3-contract-report-${{ matrix.reference }} path: ${{ runner.temp }}/api3-report.json if-no-files-found: error diff --git a/.gitignore b/.gitignore index fe97850f7..1bad86604 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,9 @@ mobile/ios/bundle_id.local # secrets as public -- but GitHub's secret scanning flags it, so it is kept out # of the repository and injected into release builds instead. /cloud_clients.lua + +# Owned compatibility fixtures; external source remains outside this checkout. +!/tests/fixtures/ +/tests/fixtures/* +!/tests/fixtures/upstream_mods/ +!/tests/engine/ diff --git a/tests/engine/example_gallery_acceptance.lua b/tests/engine/example_gallery_acceptance.lua new file mode 100644 index 000000000..85c280cf8 --- /dev/null +++ b/tests/engine/example_gallery_acceptance.lua @@ -0,0 +1,2 @@ +-- The gallery's actual load/hook assertions must run in the ROM-free engine tier. +dofile('tests/mod_examples_tests.lua') diff --git a/tests/fixtures/upstream_mods/README.md b/tests/fixtures/upstream_mods/README.md new file mode 100644 index 000000000..0ed563a46 --- /dev/null +++ b/tests/fixtures/upstream_mods/README.md @@ -0,0 +1,38 @@ +# External compatibility fixtures + +This directory contains an owned synthetic dataset, a loader probe, public +callback names and SHA-256 digests. It contains no reference implementation or +package source. The probe reads unchanged packages from a caller-supplied, +read-only checkout at the commit in `index.json`. + +Run from the repository root: + +``` +python3 tools/check_external_mod_fixtures.py --reference /absolute/reference \ + --lua /absolute/luajit --report .local/qa/mod-fixtures/report.json +``` + +The optional local Python runner requires the QA storage guard and its companion +modules. Supply their directory with `--qa-tools` when they are maintained outside +the checkout. It never falls back to unguarded local execution. + +The command authenticates every file before loading it. It also checks that the +eight shipped gallery packages match the external package bytes. Each package +gets an isolated worker running the production Loader, manifest validation, +registry merge and callback registration. The synthetic pack filesystem and CPU +image objects exercise entrypoints without providing cartridge art or claiming +rendered-image correctness. Writes stay in the worker's memory. + +A missing package, changed file, unknown hook, unloaded entrypoint or worker +failure makes the gate fail. The initial probe deliberately fails on +`mystery_dungeon_follower` at `src.import.pmd.Sprite`; retain that failed report as +the negative control. This is an executable compatibility gap, not a skipped or +successful test. After aliases are available, the requested intermediate +acceptance may name unsupported modules explicitly; complete acceptance requires +all entrypoints to load. ROM extraction is outside this gate. + +The gallery test additionally uses the synthetic dataset whenever no imported +cache is present, so `tests/mod_examples_tests.lua` runs its original effect and +hook assertions in a ROM-free checkout. It validates the legacy API-1 manifest +separately from the retired production example path. No compatibility fixtures +are placed under `mods/`. diff --git a/tests/fixtures/upstream_mods/data.lua b/tests/fixtures/upstream_mods/data.lua new file mode 100644 index 000000000..db0b5d609 --- /dev/null +++ b/tests/fixtures/upstream_mods/data.lua @@ -0,0 +1,31 @@ +-- Synthetic data for exercising loader contracts without an imported cartridge. +-- Identifiers match consumer queries; records and art are test-owned stand-ins. +local M = {} +local function copy(value) + if type(value) ~= 'table' then return value end + local out = {}; for k,v in pairs(value) do out[k]=copy(v) end; return out +end +function M.fresh() + local data=require('tests.modkit.fixtures').fresh() + local seed=data.pokemon.FIXMON_A + for index,id in ipairs({'MEW','VENUSAUR','CHARIZARD','BLASTOISE','PIDGEY','RATTATA','SPEAROW','CHARMANDER'}) do + local mon=copy(seed);mon.id=id;mon.name=id;mon.index=index;mon.dex=index + data.pokemon[id]=mon + end + data.pokemon.MEW.dex=151 + for _,id in ipairs({'TACKLE','GROWL','QUICK_ATTACK','CUT','SURF'}) do + data.moves[id]=copy(data.moves.FIX_TACKLE);data.moves[id].id=id + end + data.items.POTION={id='POTION',name='Potion',price=300,tossable=true} + data.items.TM_TOXIC={id='TM_TOXIC',name='TM test',price=4000,machine={kind='TM',move='TACKLE'}} + data.tilesets.OVERWORLD=copy(data.tilesets.FIX_OUT) + for _,id in ipairs({'PALLET_TOWN','OAKS_LAB','ROUTE_1','VIRIDIAN_CITY','PEWTER_CITY'}) do + local map=copy(data.maps.FIX_TOWN);map.id=id;map.tileset='OVERWORLD';map.objects={};map.warps={};map.connections={} + data.maps[id]=map + end + data.encounters.ROUTE_1={grass={rate=10,slots={{level=3,species='PIDGEY'}}}} + data.sprites.SPRITE_RED=copy(data.sprites.SPRITE_FIX_PLAYER) + data.sprites.SPRITE_RED_BIKE=copy(data.sprites.SPRITE_RED) + return data +end +return M diff --git a/tests/fixtures/upstream_mods/index.json b/tests/fixtures/upstream_mods/index.json new file mode 100644 index 000000000..e36106415 --- /dev/null +++ b/tests/fixtures/upstream_mods/index.json @@ -0,0 +1,239 @@ +{ + "reference": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "fixtures": [ + { + "path": "example_mew_starter", + "files": { + "README.md": "7f2a710b80723aa71c1040d3c00bd2e5d153c0e8d75edcfe088f71da0b8a35de", + "assets/mew_back_inverted.png": "e860dc4ba66efdc3f8d8d1b8fe5c17cb008fdcb57fbd435f15c0e0a2d7b93ec8", + "assets/mew_back_inverted_64.png": "924f6f50eabab8cc2b405ae318b6b9beb033568c461c4bad82692467001201c3", + "assets/mew_front_inverted.png": "a785e64760bdcf790bf1fc6005524ed3352b9ced922275b68ce4a8944cb00e71", + "assets/mew_front_inverted_64.png": "4dedded623ade2ed07c3d9de3fb34d6b9456fc3a56aaeaf812afd55a877240a9", + "main.lua": "d3cda8d3f402e8b36d0546267d0008e071e40f9112847125957898dc85c198b4", + "manifest.json": "835590b79e2bb31fc3666f5e27469b67c7313f83037f284524a7f3f52b3dea0f", + "mod.card": "c342c8e59fb75e52091e7a19ed346c7048672c46414d61e34d828660806f7372" + } + }, + { + "path": "example_lttp_overhaul", + "files": { + "main.lua": "c206fc886986a1ed8daf47a4c1f1ca01cb86ece7224dc5d1601ea84a8f14e1aa", + "manifest.json": "5d43ad9518455facebb3ec9c9230bd856cc9a6cb072a13c531306113bbb9cc69", + "town.lua": "a7688fce525725a65bd4cfccb545972ad87b29e4bc24608eee738a3496a165fb" + } + }, + { + "path": "mystery_dungeon_follower", + "files": { + "README.md": "5e384571580ec21aecdc334d35671bbd45ae1ef77d7d47393d73c21a86343b4f", + "main.lua": "2009b76ba5bcfb7c459adc6de93ecc5a265216c0a45f49174cf4203bb55e65db", + "manifest.json": "15f24259f6f7ce963a34ccdc57c5f89b53ec685b4b9d3a95e7d45c2b5cc12f85" + } + }, + { + "path": "examples/portable_api", + "files": { + "main.lua": "d7920469c0fc6e7908cd0aca84e93e2cc7187df75702d775fb8a86034764a8e8", + "manifest.json": "c054ede45667d98e47a5f1a311271089c291b08e17ac3a847db2653675da9550" + } + }, + { + "path": "examples/gen5_battle_sprites", + "files": { + ".modkitignore": "08c5cc7829fa520a00e4a997e93e17ab886ea5a208b7d29087f6432af372ae81", + "CHANGELOG.md": "1f27fd3d6e9c713a9fe39f48b7c52955b5f1f0e17852ba314be769ae2734c84c", + "README.md": "c8cd82a339c7868401ca72a31cd73dc62ecb41f4cdc6f57870374e29545da4a9", + "main.lua": "1ef5a2d247ad080a424848da25e2049e5734ae3906f2c4aa600dc5e4ae840480", + "manifest.json": "ba05e72bc933548503a9ee4f6518239a467a6adb4843589365851db8da310e1c", + "mod.card": "bfafadaae24328f0f3aa4698377e0901160857d4c6e675f4348c4b893cc9ab6f", + "tests/provider_test.lua": "93a253b06437673b8e3b4d44e4acfd6956c310cdba4030eac10facd10a0cb608" + } + }, + { + "path": "examples/example_balance_tweaks", + "files": { + "CHANGELOG.md": "c2bf004d49a2430aa4a1b2a2cb5775f4e0f5095c028b6873db11d07837a454a1", + "README.md": "2f08f7cd51a4f5ef07a2a7fa94dc6e487d776eca374260fba47cb82b9e6ce2a7", + "main.lua": "7965513d3b57938f048469f6335b62fdfd799662902813517a51d255eb8dfda1", + "manifest.json": "9950d8fdaf8322aa16cefad086ef37f6983a8d81ab555f90fe63664ade20c7bb", + "mod.card": "d4fed4416a71281e7ed8b7ddf4a27122c83a8e63d9cf11827afecbd8ab56df70", + "tests/example_balance_tweaks_test.lua": "3859fe203dc1774c4bff7e928edb604567a8707eb4336ddad629335737dcd1fe" + } + }, + { + "path": "examples/example_dexnav", + "files": { + "CHANGELOG.md": "6ceadfb09065d86e07426d71dfb03e124b463272ba42ad364eed490774e9b843", + "README.md": "910bcca17506755696a21fb72c807a2b113a53e259332f1b9968eb48cbea3344", + "main.lua": "1ba6989142555077a65c6083f5cb691c2b350e4df3721dc710972a32b96290cb", + "manifest.json": "eb0f394097f5f767778521a02378557c283fbb678b932319ea1bb53fddde72df", + "mod.card": "d749d034ec5397b124bc96f7ec6e4a051dda49f03faf5fcad868aa73a97019fb", + "tests/example_dexnav_test.lua": "3f6cf884fcd27ce793153a30409d238fc60f2e6ffdefc5cfd4de6631cc6ca643" + } + }, + { + "path": "examples/example_jukebox", + "files": { + "CHANGELOG.md": "c4e3f09e38ba72888140ee0a27b94453dfa138ab62f40026dbf9c42ceb0a6af3", + "README.md": "417a37a730f80d05b9948b22cb1d842e1e3ada4475fd32be98f7022ef2fa0307", + "main.lua": "d2f153f3aee094d154c6f223cb1c6efaf458de5b3714c642953868537c3711e6", + "manifest.json": "3691e9b41f51733c8449854187285fbc0aa94e836e01739858485b3181243d6c", + "mod.card": "3260bc14e18049ea0a1a4c299e1d80690a02bda8c0b1a292a591a39e82a8772f", + "song.lua": "9ace700345831a45f6a69f158ec21e518923a81cf533ddfca1a39c608e2085dc", + "tests/example_jukebox_test.lua": "e391cc36ab539ea19678f4a6a6d9296bcaf0c2d5de9c33e7bdaccc74a9fb1468" + } + }, + { + "path": "examples/example_lost_parcel", + "files": { + "CHANGELOG.md": "874c37283a2966bf7cc8c84d188c3236923a3c9be1e34405804d5d4bed4e77e3", + "README.md": "03d5a96346f2eb0d4abf7d089993b13d072aedf808bf8efe2dd9c7e699f1c1ba", + "main.lua": "c12780da1f628393beffe164e8fd5b936c3097153a245a9b77b8a146b2d40b72", + "manifest.json": "fbce2a37050a22b8eba63c9b4d2b83ca858a2a3647181f3dc89776bcdee0e77b", + "mod.card": "de071623e46b7399dfc2b4806914800e98c9c87d1cb0e6fb8ec16d16b4268873", + "tests/example_lost_parcel_test.lua": "830c1b568ddb8fc8936c4ddccae0dbe570e84f132394c08bcec922520d627204" + } + }, + { + "path": "examples/example_mini_conversion", + "files": { + "CHANGELOG.md": "5b2859552307c79a6c2c48a77adfa8428894fcc18c2a6d2028e39edad7aeb384", + "README.md": "b72557832443bf0e6a046c4a0f5e09b710051db46f88137ca8e5632dbfb916aa", + "assets/emberkit_back.png": "c43583aa7236ba089a584a17a823ee118d026168a2f924c8a1cfa35bb3156f2e", + "assets/emberkit_front.png": "615300dd5ae8adedffe0d291599d8f2e563cb4a618503590fd1ac7d8ed3b02a7", + "assets/emberkit_icon.png": "de25a5bc5d21c69a11c60302acb1ba80442024e2f524343d9921de6dc3d848ea", + "assets/mossling_back.png": "2c7318680d922f7d4e6faed28c7ada90d0fcc85b99959d4881c0e740d82b1c25", + "assets/mossling_front.png": "dc345925977760209798525e2803f8b8e026001e570b7eeb84ff1e30f55763ec", + "assets/mossling_icon.png": "4dce52410e5b363860c0cbdb89b551716a8edb27a3d884f57c63b6fdfe75ca4c", + "assets/tidepup_back.png": "62f9285c9140cfd4860edc9ac62c4def3a665b1ef71e0b798c7b300245995ca7", + "assets/tidepup_front.png": "d40b73febd54792fab4786ead1647d925e0d9485ccfab8264de0ba0119dc733c", + "assets/tidepup_icon.png": "c9f5fc63e6d6e1e44456e2fa3269baa91f7a6e5c4a7d6707fd58362fc44ff800", + "main.lua": "a6422be98ef0bd836256492f3db56f43320097b6112e843362b203a030f68e04", + "manifest.json": "db5243b57e3a644457499022e70b77f3812f01b9336fd81b8f742dce11d1dcfb", + "mod.card": "f78ab29616d631ea8035c5a755c48794b89443df427be418c1743955e6e966ef", + "tests/example_mini_conversion_test.lua": "141cca215c97b2dcc8daedcf5bc11b5e1ae0d5d5ce3e821b467441724c649b54", + "tools/make_assets.py": "e9ee0d9ce949ee52b2b141e2ac2c82a48f45247a2003b56672f8cd223ffc16ec" + } + }, + { + "path": "examples/example_shiny_palette", + "files": { + "CHANGELOG.md": "bda0e3903af2e0cc65fa6866091da6c6f8625a4a8e42606f9e0f9f11a16d3461", + "README.md": "46bfda385d398fe3f81e655a92686913b1e54f43c820d2929960c3ab2844d650", + "assets/accent_sparkle.png": "bfbc34694108fa6501cc1b1bba1ddcacc5010d3d752290ae473fa33a888a23a2", + "main.lua": "9071e23785aa0c9fcd2fc87df303958573689bf4fa1be939e29b610437549a0e", + "manifest.json": "263f8626e8a875f502251d71edb3908fb8f6d8d833d6257e2c2c7fb66056ad9e", + "mod.card": "5b40433baaf4a5ba82b855f067f8e565ee75a3a9e67879973bc5a94dd97dec60", + "tests/example_shiny_palette_test.lua": "ffba892ce7390f8ec99fdb84fac3e31b797251dee86582933b2d03868b90944c", + "transforms.lua": "66bb07464003969402abe5c0b6fdfe43b20a93f578e06f48b96de75b2f2594eb" + } + }, + { + "path": "examples/example_silly_oak", + "files": { + "CHANGELOG.md": "0077f49b1a8415edaf278e7da0f7ac924af886dbbba92795a22903d3150e9481", + "README.md": "5d3d65f536bd9b5bb195cfc3dda2b2fa7bf8ecac3ead1abd066a802d066b8df1", + "assets/toast_kid.png": "02963b7c5c55e1e80d7ec4eaf274776170dd924ae421f29af14020bc3f32bc7d", + "main.lua": "2f7fbc5c6555f6d20ffa27c20233f0532368cca165a0afa7d393afd75f23de48", + "manifest.json": "cac8e5d240261cca5e6c02ff9e9446a74162200c2534e44589549a29d4a92026", + "mod.card": "0affa0fb359320b5b043731458d59eb92de4330bd4d3913ce9c35496b7ef3539", + "tests/example_silly_oak_test.lua": "3be13592fc90452edcc8c8589ab7f14b5690a5a721b5617871d17d699eea6eeb" + } + }, + { + "path": "examples/example_weather", + "files": { + "CHANGELOG.md": "82e88c93893ae5375a0151c9522cc8b082694bb53af5839f89cc55f9249acd37", + "README.md": "3c3490d87f38f37104ed46d22bdefa7aeed6815ecbb6a69834f9738a81fe7b13", + "main.lua": "469e663f3eaaaa9557253e2a7022e65d332731f438ef532a49a5715f354dd2eb", + "manifest.json": "7ddb5dd1f9c09b19b9cfbf61e37b1df48fa7321c93728495fbe778d00ab6f1d8", + "mod.card": "02a4949b455ccf12a3f00b33e39f8c0327f4544179de59c5c45108d3aae79e46", + "tests/example_weather_test.lua": "77fd2dd1fdc737b65511c6e89bca5b1077c0de27a2254168427f253aef3f0a1f" + } + } + ], + "hooks": [ + "battle.accuracy", + "battle.bottom_ui_visible", + "battle.catch_exp", + "battle.caught_marker_visible", + "battle.charge_required", + "battle.crit", + "battle.damage", + "battle.enemy_action", + "battle.enemy_switch_or_item", + "battle.exp_award", + "battle.field_residual", + "battle.low_health_alarm", + "battle.menu_auxiliary", + "battle.move_grid_navigation", + "battle.overlay", + "battle.run", + "battle.status_hud_visible", + "battle.style", + "battle.turn_order", + "breeding.compatibility", + "catch.nickname", + "catch.party_full", + "catch.rate", + "core.logic_speed", + "core.quit_to_launcher", + "core.update", + "encounter.fishing", + "encounter.roll", + "encounter.species", + "encounter.table", + "evolution.check", + "exp.gain", + "fieldmove.eligibility", + "gender.roll", + "held_item.trigger", + "input.gamepad", + "input.key", + "input.pointer", + "input.step", + "input.wheel", + "intro.oak_speech.build", + "item.use", + "link.fingerprint", + "map.palette", + "movement.collision", + "movement.speed", + "music.select", + "music.volume", + "phone.contact_list", + "player.sprite", + "pokemon.icon", + "pokemon.level_visible", + "pokemon.sprite", + "render.compose", + "render.hud", + "render.letterbox", + "render.output", + "render.output_enabled", + "render.viewport", + "render.window", + "render.zones", + "save.new_game", + "save.write", + "screen.render_visible", + "script.command", + "shiny.roll", + "trainer.before_battle", + "trainer.party", + "transition.style", + "ui.list_menu", + "ui.naming.grid", + "ui.options.rows", + "ui.party.grid_navigation", + "ui.party.submenu", + "ui.pc.items", + "ui.start_menu.items", + "ui.title_menu.items", + "warp.destination", + "world.follower.spawn", + "world.talk", + "world.tod", + "zoom.range" + ] +} diff --git a/tests/fixtures/upstream_mods/worker.lua b/tests/fixtures/upstream_mods/worker.lua new file mode 100644 index 000000000..8ad274e51 --- /dev/null +++ b/tests/fixtures/upstream_mods/worker.lua @@ -0,0 +1,71 @@ +-- Read-only external packages; production Loader, manifests, registries and buses. +package.path='./?.lua;./?/init.lua;'..package.path +local reference,fixture,version=assert(arg[1]),assert(arg[2]),arg[3] or 'red' +love=require('tests.love_stub') +local Json=require('src.link.Json') +local Files=require('tests.fs_io').new(reference) +local Writer=require('src.import.LuaWriter') +local I={ + manifestPath=function(importer,pack)return 'asset_packs/'..importer..'/'..pack..'/pack.lua' end, + assetPath=function(importer,pack,file)return 'asset_packs/'..importer..'/'..pack..'/'..file end, +} +local raw=assert(Files.read('mods/'..fixture..'/manifest.json')) +local manifest=assert(Json.decode(raw));local id=manifest.id +local mount='mods/'..id +local overlay={['options.lua']=Writer.encode({mods={[id]=true}})} +local function pack(importer,kind,name,entry) + local p={format=1,importer=importer,pack=kind,kind=kind=='tiles' and 'tileset' or 'sprite',version='1.1.0',source={name='Synthetic fixture',md5=string.rep('0',32)},entries={[name]=entry}} + overlay[I.manifestPath(importer,kind)]=Writer.encode(p) + overlay[I.assetPath(importer,kind,entry.file)]='PNG!' +end +pack('lttp','sprites','player',{file='synthetic.png',size=4,width=16,height=24}) +pack('lttp','tiles','kakariko',{file='synthetic.png',size=4,width=1024,height=1024}) +pack('pmd_red','sprites','pikachu',{file='synthetic.png',size=4,width=16,height=24,sprite={dexNumber=25,monsterId=25}}) +local function remap(path) + if path==mount then return 'mods/'..fixture end + if path:sub(1,#mount+1)==mount..'/' then return 'mods/'..fixture..path:sub(#mount+1) end + return path +end +local fs={root=reference} +function fs.read(path)if path:match('^assets/generated/')then return nil end;return overlay[path] or Files.read(remap(path))end +function fs.write(path,bytes)overlay[path]=bytes;return true end +function fs.remove(path)overlay[path]=nil;return true end +function fs.rename(a,b)if not overlay[a]then return false end;overlay[b]=overlay[a];overlay[a]=nil;return true end +function fs.createDirectory()return true end +function fs.load(path)local bytes=fs.read(path);if not bytes then return nil,'missing '..path end;return loadstring(bytes,'@'..path)end +function fs.getInfo(path) + if overlay[path]then return {type='file',size=#overlay[path]}end + if path=='mods'then return {type='directory'}end + if path:match('^assets/generated/')then return nil end + return Files.getInfo(remap(path)) +end +function fs.getDirectoryItems(path)if path=='mods'then return {id}end;return Files.getDirectoryItems(remap(path))end +-- CPU image objects exercise the real assembling consumer, without claiming pixels. +love.image={newImageData=function(w,h) + if type(w)=='string'then w,h=1024,1024 end + return {getWidth=function()return w end,getHeight=function()return h end, + getDimensions=function()return w,h end,paste=function()end,setPixel=function()end, + getPixel=function()return 0.5,0.5,0.5,1 end,getString=function()return 'synthetic' end, + release=function()end,encode=function()return {getString=function()return 'PNG!' end}end} +end} +require('src.core.GameVersion').set(version) +local data=require('tests.fixtures.upstream_mods.data').fresh() +local Loader=require('src.mods.Loader') +local loader=Loader.new({fs=fs,dev=false}) +local ok,result=pcall(loader.load,loader,data) +local names,known={},{} +local handle=assert(io.open('tests/fixtures/upstream_mods/index.json','rb')) +local index=assert(Json.decode(handle:read('*a')));handle:close() +for _,name in ipairs(index.hooks)do known[name]=true end +for name in pairs(loader.hooks.chains)do assert(known[name],'unknown hook '..name);names[#names+1]=name end +table.sort(names) +local loaded=ok and result==true and loader.mods[id] and loader.mods[id].state=='loaded' +local errorText=ok and table.concat(loader.errors,'; ') or tostring(result) +-- Execute a registered gift handler, without substituting any loader/API method. +if loaded and id=='example_mew_starter' and version=='red' then + local gift={species='CHARMANDER',level=5,ctx={overworld={map={id='OAKS_LAB'}},save={flags={},party={}}}} + loader.events:emit('pokemon.before_give',gift) + assert(gift.species=='MEW' and gift.level==20,'legacy starter event did not run') +end +print('FIXTURE_RESULT '..Json.encode({fixture=fixture,version=version,loaded=loaded and true or false,hooks=names,error=errorText})) +if not loaded then os.exit(1)end diff --git a/tests/mod_examples_tests.lua b/tests/mod_examples_tests.lua index 64b84b2d7..0dbff0a1a 100644 --- a/tests/mod_examples_tests.lua +++ b/tests/mod_examples_tests.lua @@ -141,25 +141,16 @@ for _, id in ipairs(IDS) do end end --- the legacy entry keeps its v1 manifest and gains only a card +-- API-1 manifest validation remains covered even when the old demonstration +-- is not installed in the production mod directory. The pinned external +-- fixture gate loads the untouched original separately. do - local cardChunk = loadfile("mods/example_mew_starter/mod.card") - if check(cardChunk ~= nil, "example_mew_starter: ships a mod.card") then - local ok, card = pcall(cardChunk) - check(ok and type(card) == "table", "example_mew_starter: the card parses") - check(ok and card.compat and card.compat.modApi == 1, - "example_mew_starter: the card declares api 1, not 2") - end - local body = io.open("mods/example_mew_starter/manifest.json", "rb") - if check(body ~= nil, "example_mew_starter: manifest is readable") then - local manifest = require("src.link.Json").decode(body:read("*a")) - body:close() - check(manifest.api == nil, "example_mew_starter: stays an api 1 manifest") - eq(manifest.category, "GAMEPLAY", - "example_mew_starter: keeps the legacy category value") - local ok = pcall(Manifest.validate, manifest, "mods/example_mew_starter") - check(ok, "example_mew_starter: the v1 manifest still validates") - end + local ok, manifest = pcall(Manifest.validate, { + id = "legacy_gallery_probe", name = "Legacy gallery probe", version = "1.0.0", + entry = "main.lua", category = "GAMEPLAY", + }, "tests/fixtures/upstream_mods/legacy_gallery_probe") + check(ok, "legacy api-1 manifest still validates") + eq(ok and manifest.api, 1, "omitted api selects legacy semantics") end -- ------- disabled by default @@ -176,18 +167,13 @@ do for _, id in ipairs(IDS) do check(not top[id], id .. " is not discoverable at the mods/ root") end - check(top.example_mew_starter, - "the legacy example is still discovered at the mods/ root") + check(not top.example_mew_starter, + "external compatibility fixtures are not installed as player mods") end -- ------- the gallery loads -local data = freshImported() -if not data then - print("modkit: example gallery load skipped -- no imported dataset in " - .. "data/generated/ to fold the examples against") - return S.finish() -end +local data = freshImported() or require("tests.fixtures.upstream_mods.data").fresh() -- The transform in example_shiny_palette reads the imported cache and -- writes under save/mod-derived/. The io-backed harness filesystem has no diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 48ba0eb9b..8730c8e92 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -9,7 +9,8 @@ import sys import tempfile -GEN1_REF = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" +GEN1_REFS = ("79ea62f45fb2ea8f65d081a109431e4eb1bbdb36", + "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91") VERSIONS = ("red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald") @@ -25,8 +26,9 @@ def main(): parser.add_argument("--report", type=Path) args = parser.parse_args() gen1, gen2 = args.gen1.resolve(), args.gen2.resolve() - if git(gen1, "rev-parse", "HEAD") != GEN1_REF: - parser.error(f"Gen1 must be pinned to {GEN1_REF}") + reference_ref = git(gen1, "rev-parse", "HEAD") + if reference_ref not in GEN1_REFS: + parser.error("reference must be pinned to one of " + ", ".join(GEN1_REFS)) if git(gen1, "status", "--porcelain", "--untracked-files=normal"): parser.error("Gen1 reference has tracked modifications; use an isolated checkout") fixtures = gen2 / "tests" / "api3_contract" @@ -65,7 +67,7 @@ def main(): raise RuntimeError(f"contract mismatch for {version}/{mode}: {values}") results.append({"version": version, "mode": mode, "result": values["gen1"]}) print(f"PASS {version}/{mode}: identical mod and public result") - report = {"gen1_ref": GEN1_REF, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + report = {"gen1_ref": reference_ref, "gen2_ref": git(gen2, "rev-parse", "HEAD"), "gen2_tracked_changes": bool(git(gen2, "status", "--porcelain", "--untracked-files=no")), "fixture_sha256": manifest, "scaffold_sha256": generated_hashes, "comparisons": results} if args.report: diff --git a/tools/check_external_mod_fixtures.py b/tools/check_external_mod_fixtures.py new file mode 100644 index 000000000..298571602 --- /dev/null +++ b/tools/check_external_mod_fixtures.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Authenticate external packages and exercise their unchanged entrypoints.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--reference', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path, help='directory providing qa_storage.py and its companion modules') + parser.add_argument('--lua', default=os.environ.get('LUA', 'luajit')) + parser.add_argument('--report', type=Path, required=True) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + reference = args.reference.resolve() + if args.qa_tools: + sys.path.insert(0, str(args.qa_tools.resolve())) + try: + from qa_storage import qa_source, qa_cache, qa_run + except ImportError: + parser.error('local QA storage guard is required; provide --qa-tools') + qa_source(root) + qa_source(reference) + qa_cache(args.report.parent) + index = json.loads((root / 'tests/fixtures/upstream_mods/index.json').read_text()) + head = subprocess.check_output(['git', '-C', str(reference), 'rev-parse', 'HEAD'], text=True).strip() + if head != index['reference']: + parser.error('reference checkout does not match the authenticated commit') + results = [] + for fixture in index['fixtures']: + package = reference / 'mods' / fixture['path'] + files = {str(p.relative_to(package)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in package.rglob('*') if p.is_file()} + if files != fixture['files']: + raise RuntimeError('external package bytes differ: ' + fixture['path']) + if fixture['path'].startswith('examples/example_'): + shipped = root / 'mods' / fixture['path'] + for name, digest in fixture['files'].items(): + if hashlib.sha256((shipped / name).read_bytes()).hexdigest() != digest: + raise RuntimeError('shipped gallery bytes differ: ' + fixture['path'] + '/' + name) + versions = ('red', 'firered') if fixture['path'] == 'example_mew_starter' else ('red',) + for version in versions: + run = qa_run([args.lua, str(root / 'tests/fixtures/upstream_mods/worker.lua'), + str(reference), fixture['path'], version], cwd=root, + capture_output=True, text=True, timeout=60) + lines = [line[len('FIXTURE_RESULT '):] for line in run.stdout.splitlines() + if line.startswith('FIXTURE_RESULT ')] + if len(lines) != 1: + raise RuntimeError(f"{fixture['path']}/{version}: missing worker result\n{run.stdout}{run.stderr}") + result = json.loads(lines[0]) + result['exit_code'] = run.returncode + results.append(result) + print(('PASS ' if result['loaded'] and not run.returncode else 'FAIL ') + + fixture['path'] + '/' + version + ': ' + result['error'], flush=True) + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps({'reference': head, 'fixtures': results}, indent=2) + '\n') + return 0 if all(r['loaded'] and r['exit_code'] == 0 for r in results) else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) From e22eb697120749e7b065be1c11bf2d064fefae8d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:50:25 +0100 Subject: [PATCH 050/138] Record the explicit intermediate module-availability boundary --- tests/fixtures/upstream_mods/README.md | 5 +++++ tools/check_external_mod_fixtures.py | 13 ++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/fixtures/upstream_mods/README.md b/tests/fixtures/upstream_mods/README.md index 0ed563a46..66c1677bc 100644 --- a/tests/fixtures/upstream_mods/README.md +++ b/tests/fixtures/upstream_mods/README.md @@ -36,3 +36,8 @@ cache is present, so `tests/mod_examples_tests.lua` runs its original effect and hook assertions in a ROM-free checkout. It validates the legacy API-1 manifest separately from the retired production example path. No compatibility fixtures are placed under `mods/`. + +Use `--phase aliases` only for the explicitly defined intermediate acceptance: the +one sprite-interface gap must be reported with its module and affected consumer. +It is recorded as an expected gap, never as a loaded mod. Default complete +acceptance remains red until the entrypoint actually loads. diff --git a/tools/check_external_mod_fixtures.py b/tools/check_external_mod_fixtures.py index 298571602..b5ff89d33 100644 --- a/tools/check_external_mod_fixtures.py +++ b/tools/check_external_mod_fixtures.py @@ -17,6 +17,7 @@ def main(): parser.add_argument('--qa-tools', type=Path, help='directory providing qa_storage.py and its companion modules') parser.add_argument('--lua', default=os.environ.get('LUA', 'luajit')) parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--phase', choices=('complete', 'aliases'), default='complete') args = parser.parse_args() root = Path(__file__).resolve().parents[1] reference = args.reference.resolve() @@ -56,12 +57,18 @@ def main(): raise RuntimeError(f"{fixture['path']}/{version}: missing worker result\n{run.stdout}{run.stderr}") result = json.loads(lines[0]) result['exit_code'] = run.returncode + expected = ('Compatibility module src.import.pmd.Sprite is unavailable in this engine; ' + 'affected mods: mystery_dungeon_follower') + result['accepted_gap'] = (args.phase == 'aliases' and fixture['path'] == 'mystery_dungeon_follower' + and run.returncode == 1 and not result['loaded'] + and result['error'].endswith(expected)) results.append(result) - print(('PASS ' if result['loaded'] and not run.returncode else 'FAIL ') + + print(('EXPECTED GAP ' if result['accepted_gap'] else + 'PASS ' if result['loaded'] and not run.returncode else 'FAIL ') + fixture['path'] + '/' + version + ': ' + result['error'], flush=True) args.report.parent.mkdir(parents=True, exist_ok=True) - args.report.write_text(json.dumps({'reference': head, 'fixtures': results}, indent=2) + '\n') - return 0 if all(r['loaded'] and r['exit_code'] == 0 for r in results) else 1 + args.report.write_text(json.dumps({'reference': head, 'phase': args.phase, 'fixtures': results}, indent=2) + '\n') + return 0 if all((r['loaded'] and r['exit_code'] == 0) or r['accepted_gap'] for r in results) else 1 if __name__ == '__main__': From 58c093f8ba4cb54dc6e7a1f9261bea9634b930b2 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:51:40 +0100 Subject: [PATCH 051/138] Keep the authenticated gallery documentation byte-identical --- mods/examples/example_silly_oak/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mods/examples/example_silly_oak/README.md b/mods/examples/example_silly_oak/README.md index 7278827ea..3a9e77ce0 100644 --- a/mods/examples/example_silly_oak/README.md +++ b/mods/examples/example_silly_oak/README.md @@ -35,8 +35,8 @@ SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \ | Seam | Where | |---|---| -| `hooks:wrap("intro.oak_speech.build")` | `main.lua` — reshape the step list | -| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` — anchored on vanilla step ids | +| `hooks:wrap("intro.oak_speech.build")` | `main.lua` -- reshape the step list | +| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` -- anchored on vanilla step ids | | step kinds `say` / `yesno` / `choice` | `main.lua` | | pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` | | `events:on("intro.oak_speech.answered")` | `main.lua` → `mod.save` | From d176fe86049fecf66b17155665eab27ec476b378 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:54:50 +0100 Subject: [PATCH 052/138] compat(M1): add native module aliases and named unsupported imports --- src/core/ModCompat.lua | 11 +++++++++++ src/mods/Gen1Compat.lua | 22 ++++++++++++++++++++++ tests/engine/compat_module_aliases.lua | 19 +++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 src/mods/Gen1Compat.lua create mode 100644 tests/engine/compat_module_aliases.lua diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..31a1015a2 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -100,6 +100,13 @@ ModCompat.ALIASES = { ["src.render.GbcPalette"] = "src.render.GBCFX", } +-- Extend the data vocabulary without changing loader permissions or public +-- module objects. Existing aliases retain their native identities. +local Compatibility = require("src.mods.Gen1Compat") +for name, target in pairs(Compatibility.ALIASES) do + if ModCompat.ALIASES[name] == nil then ModCompat.ALIASES[name] = target end +end + local seen = {} local installed = false @@ -107,6 +114,8 @@ local installed = false -- matters because "the target is missing too" is a build problem, not a mod -- problem, and should not be reported as an unknown module. function ModCompat.resolve(name) + local missing = Compatibility.unavailable(name) + if missing then return nil, missing end local target = ModCompat.ALIASES[name] if not target then return nil, nil end local ok, value = pcall(require, target) @@ -129,6 +138,8 @@ function ModCompat.install() local loaders = package.loaders or package.searchers if type(loaders) ~= "table" then return false end loaders[#loaders + 1] = function(name) + local missing = Compatibility.unavailable(name) + if missing then return function() error(missing, 0) end end if not ModCompat.ALIASES[name] then -- the standard "not found" contract: a string the require error joins return "\n\tno mod-compat alias for '" .. tostring(name) .. "'" diff --git a/src/mods/Gen1Compat.lua b/src/mods/Gen1Compat.lua new file mode 100644 index 000000000..9004bdc62 --- /dev/null +++ b/src/mods/Gen1Compat.lua @@ -0,0 +1,22 @@ +-- Compatibility spellings, kept separate from the loader's execution policy. +-- Only interchangeable native objects belong in ALIASES. Unsupported modules +-- fail at import with an actionable diagnostic instead of a misleading facade. +local C = {} +C.ALIASES = { + ['src.world.gen2.WorldAPI'] = 'src.world.WorldAPI', + ['src.world.gen2.Follower'] = 'src.world.OptionalFollower', +} +C.UNAVAILABLE = { + ['src.import.pmd.Sprite'] = { 'mystery_dungeon_follower' }, + ['src.world.game3.Follower'] = { 'mystery_dungeon_follower' }, + ['src.core.gen2.Unown'] = { 'mystery_dungeon_follower' }, + ['src.core.game3.pokemon'] = { 'mystery_dungeon_follower' }, + ['src.core.game3.player'] = { 'mystery_dungeon_follower' }, +} +function C.unavailable(name) + local consumers = C.UNAVAILABLE[name] + if not consumers then return nil end + return ('Compatibility module %s is unavailable in this engine; affected mods: %s') + :format(name, table.concat(consumers, ', ')) +end +return C diff --git a/tests/engine/compat_module_aliases.lua b/tests/engine/compat_module_aliases.lua new file mode 100644 index 000000000..b04529a8e --- /dev/null +++ b/tests/engine/compat_module_aliases.lua @@ -0,0 +1,19 @@ +local T=require('tests.harness') +love=require('tests.love_stub') +local C=require('src.core.ModCompat') +C.install() +T.eq(require('src.world.gen2.WorldAPI'),require('src.world.WorldAPI'),'world API alias preserves object identity') +if loadfile('src/world/OptionalFollower.lua') then + T.eq(require('src.world.gen2.Follower'),require('src.world.OptionalFollower'),'Gen2 follower alias preserves object identity') +else + T.raises(function()require('src.world.gen2.Follower')end,'src.world.OptionalFollower','alias identifies an unavailable native follower provider') +end +local names=require('src.mods.Gen1Compat') +for name in pairs(names.UNAVAILABLE)do + local ok,reason=pcall(require,name) + T.check(not ok,'unsupported import fails '..name) + T.check(tostring(reason):find(name,1,true)~=nil,'failure identifies module '..name) + T.check(tostring(reason):find('mystery_dungeon_follower',1,true)~=nil,'failure identifies affected consumer '..name) + T.check(not tostring(reason):find('no file ',1,true),'failure does not masquerade as a filesystem search '..name) +end +T.finish() From 9fb0208fabd1123383f02a60ef0be6e0cd86db81 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Sun, 25 Oct 2026 23:55:08 +0100 Subject: [PATCH 053/138] Add fixed-destination mod logs and bounded native mobile HTTP --- .github/workflows/async-get.yml | 4 +- .github/workflows/mod-native-http.yml | 71 ++++++++ LICENSE-ROXAS2712.md | 88 ++++++++++ docs/mod-log-reporting.md | 60 +++++++ docs/modding.md | 4 + .../love/src/modules/system/wrap_System.cpp | 21 +++ .../java/org/love2d/android/BoundedHttp.java | 130 ++++++++++++++ .../java/org/love2d/android/GameActivity.java | 2 + mobile/ios/native/GRHTTPBridge.swift | 162 ++++++++++++++++++ mobile/ios/patch_love_src.py | 34 +++- src/core/HostShell.lua | 46 ++++- src/mods/Loader.lua | 1 + src/mods/LogReport.lua | 18 ++ src/mods/Manifest.lua | 11 ++ src/mods/Net.lua | 50 ++++-- src/net/AsyncGet.lua | 22 ++- src/net/BoundedHttp.lua | 51 ++++++ src/net/DownloadTransport.lua | 14 +- src/net/Fetch.lua | 1 + src/net/get_worker.lua | 10 +- tests/engine/async_get_queue.lua | 8 + tests/engine/bounded_http.lua | 64 +++++++ tests/engine/log_reporting.lua | 91 ++++++++++ tests/native_http/BoundedHttpCheck.java | 69 ++++++++ tests/native_http/android/util/Base64.java | 8 + tests/native_http/main.swift | 89 ++++++++++ tools/async-get-check/main.lua | 29 +++- tools/check_async_get.py | 42 ++++- tools/qualify_native_http.py | 50 ++++++ 29 files changed, 1210 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/mod-native-http.yml create mode 100644 LICENSE-ROXAS2712.md create mode 100644 docs/mod-log-reporting.md create mode 100644 mobile/android/love/src/main/java/org/love2d/android/BoundedHttp.java create mode 100644 mobile/ios/native/GRHTTPBridge.swift create mode 100644 src/mods/LogReport.lua create mode 100644 src/net/BoundedHttp.lua create mode 100644 tests/engine/bounded_http.lua create mode 100644 tests/engine/log_reporting.lua create mode 100644 tests/native_http/BoundedHttpCheck.java create mode 100644 tests/native_http/android/util/Base64.java create mode 100644 tests/native_http/main.swift create mode 100644 tools/qualify_native_http.py diff --git a/.github/workflows/async-get.yml b/.github/workflows/async-get.yml index cb03a8521..5cf399334 100644 --- a/.github/workflows/async-get.yml +++ b/.github/workflows/async-get.yml @@ -1,4 +1,4 @@ -name: async mod GET +name: Async mod HTTP and file downloads on: pull_request: workflow_dispatch: @@ -35,4 +35,4 @@ jobs: with zipfile.ZipFile(archive) as z: z.extractall(target) executable = next(target.rglob('love.app/Contents/MacOS/love')) if host == 'macOS' else next(target.rglob('lovec.exe')) if host == 'macOS': executable.chmod(0o755) - subprocess.run(['python', 'tools/check_async_get.py', '--love', str(executable)], check=True) + subprocess.run(['python', 'tools/check_async_get.py', '--love', str(executable), '--tls'], check=True) diff --git a/.github/workflows/mod-native-http.yml b/.github/workflows/mod-native-http.yml new file mode 100644 index 000000000..54929ffcb --- /dev/null +++ b/.github/workflows/mod-native-http.yml @@ -0,0 +1,71 @@ +name: Native bounded HTTP +on: + pull_request: + paths: + - 'src/mods/LogReport.lua' + - 'src/mods/Net.lua' + - 'src/mods/Manifest.lua' + - 'src/mods/Loader.lua' + - 'src/net/**' + - 'src/core/HostShell.lua' + - 'mobile/ios/native/GRHTTPBridge.swift' + - 'mobile/ios/patch_love_src.py' + - 'mobile/android/love/src/main/java/org/love2d/android/BoundedHttp.java' + - 'mobile/android/love/src/main/java/org/love2d/android/GameActivity.java' + - 'mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp' + - 'tests/native_http/**' + - 'tests/engine/log_reporting.lua' + - 'tests/engine/bounded_http.lua' + - 'tools/qualify_native_http.py' + - '.github/workflows/mod-native-http.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: native-bounded-http-${{ github.ref }} + cancel-in-progress: true +jobs: + android-bridge-build: + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - name: Install native Android build dependencies + run: '"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install "platforms;android-34" "build-tools;34.0.0" "ndk;25.2.9519653"' + - name: Compile the Android HTTP adapter and native Lua binding + working-directory: mobile/android + run: bash ./gradlew --no-daemon :love:compileEmbedDebugJavaWithJavac :love:externalNativeBuildEmbedDebug + native-protocols: + runs-on: macos-15 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - run: brew install luajit + - run: luajit tests/engine/log_reporting.lua && luajit tests/engine/async_get_queue.lua && luajit tests/engine/bounded_http.lua + - name: Verify the pinned host-test dependency + shell: bash + run: | + set -euo pipefail + curl --fail --location https://repo.maven.apache.org/maven2/org/json/json/20240303/json-20240303.jar -o "$RUNNER_TEMP/json-20240303.jar" + printf '%s %s\n' 3cf6cd6892e32e2b4c1c39e0f52f5248a2f5b37646fdfbb79a66b46b618414ed "$RUNNER_TEMP/json-20240303.jar" | shasum -a 256 -c - + - name: Exercise native adapter logic and typecheck iOS + shell: bash + run: | + python3 tools/qualify_native_http.py --swiftc "$(xcrun --find swiftc)" --sdk "$(xcrun --sdk macosx --show-sdk-path)" --ios-sdk "$(xcrun --sdk iphoneos --show-sdk-path)" --java-home "$JAVA_HOME" --json-jar "$RUNNER_TEMP/json-20240303.jar" --output "$RUNNER_TEMP/native-http" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: native-bounded-http + path: | + ${{ runner.temp }}/native-http/*.json + ${{ runner.temp }}/native-http/*.log + if-no-files-found: warn + retention-days: 7 diff --git a/LICENSE-ROXAS2712.md b/LICENSE-ROXAS2712.md new file mode 100644 index 000000000..8931eb41f --- /dev/null +++ b/LICENSE-ROXAS2712.md @@ -0,0 +1,88 @@ +# Roxas2712 — Fork Contributions + +Version 1.0 · Effective 25 October 2026 + +Copyright © 2026 Roxas2712, to the extent of rights held. +All rights reserved except as expressly granted below or already granted. + +## 1. Scope and ownership + +This notice applies only to original contributions made by Roxas2712 +(GitHub: Roxas2712) to `Roxas2712/Gen2Recomped`, including original additions +within modified files, insofar as he owns the relevant rights. Git history, +pull-request diffs and source provenance identify contributions; a commit +author or a whole-file change alone does not establish ownership of every +part of that file. + +It does not claim ownership of Cedric's work, third-party engine work, +other contributors' work, third-party dependencies, or ROM-derived +content. Their applicable licences and notices remain in force. It does not +grant permission to distribute the complete combined fork where another +rights holder's permission is required. + +## 2. General permission and reserved rights + +For contributions covered by this notice and not already licensed more +broadly, you may read and study the published source and make private, +non-commercial modifications and builds for your own use. This grants no +general permission to redistribute, sublicense, sell, or offer those +contributions as a hosted service. Other uses require Roxas2712's +written permission, except for the specific permission below and rights +available independently under applicable law. + +Keep the relevant copyright, licence and attribution notices. Do not +represent Roxas2712's contributions as exclusively authored by someone +else. Public source availability alone does not put them in the public +domain or grant an open-source licence. + +## 3. Specific permission for Cedric / UNDERdecoded + +Roxas2712 expressly grants Cedric (Ced; GitHub: UNDERdecoded / +UNDERdecodedHD) a non-exclusive, worldwide, perpetual, royalty-free +permission to use, reproduce, integrate, modify and distribute the covered +contributions that Roxas2712 submits or expressly offers through pull +requests to `UNDERdecoded/Gen2Recomped`, for Cedric's Gen2Recomped / +RetroRecompiled project. This includes its renamed continuation maintained +by Cedric, its official builds, and its commercial operation. + +Cedric may license the incorporated contributions to recipients as part of +that project, subject to preserving this permission's applicable credit and +notice requirements. This permission is not a copyright assignment, is not +exclusive, and does not cover unpublished work, separate repositories, +ModCompass service operation, credentials, or content not offered in those +pull requests. Roxas2712 retains his own rights to his contributions. + +For uses relying on this permission: + +- retain the relevant source copyright and licence notices; +- give visible credit in the project's README or equivalent public project + page, and in the application's existing About / Credits view, if present; +- use at least the following credit, or a substantially equivalent one: + + > Contributions by Roxas2712 — + > https://github.com/Roxas2712 + +A maintained consolidated contributor credit is sufficient; a separate +credit on every screen or for every change is not required. Adapted +contributions must not be attributed exclusively to another author. Other +contributors' credits must also remain intact. + +## 4. Earlier licences and permissions remain effective + +This notice does not revoke, narrow, or retrospectively add conditions to +any permission already validly granted, including MIT / GPL grants, +separate agreements, or permissions arising from previously submitted +pull requests. Where an earlier grant permits a use independently of this +notice, its existing terms govern that use. For such earlier uses, the +visible credit above is requested to the extent it is not already required. + +For subsequent contributions, this is the default statement of Roxas2712's own reserved rights and the specific PR permission, subject to any +applicable existing licence obligations or separately agreed terms. It +does not change another rights holder's licence or guarantee compatibility +of a particular combination of code. + +## 5. No warranty + +To the extent permitted by applicable law, these contributions are provided +as is, without warranty. No support obligation, endorsement, or rights to +third-party code, game assets, names, or trademarks are granted. diff --git a/docs/mod-log-reporting.md b/docs/mod-log-reporting.md new file mode 100644 index 000000000..602013f6f --- /dev/null +++ b/docs/mod-log-reporting.md @@ -0,0 +1,60 @@ +# Fixed-destination log reporting and bounded native HTTP + +Declare a fixed HTTPS `log_url` and the `network` permission in the manifest. +API 2/3 reject invalid or ungranted declarations before executing the mod; legacy +declarations warn and discard an invalid destination. + +```lua +local job, why = mod:postLog("diagnostic text", {format="text"}) +-- Later: poll and release through the same interface as GET. +if job then + local result = mod.fetch:poll(job) + if result.status ~= "pending" then mod.fetch:release(job) end +end +``` + +The only option is `format`, either `text` (default) or `json`. Input is at most +65,536 bytes. JSON wraps it in `{ts, mod, format, body}`; its encoded payload is +bounded separately at 512 KiB. Unknown keys and invalid values submit nothing. +The endpoint and mod identity are captured when the API is created. Manifest +mutation, options and redirects cannot change the log destination. POST does not +follow redirects. No server response body reaches the mod, including error and +late/cancelled results. Public log results expose status, progress, HTTP status +and a stable failure diagnostic. The mod's existing network permission applies. + +GET and logs share the existing three-worker queue, global 16-job ceiling and +four-pending-requests-per-mod ceiling. Queue time counts towards the timeout. +Log requests use 20 seconds; other internal requests cap at 30 seconds. Released +or timed-out jobs retain their physical worker slot until completion. Poll reads +only worker results and performs no network, pipe or filesystem reads. + +Desktop POST reuses the bounded curl path. Payloads reside in worker-owned files +and never enter shell syntax; those files are removed after the request. Curl +configuration is disabled and explicit HTTPS protocol limits remain enforced. + +The new mobile `boundedHttpVersion()` / `httpRequestBounded()` protocol carries +binary payloads as bounded Base64 fields. Android reuses the existing JNI route +with a mandatory bounded envelope and a separately validated URLConnection +adapter; legacy cloud HTTP is unchanged. iOS uses a separate Foundation adapter +and private ephemeral sessions, without cookie/credential storage. Both adapters +retain platform TLS verification, bound body accumulation, refuse non-HTTP URLs +and HTTPS downgrades, and cap GET redirects at five. A POST is always HTTPS and +cannot redirect. iOS retains native task slots until terminal delegate callbacks; +Android closes the current connection on the absolute request deadline. + +An HTTP failure, timeout or cancellation does not prove that a server did not +accept the submitted log. The service does not retry POST automatically. + +Verification includes real owned desktop TLS transfers, literal bytes, expanded +JSON payloads, reply suppression, redirect refusal and existing GET/file-download +regressions. `tools/qualify_native_http.py` executes the production Swift and Java +adapter logic with URLProtocol/URLConnection fixtures and typechecks iOS. Those +fixtures do not exercise mobile TLS stacks or physical devices. Native packaging, +real mobile TLS/device acceptance, and resource behavior under suspension remain +separate gates. + +The fixed Switch runtime currently has no bounded HTTP binding; the service +reports unavailable there and does not attempt a subprocess. Its TLS/runtime +adapter still needs integration and qualification. A native source build and +matching Lua payload are required for the mobile protocol. This change does not +establish full mod gameplay or complete platform parity. diff --git a/docs/modding.md b/docs/modding.md index e2a395416..a81e07943 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -301,3 +301,7 @@ The launcher hands the link to the operating system to open, so only `http://` and `https://` links are accepted. Any other value, such as a `file:` link or a custom scheme, is dropped with a warning. The mod itself still loads; it just gets no button. + +With `network` and a fixed HTTPS `log_url`, `mod:postLog` sends bounded one-way +diagnostics through the shared workers. See [log reporting](mod-log-reporting.md) +for payload, ownership and native platform boundaries. diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp index ec2348985..505f5a7df 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp @@ -22,6 +22,7 @@ #include "wrap_System.h" #include "common/android.h" #include "sdl/System.h" +#include namespace love { @@ -199,6 +200,24 @@ int w_httpRequest(lua_State *L) return 1; } +// Bounded worker transport added by Roxas2712; shares the existing JNI route. +int w_httpRequestBounded(lua_State *L) +{ + size_t size = 0; + const char *json = luaL_checklstring(L, 1, &size); + if (size > 1024 * 1024 || std::memchr(json, 0, size) != nullptr) + lua_pushliteral(L, "{\"error\":\"invalid bounded request\"}"); + else + { + std::string envelope = "{\"boundedEnvelope\":"; + envelope.append(json, size); + envelope.push_back('}'); + luax_pushstring(L, instance()->httpRequest(envelope)); + } + return 1; +} +int w_boundedHttpVersion(lua_State *L) { lua_pushinteger(L, 1); return 1; } + int w_walkerNotify(lua_State *L) { const char *json = luaL_checkstring(L, 1); @@ -263,6 +282,8 @@ static const luaL_Reg functions[] = { "mkdirs", w_mkdirs }, { "walkerNotify", w_walkerNotify }, { "httpRequest", w_httpRequest }, + { "httpRequestBounded", w_httpRequestBounded }, + { "boundedHttpVersion", w_boundedHttpVersion }, { "syncHealthSteps", w_syncHealthSteps }, { "restartApp", w_restartApp }, { "httpDownload", w_httpDownload }, diff --git a/mobile/android/love/src/main/java/org/love2d/android/BoundedHttp.java b/mobile/android/love/src/main/java/org/love2d/android/BoundedHttp.java new file mode 100644 index 000000000..8d36bea24 --- /dev/null +++ b/mobile/android/love/src/main/java/org/love2d/android/BoundedHttp.java @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package org.love2d.android; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.concurrent.Semaphore; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.json.JSONObject; + +/** Worker-only bounded transport; the existing cloud transport is unchanged. */ +final class BoundedHttp { + static final int MAX_BODY = 512 * 1024, MAX_BYTES = 16 * 1024 * 1024; + private static final Semaphore slots = new Semaphore(16); + private static final ScheduledThreadPoolExecutor deadlines = new ScheduledThreadPoolExecutor(3, new ThreadFactory() { + public Thread newThread(Runnable task) { + Thread t = new Thread(task, "bounded-http-deadline"); t.setDaemon(true); return t; + } + }); + static { deadlines.setRemoveOnCancelPolicy(true); } + private static String string(JSONObject req, String key, int maximum) throws Exception { + Object value = req.get(key); + if (!(value instanceof String)) throw new Exception("invalid field"); + String text = (String) value; + if (text.length() > maximum) throw new Exception("field too large"); + for (int i=0;imaximum) throw new Exception("invalid bounds"); + return n; + } + private static boolean flag(JSONObject req,String key) throws Exception { + Object value=req.get(key);if (!(value instanceof Boolean)) throw new Exception("invalid flag");return (Boolean)value; + } + private static URL url(String value, boolean secure) throws Exception { + if (value.length()>8192) throw new Exception("URL too long"); + for (int i=0;i current=new AtomicReference<>(); + ScheduledFuture timeout=null;boolean acquired=false; + try { + if (req.toString().length()>1024*1024 || number(req,"boundedVersion",1,1)!=1) throw new Exception("invalid protocol"); + String method=string(req,"method",4); + if (!method.equals("GET") && !method.equals("POST")) throw new Exception("unsupported method"); + boolean secure=flag(req,"httpsOnly") || method.equals("POST"); + boolean discard=flag(req,"discardBody"); + URL target=url(string(req,"url",8192),secure); + String agent=string(req,"userAgent",512),accept=string(req,"accept",512),mime=string(req,"contentType",128); + double limit=number(req,"maxBytes",1,MAX_BYTES); + if (Math.floor(limit)!=limit) throw new Exception("invalid byte bound"); + int maximum=(int)limit; + double seconds=number(req,"maxSeconds",0.001,30); + byte[] body=null; + if (method.equals("POST")) { + Object encoded=req.get("bodyBase64"); + if (!(encoded instanceof String) || ((String)encoded).length()>((MAX_BODY+2)/3)*4) throw new Exception("request too large"); + body=android.util.Base64.decode((String)encoded,android.util.Base64.DEFAULT); + if (body.length>MAX_BODY) throw new Exception("request too large"); + } + if (!slots.tryAcquire()) throw new Exception("transport busy");acquired=true; + final long deadline=System.nanoTime()+(long)(seconds*1000000000L); + timeout=deadlines.schedule(new Runnable() { + public void run() { HttpURLConnection c=current.get();if(c!=null)c.disconnect(); } + },Math.max(1,(long)Math.ceil(seconds*1000)),TimeUnit.MILLISECONDS); + int redirects=0; + while (true) { + HttpURLConnection conn=(HttpURLConnection)target.openConnection();current.set(conn); + conn.setInstanceFollowRedirects(false);conn.setConnectTimeout(Math.min(10000,remaining(deadline))); + conn.setUseCaches(false); + conn.setReadTimeout(remaining(deadline));conn.setRequestMethod(method); + conn.setRequestProperty("User-Agent",agent);conn.setRequestProperty("Accept",accept); + conn.setRequestProperty("Accept-Encoding","identity"); + if (body!=null) { + conn.setRequestProperty("Content-Type",mime);conn.setDoOutput(true);conn.setFixedLengthStreamingMode(body.length); + try (OutputStream out=conn.getOutputStream()) { out.write(body); } + } + remaining(deadline);int status=conn.getResponseCode();result.put("status",status); + if (status==301 || status==302 || status==303 || status==307 || status==308) { + String location=conn.getHeaderField("Location"); + if (!method.equals("GET") || location==null || ++redirects>5) throw new Exception("redirect refused"); + target=url(new URL(target,location).toString(),secure);conn.disconnect();current.set(null);continue; + } + if (status<200 || status>=300) throw new Exception("HTTP status"); + if (!discard) { + long declared=-1;String length=conn.getHeaderField("Content-Length"); + if (length!=null) {try {declared=Long.parseLong(length);} catch(NumberFormatException bad) {throw new Exception("invalid length");}} + if (declared>maximum) throw new Exception("response too large"); + ByteArrayOutputStream bytes=new ByteArrayOutputStream(Math.min(maximum,65536)); + try (InputStream in=conn.getInputStream()) { + byte[] chunk=new byte[8192];int n; + while (true) { + conn.setReadTimeout(remaining(deadline));n=in.read(chunk);if(n<0)break; + if (n>maximum-bytes.size()) throw new Exception("response too large");bytes.write(chunk,0,n); + } + } + if (declared>=0 && declared!=bytes.size()) throw new Exception("partial response"); + result.put("bodyBase64",android.util.Base64.encodeToString(bytes.toByteArray(),android.util.Base64.NO_WRAP)); + } + remaining(deadline);return result.toString(); + } + } catch (Exception error) { + try {result.remove("bodyBase64");result.put("error","bounded HTTP request failed");} catch(Exception ignored){} + return result.toString(); + } finally { + if (timeout!=null)timeout.cancel(false); + try {HttpURLConnection conn=current.get();if(conn!=null)conn.disconnect();} + finally {if(acquired)slots.release();} + } + } +} diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 4eedb2f2c..1eed05e29 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -1175,6 +1175,8 @@ public static String httpRequest(String json) { HttpURLConnection conn = null; try { org.json.JSONObject req = new org.json.JSONObject(json); + if (req.has("boundedEnvelope")) return BoundedHttp.request(req.getJSONObject("boundedEnvelope")); + if (req.has("boundedVersion")) return BoundedHttp.request(req); String method = req.optString("method", "GET"); URL url = new URL(req.getString("url")); conn = (HttpURLConnection) url.openConnection(); diff --git a/mobile/ios/native/GRHTTPBridge.swift b/mobile/ios/native/GRHTTPBridge.swift new file mode 100644 index 000000000..c91c4e97a --- /dev/null +++ b/mobile/ios/native/GRHTTPBridge.swift @@ -0,0 +1,162 @@ +// Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +import Foundation +import CoreFoundation + +enum GRHTTPFailure: Error { case invalid } +struct GRHTTPRequest { + let url: URL, method: String, agent: String, accept: String, mime: String + let seconds: Double, maximum: Int, secure: Bool, discard: Bool, body: Data? + static func text(_ value: Any?, maximum: Int) throws -> String { + guard let text=value as? String, text.utf8.count<=maximum, + !text.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) + else { throw GRHTTPFailure.invalid } + return text + } + static func number(_ value: Any?, minimum: Double, maximum: Double) throws -> Double { + guard let n=value as? NSNumber, CFGetTypeID(n) != CFBooleanGetTypeID(), + n.doubleValue.isFinite, n.doubleValue>=minimum, n.doubleValue<=maximum + else { throw GRHTTPFailure.invalid } + return n.doubleValue + } + static func flag(_ value: Any?) throws -> Bool { + guard let n=value as? NSNumber, CFGetTypeID(n)==CFBooleanGetTypeID() else { throw GRHTTPFailure.invalid } + return n.boolValue + } + static func allowed(_ url: URL, secure: Bool) -> Bool { + guard let scheme=url.scheme?.lowercased(), let host=url.host, !host.isEmpty, + url.absoluteString.utf8.count<=8192, + !url.absoluteString.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) }) + else { return false } + return scheme=="https" || (!secure && scheme=="http") + } + init(_ json: String) throws { + guard json.utf8.count<=1024*1024, let data=json.data(using:.utf8), + let doc=try JSONSerialization.jsonObject(with:data) as? [String:Any] + else { throw GRHTTPFailure.invalid } + guard try Self.number(doc["boundedVersion"],minimum:1,maximum:1)==1 else { throw GRHTTPFailure.invalid } + method=try Self.text(doc["method"],maximum:4) + guard method=="GET" || method=="POST" else { throw GRHTTPFailure.invalid } + secure=try Self.flag(doc["httpsOnly"]) || method=="POST" + discard=try Self.flag(doc["discardBody"]) + let address=try Self.text(doc["url"],maximum:8192) + guard !address.unicodeScalars.contains(where:{CharacterSet.whitespacesAndNewlines.contains($0)}), + let target=URL(string:address), Self.allowed(target,secure:secure) + else { throw GRHTTPFailure.invalid } + url=target + agent=try Self.text(doc["userAgent"],maximum:512);accept=try Self.text(doc["accept"],maximum:512) + mime=try Self.text(doc["contentType"],maximum:128) + seconds=try Self.number(doc["maxSeconds"],minimum:0.001,maximum:30) + let bytes=try Self.number(doc["maxBytes"],minimum:1,maximum:16*1024*1024) + guard bytes.rounded(.down)==bytes else { throw GRHTTPFailure.invalid };maximum=Int(bytes) + if method=="POST" { + let encoded=try Self.text(doc["bodyBase64"],maximum:((512*1024+2)/3)*4) + guard let decoded=Data(base64Encoded:encoded), decoded.count<=512*1024 else { throw GRHTTPFailure.invalid } + body=decoded + } else { body=nil } + } +} + +private final class GRHTTPTransfer: NSObject, URLSessionDataDelegate, @unchecked Sendable { + let request: GRHTTPRequest + let done=DispatchSemaphore(value:0) + private let gate=NSLock() + private var result: [String:Any]? + private var bytes=Data() + private var status: Int? + private var declared: Int64 = -1 + private var redirects=0 + private var released=false + private let release: () -> Void + init(_ request: GRHTTPRequest, release: @escaping () -> Void) { self.request=request;self.release=release } + func finish(error: Bool) { + gate.lock() + if result != nil { gate.unlock();return } + var value: [String:Any]=[:] + if let code=status { value["status"]=code } + if error {value["error"]="bounded HTTP request failed"} + else if !request.discard {value["bodyBase64"]=bytes.base64EncodedString()} + result=value;gate.unlock();done.signal() + } + func take() -> [String:Any] { gate.lock();defer{gate.unlock()};return result ?? ["error":"bounded HTTP request failed"] } + func urlSession(_ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, newRequest: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) { + gate.lock() + let allowed=request.method=="GET" && redirects<5 && result==nil && + newRequest.url.map{GRHTTPRequest.allowed($0,secure:request.secure)}==true + if allowed {redirects+=1} else {status=response.statusCode} + gate.unlock() + if allowed { + var next=newRequest;next.setValue(request.agent,forHTTPHeaderField:"User-Agent") + next.setValue(request.accept,forHTTPHeaderField:"Accept") + next.setValue("identity",forHTTPHeaderField:"Accept-Encoding") + completionHandler(next) + } else {finish(error:true);completionHandler(nil)} + } + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + guard let http=response as? HTTPURLResponse else {finish(error:true);completionHandler(.cancel);return} + gate.lock();status=http.statusCode;declared=response.expectedContentLength + let valid=result==nil && (200..<300).contains(http.statusCode) + let oversized=declared>Int64(request.maximum) + gate.unlock() + if !valid || (!request.discard && oversized) {finish(error:true);completionHandler(.cancel)} + else if request.discard {finish(error:false);completionHandler(.cancel)} + else {completionHandler(.allow)} + } + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + gate.lock() + if result != nil {gate.unlock();return} + let oversized=data.count>request.maximum-bytes.count + if !oversized {bytes.append(data)} + gate.unlock() + if oversized {finish(error:true);dataTask.cancel()} + } + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + gate.lock() + let incomplete=declared>=0 && declared != Int64(bytes.count) + let firstRelease = !released;released=true + gate.unlock() + finish(error:error != nil || incomplete) + // A cancelled platform task retains its native slot until this terminal callback. + if firstRelease {release()} + } +} + +enum GRBoundedHTTP { + private static let pool=NSLock() + private static var active=0 + private static func acquire() -> Bool {pool.lock();defer{pool.unlock()};if active>=16{return false};active+=1;return true} + private static func release() {pool.lock();active-=1;pool.unlock()} + static func perform(_ json: String, configuration: URLSessionConfiguration? = nil) -> String { + let failure="{\"error\":\"bounded HTTP request failed\"}" + guard let request=try? GRHTTPRequest(json), acquire() else {return failure} + let transfer=GRHTTPTransfer(request,release:release) + let config=configuration ?? URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest=request.seconds;config.timeoutIntervalForResource=request.seconds + config.httpShouldSetCookies=false;config.httpCookieStorage=nil;config.urlCredentialStorage=nil + config.urlCache=nil;config.requestCachePolicy = .reloadIgnoringLocalCacheData + let session=URLSession(configuration:config,delegate:transfer,delegateQueue:nil) + var message=URLRequest(url:request.url,timeoutInterval:request.seconds) + message.httpMethod=request.method;message.httpBody=request.body + message.setValue(request.agent,forHTTPHeaderField:"User-Agent") + message.setValue(request.accept,forHTTPHeaderField:"Accept") + message.setValue("identity",forHTTPHeaderField:"Accept-Encoding") + if request.body != nil {message.setValue(request.mime,forHTTPHeaderField:"Content-Type")} + let task=session.dataTask(with:message);task.resume() + if transfer.done.wait(timeout:.now()+request.seconds) == .timedOut {transfer.finish(error:true);task.cancel()} + session.invalidateAndCancel() + guard let data=try? JSONSerialization.data(withJSONObject:transfer.take()), + let value=String(data:data,encoding:.utf8) else {return failure} + return value + } +} + +@objc(GRHTTPBridge) +public final class GRHTTPBridge: NSObject { + @objc(requestWithJSON:) + public static func request(json: UnsafePointer?) -> NSString { + guard let json=json, let value=String(validatingUTF8:json) else {return "{\"error\":\"invalid bounded request\"}"} + return GRBoundedHTTP.perform(value) as NSString + } +} diff --git a/mobile/ios/patch_love_src.py b/mobile/ios/patch_love_src.py index 9c0fa0ac4..c0c8e5b55 100644 --- a/mobile/ios/patch_love_src.py +++ b/mobile/ios/patch_love_src.py @@ -29,7 +29,7 @@ PBXPROJ = LOVE_SRC / "platform" / "xcode" / "love.xcodeproj" / "project.pbxproj" ENTITLEMENTS_SRC = IOS_DIR / "overlays" / "love-ios.entitlements" -NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRBootstrap.m") +NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRHTTPBridge.swift", "GRBootstrap.m") MARKER = "gen1recomp iOS picker bridge" @@ -40,6 +40,7 @@ #include #include #include +#include #include "filesystem/Filesystem.h" #endif """ % MARKER @@ -94,6 +95,32 @@ { return gr_callBridge(L, "GRHealthBridge", "syncStepsWithCommand:saveDir:", "sync"); } +int w_boundedHttpVersion(lua_State *L) +{ + lua_pushinteger(L, objc_getClass("GRHTTPBridge") != nullptr ? 1 : 0); + return 1; +} +int w_httpRequestBounded(lua_State *L) +{ + size_t size = 0; + const char *json = luaL_checklstring(L, 1, &size); + Class cls = objc_getClass("GRHTTPBridge"); + if (cls == nullptr || size > 1024 * 1024 || std::memchr(json, 0, size) != nullptr) + { + lua_pushliteral(L, "{\\\"error\\\":\\\"invalid bounded request\\\"}"); + return 1; + } + typedef id (*GRNew)(Class, SEL); + typedef id (*GRRequest)(Class, SEL, const char *); + typedef const char *(*GRUTF8)(id, SEL); + typedef void (*GRDrain)(id, SEL); + id pool = ((GRNew)objc_msgSend)(objc_getClass("NSAutoreleasePool"), sel_registerName("new")); + id result = ((GRRequest)objc_msgSend)(cls, sel_registerName("requestWithJSON:"), json); + const char *text = result == nullptr ? nullptr : ((GRUTF8)objc_msgSend)(result, sel_registerName("UTF8String")); + lua_pushstring(L, text == nullptr ? "{\\\"error\\\":\\\"bounded request failed\\\"}" : text); + ((GRDrain)objc_msgSend)(pool, sel_registerName("drain")); + return 1; +} #endif // LOVE_IOS // --------------------------------------------------------------------------- @@ -103,6 +130,8 @@ { "pickFile", w_pickFile }, { "createFile", w_createFile }, { "syncHealthSteps", w_syncHealthSteps }, + { "httpRequestBounded", w_httpRequestBounded }, + { "boundedHttpVersion", w_boundedHttpVersion }, #endif """ @@ -114,6 +143,8 @@ ID_BUILD_PICKER = "6E1AC0DE0002000000000001" ID_BUILD_OBJC = "6E1AC0DE0002000000000002" ID_BUILD_HEALTH = "6E1AC0DE0002000000000003" +ID_FILE_HTTP = "6E1AC0DE0001000000000004" +ID_BUILD_HTTP = "6E1AC0DE0002000000000004" SOURCES_PHASE_ID = "FA0B7F021A95AAF3000E1D17" # love-ios Sources phase IOS_APP_CONFIG_IDS = ( "FA0B7F261A95AAF4000E1D17", # Debug @@ -124,6 +155,7 @@ PBX_SOURCES = ( ("GRPickerBridge.swift", ID_FILE_PICKER, ID_BUILD_PICKER, "sourcecode.swift"), ("GRHealthBridge.swift", ID_FILE_HEALTH, ID_BUILD_HEALTH, "sourcecode.swift"), + ("GRHTTPBridge.swift", ID_FILE_HTTP, ID_BUILD_HTTP, "sourcecode.swift"), ("GRBootstrap.m", ID_FILE_OBJC, ID_BUILD_OBJC, "sourcecode.c.objc"), ) diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 004a4ae4f..7aff5614b 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -385,9 +385,9 @@ end -- Worker-only GET. Curl arguments containing remote input live in a config -- file, never in shell syntax (also preserves percent-encoded Windows URLs). -- errorPath belongs to the engine's private per-job workspace, not the mod. -function HostShell.httpGetBounded(url, opts) +local function httpBounded(url, opts, method, postBody) opts = opts or {} - if not HostShell.canSpawnProcess() then return nil, "bounded GET requires desktop curl" end + if not HostShell.canSpawnProcess() then return nil, "bounded HTTP requires desktop curl" end if type(url) ~= "string" or #url > 8192 or url:find("[%z%c%s]") or not url:lower():match("^https?://[^/?#]+") then return nil, "invalid HTTP URL" end if opts.httpsOnly and not url:lower():match("^https://") then return nil, "HTTPS required" end @@ -406,6 +406,8 @@ function HostShell.httpGetBounded(url, opts) -- shell-visible; refuse an unsupported local pathname rather than expand it. if windows and path:find('[%%!"^]') then return nil, "unsupported characters in worker path" end local configPath = path .. ".cfg" + local bodyPath = path .. ".body" + local function cleanup() os.remove(configPath);if postBody then os.remove(bodyPath) end end local function quoted(value) value = tostring(value) if value:find("[%z%c]") then return nil end @@ -413,22 +415,42 @@ function HostShell.httpGetBounded(url, opts) end local ua, accept = quoted(opts.userAgent or "gen2recomp"), quoted(opts.accept or "*/*") if not ua or not accept then return nil, "invalid HTTP header" end - local config = table.concat({"silent", "show-error", "fail", "location", "globoff", + local lines = {"silent", "show-error", "fail", "globoff", "connect-timeout = " .. math.min(10, seconds), "max-time = " .. seconds, "max-filesize = " .. bytes, "max-redirs = 5", 'proto = "=' .. (opts.httpsOnly and "https" or "http,https") .. '"', 'proto-redir = "=' .. (opts.httpsOnly and "https" or "http,https") .. '"', "user-agent = " .. ua, "header = " .. quoted("Accept: " .. (opts.accept or "*/*")), - "stderr = " .. quoted(path), 'write-out = "\\n%{http_code}"', "url = " .. quoted(url), ""}, "\n") + "stderr = " .. quoted(path), 'write-out = "\\n%{http_code}"', "url = " .. quoted(url)} + -- Schannel does not consistently consume this curl environment setting. + -- Put the caller's trusted CA bundle in the private config on every desktop. + local ca=os.getenv('CURL_CA_BUNDLE') + if ca and ca~='' then + local value=quoted(ca);if not value then return nil,'invalid CA bundle path' end + lines[#lines+1]='cacert = '..value + end + if method == "POST" then + if type(postBody) ~= "string" or #postBody > 512 * 1024 then return nil, "POST body exceeds byte limit" end + local contentType=opts.contentType or "text/plain; charset=utf-8" + if type(contentType)~="string" or #contentType>128 or not quoted(contentType) then return nil,"invalid Content-Type" end + local data,why=io.open(bodyPath,"wb");if not data then return nil,tostring(why) end + local wrote,err=data:write(postBody);local closed=data:close() + if not wrote or not closed then cleanup();return nil,tostring(err or "POST staging failed") end + lines[#lines+1]='request = "POST"' + lines[#lines+1]="header = "..quoted("Content-Type: "..contentType) + lines[#lines+1]="data-binary = "..quoted("@"..bodyPath) + else lines[#lines+1]="location" end + lines[#lines+1]="" + local config = table.concat(lines,"\n") local f, err = io.open(configPath, "wb") - if not f then return nil, tostring(err) end + if not f then cleanup();return nil, tostring(err) end local wrote, why = f:write(config); local closed = f:close() - if not wrote or not closed then os.remove(configPath); return nil, tostring(why or "config write failed") end + if not wrote or not closed then cleanup(); return nil, tostring(why or "config write failed") end local pipe = HostShell.popen("curl --disable --config " .. HostShell.quote(configPath), windows and "rb" or "r") - if not pipe then os.remove(configPath); return nil, "could not start curl" end + if not pipe then cleanup(); return nil, "could not start curl" end local ok, body = pcall(pipe.read, pipe, bytes + 5) local closeOk, done, _, code = pcall(pipe.close, pipe) - os.remove(configPath) + cleanup() local ef = io.open(path, "rb") local complaint if ef then complaint = HostShell.curlComplaint(ef:read(4096)); ef:close() end @@ -448,4 +470,12 @@ function HostShell.httpGetBounded(url, opts) return body, nil, status end +function HostShell.httpGetBounded(url,opts)return httpBounded(url,opts,"GET")end +function HostShell.httpPostBounded(url,body,opts) + opts=opts or {} + if type(url)~="string" or not url:lower():match("^https://") then return nil,"HTTPS required" end + local bounded={};for key,value in pairs(opts)do bounded[key]=value end + bounded.httpsOnly=true + return httpBounded(url,bounded,"POST",body) +end return HostShell diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index d90803d85..2f5d69ae7 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1121,6 +1121,7 @@ function Loader:_api(mod) end return data end) + api.postLog = Net.logReporter(loader,mod) -- mod.world / mod.game / mod.storage all materialize on first touch, for -- the same reason: a headless load must not drag the world stack in, and -- the Game the facade acts on is still being wired when the entry chunk diff --git a/src/mods/LogReport.lua b/src/mods/LogReport.lua new file mode 100644 index 000000000..ec0c7a9a8 --- /dev/null +++ b/src/mods/LogReport.lua @@ -0,0 +1,18 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Json=require('src.link.Json') +local L={MAX_BODY=65536,MAX_WIRE_BODY=512*1024} +function L.prepare(owner,body,opts) + if type(body)~='string' or #body>L.MAX_BODY then return nil,'log body must be a string of at most 65536 bytes' end + if opts~=nil and (type(opts)~='table' or getmetatable(opts)) then return nil,'invalid log options' end + opts=opts or {} + for key in pairs(opts)do if key~='format' then return nil,'unknown log option' end end + local format=opts.format + if format==nil then format='text' end + if format~='text' and format~='json' then return nil,'log format must be text or json' end + if type(owner)~='string' or #owner>256 then return nil,'invalid log owner' end + if format=='text' then return body,'text/plain; charset=utf-8' end + local value=Json.encode({ts=os.time(),mod=owner,format=format,body=body}) + if #value>L.MAX_WIRE_BODY then return nil,'encoded log exceeds byte limit' end + return value,'application/json' +end +return L diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index ac5614634..8a71645bd 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -240,6 +240,16 @@ function Manifest.validate(raw, path) end end + local logUrl=raw.log_url + if logUrl~=nil then + local bad=type(logUrl)~="string" or #logUrl>8192 or logUrl:find("[%z%c%s]") + or not logUrl:lower():match("^https://[^/?#]+") + if bad or not permissionSet.network then + violation(strict,raw.id,bad and "log_url requires a valid HTTPS URL" or 'log_url requires the "network" permission') + logUrl=nil + end + end + local gameVersionOk, gameVersionErr = Semver.validRange(raw.game_version) assert(gameVersionOk, ("malformed game_version %q: %s") :format(tostring(raw.game_version), tostring(gameVersionErr))) @@ -325,6 +335,7 @@ function Manifest.validate(raw, path) affects_link = affectsLink, permissions = permissions, permissionSet = permissionSet, + log_url = logUrl, options_schema = optionalFile(raw.options_schema, "options_schema"), -- Base files the mod needs but cannot ship -- a Stadium 2 cartridge, a -- second game's ROM. Declared here, satisfied by the launcher, read by diff --git a/src/mods/Net.lua b/src/mods/Net.lua index cd0b018c3..919cdfa2e 100644 --- a/src/mods/Net.lua +++ b/src/mods/Net.lua @@ -1,6 +1,6 @@ -- Per-mod, opaque ownership over the engine's non-blocking GET transport. local Fetch = require("src.net.Fetch") -local Host = require("src.core.HostShell") +local Transport = require('src.net.BoundedHttp') local Net = { MAX_INFLIGHT = 4 } local loaders = setmetatable({}, {__mode = "k"}) @@ -17,12 +17,40 @@ local function bucket(loader, owner) end function Net.releaseAll(loader, owner) + if loader.netOneWay then loader.netOneWay[owner]=nil end local b = loader.netJobs and loader.netJobs[owner] if not b then return end for handle, id in pairs(b) do Fetch.cancel(id); Fetch.release(id); b[handle] = nil end loader.netJobs[owner] = nil end +local function submit(loader,owner,method,...) + local b,count=bucket(loader,owner),0 + for _,id in pairs(b)do if Fetch.isPending(id) then count=count+1 end end + if count>=Net.MAX_INFLIGHT then return nil,'too many requests in flight' end + local id,why=Fetch[method](...) + if not id then return nil,why end + local handle={};b[handle]=id + if method=='postLog' then + loader.netOneWay=loader.netOneWay or {};loader.netOneWay[owner]=loader.netOneWay[owner] or {} + loader.netOneWay[owner][handle]=true + end + return handle +end + +function Net.logReporter(loader,mod) + local owner,url,granted=mod.manifest.id,mod.manifest.log_url,mod.manifest.permissionSet.network + return function(_,body,opts) + if not granted then error('mod.postLog for '..owner..' needs the "network" permission',2) end + if not url then return nil,'mod has no log_url' end + local payload,kind=require('src.mods.LogReport').prepare(owner,body,opts) + if not payload then return nil,kind end + if not Transport.available() then return nil,'bounded HTTP transport unavailable' end + return submit(loader,owner,'postLog',url,payload,{userAgent='gen2recomp-mod/'..owner,contentType=kind, + maxSeconds=20,maxBytes=4*1024*1024,boundedHttpsGet=1}) + end +end + function Net.unloadAll() for loader in pairs(loaders) do for owner in pairs(loader.netJobs or {}) do Net.releaseAll(loader, owner) end @@ -38,30 +66,28 @@ function Net.forMod(loader, mod) capabilities = function() return {} end } end local api = {} - function api:available() return Host.canSpawnProcess() and Fetch.available() end + function api:available() return Transport.available() and Fetch.available() end function api:capabilities() - return Host.canSpawnProcess() and {boundedHttpsGet = 1, maxResponseBytes = require("src.net.AsyncGet").MAX_BYTES} or {} + return Transport.available() and {boundedHttpsGet = 1, maxResponseBytes = require("src.net.AsyncGet").MAX_BYTES} or {} end function api:get(url, opts) local denial = Net.urlDenial(url) if denial then return nil, denial end opts = type(opts) == "table" and opts or {} if opts.boundedHttpsGet == 1 and not url:lower():match("^https://") then return nil, "HTTPS required" end - local b, count = bucket(loader, owner), 0 - for _, id in pairs(b) do if Fetch.isPending(id) then count = count + 1 end end - if count >= Net.MAX_INFLIGHT then return nil, "too many requests in flight" end - local id, err = Fetch.get(url, { userAgent = "gen2recomp-mod/" .. owner, + return submit(loader,owner,'get',url, { userAgent = "gen2recomp-mod/" .. owner, accept = type(opts.accept) == "string" and opts.accept or nil, maxSeconds = opts.maxSeconds, maxBytes = opts.maxBytes, boundedHttpsGet = opts.boundedHttpsGet }) - if not id then return nil, err end - local handle = {}; b[handle] = id - return handle end function api:poll(handle) local id = bucket(loader, owner)[handle] if not id then return {status = "error", err = "unknown request"} end local state = Fetch.poll(id); local out = {} for key, value in pairs(state) do out[key] = value end + if loader.netOneWay and loader.netOneWay[owner] and loader.netOneWay[owner][handle] then + return {status=out.status,progress=out.progress,receivedBytes=0,httpStatus=out.httpStatus, + err=out.status=='error' and 'log upload failed' or nil} + end return out end function api:cancel(handle) @@ -72,7 +98,9 @@ function Net.forMod(loader, mod) function api:release(handle) local b = bucket(loader, owner); local id = b[handle] if not id then return false end - Fetch.cancel(id); Fetch.release(id); b[handle] = nil; return true + Fetch.cancel(id); Fetch.release(id); b[handle] = nil + if loader.netOneWay and loader.netOneWay[owner] then loader.netOneWay[owner][handle]=nil end + return true end return api end diff --git a/src/net/AsyncGet.lua b/src/net/AsyncGet.lua index e58dc1aeb..b01e966d5 100644 --- a/src/net/AsyncGet.lua +++ b/src/net/AsyncGet.lua @@ -46,9 +46,12 @@ local function drain() job.busy = false if not job.retained then jobs[msg.id] = nil elseif job.state.status == "pending" then - job.state = { status = msg.ok and "ok" or "error", body = msg.body, - err = msg.err, httpStatus = msg.httpStatus, - receivedBytes = msg.body and #msg.body or 0, progress = msg.ok and 1 or 0 } + local body=not job.oneWay and msg.body or nil + local failure=msg.err + if job.oneWay then failure=not msg.ok and 'log upload failed' or nil end + job.state = { status = msg.ok and "ok" or "error", body = body, + err = failure, httpStatus = msg.httpStatus, + receivedBytes = body and #body or 0, progress = msg.ok and 1 or 0 } end end end @@ -79,11 +82,12 @@ end function Get.available() return ensure() end -function Get.get(url, opts) +local function request(url, opts, method, body) opts = type(opts) == "table" and opts or {} if type(url) ~= "string" or #url > 8192 or url:find("[%z%c%s]") or not url:lower():match("^https?://[^/?#]+") then return nil, "invalid HTTP URL" end - if opts.boundedHttpsGet == 1 and not url:lower():match("^https://") then return nil, "HTTPS required" end + if (opts.boundedHttpsGet == 1 or method=="POST") and not url:lower():match("^https://") then return nil, "HTTPS required" end + if method=="POST" and (type(body)~="string" or #body>512*1024) then return nil,"POST body exceeds byte limit" end drain() local busy = 0 for _, job in pairs(jobs) do if job.busy then busy = busy + 1 end end @@ -98,14 +102,18 @@ function Get.get(url, opts) end local cancel = love.thread.newChannel() local deadline = now() + seconds - jobs[id] = { retained = true, busy = true, cancel = cancel, state = state, deadline = deadline } + jobs[id] = { retained = true, busy = true, cancel = cancel, state = state, deadline = deadline,oneWay=method=="POST" } commands:push({ id = id, url = url, cancel = cancel, deadline = deadline, + method=method,body=body,discardBody=method=="POST",contentType=opts.contentType, userAgent = opts.userAgent, accept = opts.accept, maxSeconds = seconds, maxBytes = math.floor(finite(opts.maxBytes, 4 * 1024 * 1024, Get.MAX_BYTES)), - httpsOnly = opts.boundedHttpsGet == 1 }) + httpsOnly = opts.boundedHttpsGet == 1 or method=="POST" }) return id end +function Get.get(url,opts)return request(url,opts,"GET")end +function Get.postLog(url,body,opts)return request(url,opts,"POST",body)end + function Get.poll(id) drain() local job = jobs[id] diff --git a/src/net/BoundedHttp.lua b/src/net/BoundedHttp.lua new file mode 100644 index 000000000..66cbf4389 --- /dev/null +++ b/src/net/BoundedHttp.lua @@ -0,0 +1,51 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Engine-only request bridge. The public mod service retains URL ownership. +local Host=require('src.core.HostShell') +local Json=require('src.link.Json') +local H={MAX_BODY=512*1024,MAX_BYTES=16*1024*1024,MAX_SECONDS=30} +function H.nativeAvailable() + local system=love and love.system + if not (system and type(system.boundedHttpVersion)=='function' and type(system.httpRequestBounded)=='function') then return false end + local ok,version=pcall(system.boundedHttpVersion) + return ok and version==1 and love.data and type(love.data.encode)=='function' and type(love.data.decode)=='function' or false +end +function H.available()return Host.canSpawnProcess() or H.nativeAvailable()end +function H.request(url,options) + local o=options or {};local method=o.method or 'GET' + if method~='GET' and method~='POST' then return nil,'unsupported HTTP method' end + if type(url)~='string' or #url>8192 or url:find('[%z%c%s]') or not url:lower():match('^https?://[^/?#]+') then return nil,'invalid HTTP URL' end + if (o.httpsOnly or method=='POST') and not url:lower():match('^https://') then return nil,'HTTPS required' end + local body=o.body + if method=='POST' and (type(body)~='string' or #body>H.MAX_BODY) then return nil,'POST body exceeds byte limit' end + local seconds=tonumber(o.maxSeconds) or 20;local bytes=tonumber(o.maxBytes) or 4*1024*1024 + if seconds~=seconds or bytes~=bytes or seconds<=0 or bytes<1 then return nil,'invalid HTTP bounds' end + seconds=math.min(seconds,H.MAX_SECONDS);bytes=math.floor(math.min(bytes,H.MAX_BYTES)) + if Host.canSpawnProcess() then + local opts={userAgent=o.userAgent,accept=o.accept,maxSeconds=seconds,maxBytes=bytes, + httpsOnly=o.httpsOnly,contentType=o.contentType,errorPath=o.errorPath} + local value,why,status + if method=='POST' then value,why,status=Host.httpPostBounded(url,body,opts) + else value,why,status=Host.httpGetBounded(url,opts) end + if o.discardBody and value~=nil then value='' end + return value,why,status + end + if not H.nativeAvailable() then return nil,'bounded native HTTP transport unavailable' end + local req={boundedVersion=1,method=method,url=url,userAgent=o.userAgent or 'gen2recomp', + accept=o.accept or '*/*',maxSeconds=seconds,maxBytes=bytes, + httpsOnly=o.httpsOnly==true or method=='POST',discardBody=o.discardBody==true, + contentType=o.contentType or 'text/plain; charset=utf-8'} + if body then req.bodyBase64=love.data.encode('string','base64',body) end + local ok,wire=pcall(love.system.httpRequestBounded,Json.encode(req)) + if not ok or type(wire)~='string' or #wire>math.ceil(bytes*4/3)+8192 then return nil,'native HTTP response failed' end + local decoded,response=pcall(Json.decode,wire) + if not decoded or type(response)~='table' then return nil,'invalid native HTTP response' end + local status=response.status + if type(status)~='number' or status%1~=0 or status<100 or status>599 then status=nil end + if response.error or not status or status<200 or status>=300 then return nil,'native HTTP request failed',status end + if o.discardBody then return '',nil,status end + if type(response.bodyBase64)~='string' or #response.bodyBase64>math.ceil(bytes/3)*4 then return nil,'native HTTP response exceeds byte limit',status end + local valid,value=pcall(love.data.decode,'string','base64',response.bodyBase64) + if not valid or #value>bytes then return nil,'invalid native HTTP body',status end + return value,nil,status +end +return H diff --git a/src/net/DownloadTransport.lua b/src/net/DownloadTransport.lua index 0a7ab0993..9f638fbe3 100644 --- a/src/net/DownloadTransport.lua +++ b/src/net/DownloadTransport.lua @@ -20,13 +20,23 @@ function M.run(job, output) if not ua or not accept then return nil, "invalid HTTP header" end local seconds = math.max(.1, math.min(job.maxSeconds, job.deadline - love.timer.getTime())) local protocol = job.httpsOnly and "https" or "http,https" - local config = table.concat({ "silent", "show-error", "fail", "location", "globoff", + local lines = { "silent", "show-error", "fail", "location", "globoff", "connect-timeout = " .. math.min(10, seconds), "max-time = " .. seconds, "max-filesize = " .. job.maxBytes, "max-redirs = 5", 'proto = "=' .. protocol .. '"', 'proto-redir = "=' .. protocol .. '"', "user-agent = " .. ua, "header = " .. quote("Accept: " .. (job.accept or "*/*")), "stderr = " .. quote(fs.getSaveDirectory() .. "/" .. errorPath), - 'write-out = "\\n%{http_code}"', "url = " .. quote(job.url), "" }, "\n") + 'write-out = "\\n%{http_code}"', "url = " .. quote(job.url) } + -- Keep explicit trust bundles consistent with the bounded request transport, + -- including Windows curl builds that do not consume CURL_CA_BUNDLE themselves. + local ca = os.getenv("CURL_CA_BUNDLE") + if ca and ca ~= "" then + local value = quote(ca) + if not value then return nil, "invalid CA bundle path" end + lines[#lines + 1] = "cacert = " .. value + end + lines[#lines + 1] = "" + local config = table.concat(lines, "\n") local wrote, why = fs.write(configPath, config) if not wrote then return nil, tostring(why or "could not write download config") end local pipe = Host.popen("curl --disable --config " .. Host.quote(absolute), diff --git a/src/net/Fetch.lua b/src/net/Fetch.lua index 8d7172dcd..b5eaa0dd2 100644 --- a/src/net/Fetch.lua +++ b/src/net/Fetch.lua @@ -17,6 +17,7 @@ function Fetch.download(url, dest, options) return start(Download, "download", url, dest, options) end function Fetch.get(url, options) return start(Get, "get", url, options) end +function Fetch.postLog(url, body, options) return start(Get, "postLog", url, body, options) end function Fetch.poll(id) local job = jobs[id] if not job then return {status = "error", err = "invalid job handle"} end diff --git a/src/net/get_worker.lua b/src/net/get_worker.lua index cf0476926..ccc85a49d 100644 --- a/src/net/get_worker.lua +++ b/src/net/get_worker.lua @@ -2,8 +2,9 @@ require("love.thread") require("love.filesystem") require("love.timer") require("love.system") +require("love.data") local commands, results, quit, token, workerIndex = ... -local Host = assert(love.filesystem.load("src/core/HostShell.lua"))() +local Transport = require("src.net.BoundedHttp") local directory = "net-get/" .. token .. "/" .. workerIndex -- The main thread creates this workspace before starting any worker. local saveDir = love.filesystem.getSaveDirectory() @@ -13,7 +14,8 @@ local function run(job) return {id = job.id, ok = false, err = "cancelled"} end local path = directory .. "/" .. job.id .. ".stderr" - local body, err, httpStatus = Host.httpGetBounded(job.url, { + local body, err, httpStatus = Transport.request(job.url, { + method=job.method,body=job.body,discardBody=job.discardBody,contentType=job.contentType, userAgent = job.userAgent, accept = job.accept, maxSeconds = math.max(0.1, math.min(job.maxSeconds, job.deadline - love.timer.getTime())), maxBytes = job.maxBytes, httpsOnly = job.httpsOnly, @@ -21,7 +23,9 @@ local function run(job) }) love.filesystem.remove(path) if job.cancel:peek() then return {id = job.id, ok = false, err = "cancelled"} end - return {id = job.id, ok = body ~= nil, body = body, err = err, httpStatus = httpStatus} + if job.discardBody then err=body==nil and 'log upload failed' or nil end + return {id = job.id, ok = body ~= nil, body = not job.discardBody and body or nil, + err = err, httpStatus = httpStatus} end while true do diff --git a/tests/engine/async_get_queue.lua b/tests/engine/async_get_queue.lua index 3ae33da2e..ecc4aa833 100644 --- a/tests/engine/async_get_queue.lua +++ b/tests/engine/async_get_queue.lua @@ -40,4 +40,12 @@ w.results:push({id=id,ok=true,body='ok'}) local state=Get.poll(id);state.status='changed' check(Get.poll(id).status=='ok','poll returns detached state') Get.release(id);Get.shutdown() +check(Get.postLog('http://host','log')==nil,'POST always requires HTTPS') +check(Get.postLog('https://host',string.rep('x',512*1024+1))==nil,'wire payload has a byte ceiling') +id=assert(Get.postLog('https://host','log',{contentType='text/plain'})) +w=workers[#workers] +w.results:push({id=id,ok=true,body='must not escape',err='must not escape',httpStatus=204}) +state=Get.poll(id) +check(state.status=='ok' and state.body==nil and state.err==nil,'one-way worker results hide body and diagnostics') +Get.release(id);Get.shutdown() print(('async GET queue: %d checks PASS'):format(checks)) diff --git a/tests/engine/bounded_http.lua b/tests/engine/bounded_http.lua new file mode 100644 index 000000000..8206e31b6 --- /dev/null +++ b/tests/engine/bounded_http.lua @@ -0,0 +1,64 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Host=require('src.core.HostShell') +local Json=require('src.link.Json') +local Http=require('src.net.BoundedHttp') +local spawn=Host.canSpawnProcess +Host.canSpawnProcess=function()return false end +local previous=love +local binary='a\0\255' +local calls,request,reply=0,nil,{status=200,bodyBase64='YQD/'} +love={system={},data={ + encode=function(kind,format,value) + assert(kind=='string' and format=='base64' and value==binary) + return 'YQD/' + end, + decode=function(kind,format,value) + assert(kind=='string' and format=='base64' and value=='YQD/') + return binary + end, +}} +T.eq(Http.available(),false,'old mobile runtime does not advertise bounded HTTP') +love.system.boundedHttpVersion=function()return 2 end +love.system.httpRequestBounded=function(wire) + calls=calls+1;request=Json.decode(wire) + if type(reply)=='string' then return reply end + return Json.encode(reply) +end +T.eq(Http.nativeAvailable(),false,'unknown wire version is not silently accepted') +love.system.boundedHttpVersion=function()return 1 end +T.eq(Http.available(),true,'matching native protocol enables the service without subprocesses') +T.eq(Http.request('https://fixture.invalid/get',{maxBytes=3}),binary,'native GET preserves binary bytes') +T.eq(request.method,'GET','GET dispatch reaches the native binding') +T.eq(request.maxBytes,3,'response bound reaches the native adapter') +T.eq(Http.request('https://fixture.invalid/log',{method='POST',body=binary,discardBody=true}),'','log success discards the native reply') +T.eq(request.bodyBase64,'YQD/','binary request uses the native wire codec') +T.eq(request.httpsOnly,true,'POST wire always enforces HTTPS') +T.eq(request.discardBody,true,'native adapter receives one-way mode') +local count=calls +T.eq(Http.request('http://fixture.invalid/log',{method='POST',body=binary}),nil,'insecure POST is refused before native submission') +T.eq(Http.request('file:///fixture'),nil,'non-HTTP target is refused before native submission') +T.eq(Http.request('https://fixture.invalid',{maxSeconds=0}),nil,'invalid deadline submits nothing') +T.eq(calls,count,'invalid requests never enter the platform transport') +reply={status=200,bodyBase64='YQD/'} +T.eq(Http.request('https://fixture.invalid',{maxBytes=2}),nil,'decoded binary cannot exceed the requested bound') +reply={status=200,bodyBase64='invalid'} +T.eq(Http.request('https://fixture.invalid'),nil,'malformed native Base64 is a failure') +reply={status=200,bodyBase64=string.rep('x',12)} +T.eq(Http.request('https://fixture.invalid',{maxBytes=3}),nil,'oversized wire body is rejected before decoding') +for _,bad in ipairs({'not JSON','[]',Json.encode({status=999}),Json.encode({status=200.5}), + Json.encode({status=200,error='private platform diagnostic',bodyBase64='YQD/'})})do + reply=bad + local body,why=Http.request('https://fixture.invalid') + T.eq(body,nil,'malformed or failed native response cannot become success') + T.check(not tostring(why):find('private',1,true),'native diagnostics do not escape the bridge') +end +reply={status=307,bodyBase64='private redirect reply'} +local body,why,status=Http.request('https://fixture.invalid/log',{method='POST',body=binary,discardBody=true}) +T.eq(body,nil,'one-way redirect remains a failure') +T.eq(status,307,'redirect status is retained without its body') +reply={status=204} +T.eq(Http.request('https://fixture.invalid/log',{method='POST',body=binary,discardBody=true}),'','empty accepted log response needs no Base64 field') +love=previous;Host.canSpawnProcess=spawn +T.finish('Bounded native HTTP wire') diff --git a/tests/engine/log_reporting.lua b/tests/engine/log_reporting.lua new file mode 100644 index 000000000..78c4088ef --- /dev/null +++ b/tests/engine/log_reporting.lua @@ -0,0 +1,91 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Manifest=require('src.mods.Manifest') +local Net=require('src.mods.Net') +local Fetch=require('src.net.Fetch') +local Transport=require('src.net.BoundedHttp') +local Log=require('src.mods.LogReport') +local Json=require('src.link.Json') +local function raw(extra) + local m={id='logmod',name='Log mod',version='1.0.0',api=2,entry='main.lua',permissions={'network'},log_url='https://logs.example.invalid/ingest'} + for k,v in pairs(extra or {})do m[k]=v end;return m +end +T.eq(Manifest.validate(raw()).log_url,'https://logs.example.invalid/ingest','manifest retains fixed destination') +for _,url in ipairs({'http://logs.invalid','https://','file:///log','https://log.invalid\nX: y',false})do + T.raises(function()Manifest.validate(raw({log_url=url}))end,'log_url','invalid destination rejected before entry') +end +T.raises(function()Manifest.validate(raw({permissions={}}))end,'network','strict declaration needs network grant') +T.eq(Manifest.validate(raw({api=1,permissions={}})).log_url,nil,'legacy warning cannot retain an ungranted endpoint') +local body='a\0\255\r\n`literal` $(literal) "quote"' +T.eq(Log.prepare('a',body),body,'text preserves arbitrary bytes') +T.eq(Log.prepare('a',string.rep('x',65536)),string.rep('x',65536),'exact input limit accepted') +T.eq(Log.prepare('a',string.rep('x',65537)),nil,'oversized input refused') +for _,opts in ipairs({false,'json',{format=false},{format=1},{format='xml'},{url='https://other.invalid'}, + {headers={}},{maxSeconds=99},setmetatable({},{__index=function()error('must not run')end})})do + T.eq(Log.prepare('a','x',opts),nil,'closed option contract rejects unknown switches and values') +end +local encoded,mime=Log.prepare('a','hello',{format='json'});local envelope=Json.decode(encoded) +T.eq(mime,'application/json','JSON MIME fixed');T.eq(envelope.mod,'a','envelope attributes owner') +T.eq(envelope.body,'hello','envelope retains body');T.eq(envelope.format,'json','envelope format') +T.eq(type(envelope.ts),'number','envelope captures one timestamp') +local original,available={},Transport.available +for _,name in ipairs({'get','postLog','poll','cancel','release','isPending'})do original[name]=Fetch[name]end +Transport.available=function()return true end +local calls,jobs,nextId={},{},0 +local function start(method,url,payload,opts) + nextId=nextId+1;jobs[nextId]={status='pending'} + calls[#calls+1]={method=method,url=url,body=payload,opts=opts,id=nextId};return nextId +end +Fetch.get=function(url,opts)return start('GET',url,nil,opts)end +Fetch.postLog=function(url,payload,opts)return start('POST',url,payload,opts)end +Fetch.poll=function(id)return jobs[id] or {status='error'}end +Fetch.cancel=function(id)if jobs[id]then jobs[id]={status='cancelled'}end end +Fetch.release=function(id)jobs[id]=nil end +Fetch.isPending=function(id)return jobs[id] and jobs[id].status=='pending'end +local loader={};local mod={manifest=Manifest.validate(raw())} +local api=Net.forMod(loader,mod);local send=Net.logReporter(loader,mod) +mod.manifest.log_url='https://changed.invalid' +local handle=assert(send(nil,body)) +local call=calls[#calls] +T.eq(call.url,'https://logs.example.invalid/ingest','runtime manifest mutation cannot choose another destination') +T.eq(call.body,body,'worker receives literal body bytes');T.eq(call.method,'POST','log uses POST') +T.eq(call.opts.userAgent,'gen2recomp-mod/logmod','caller identity cannot be overridden') +T.eq(call.opts.boundedHttpsGet,1,'HTTPS constraint is mandatory') +jobs[call.id]={status='ok',body='server private body',err='server diagnostic',raw='server private body',httpStatus=204,progress=1} +local state=api:poll(handle) +T.eq(state.status,'ok','accepted transfer result');T.eq(state.body,nil,'reply body never reaches mod') +T.eq(state.raw,nil,'one-way result is a closed public shape');T.eq(state.err,nil,'success cannot leak arbitrary diagnostics') +T.eq(state.httpStatus,204,'HTTP status remains observable');api:release(handle) +T.eq(next(loader.netOneWay.logmod),nil,'release removes private log-handle metadata') +local other=Net.forMod(loader,{manifest={id='other',permissionSet={network=true}}}) +handle=assert(send(nil,'failure'));call=calls[#calls] +jobs[call.id]={status='error',body='server secret',err='server secret'} +T.eq(api:poll(handle).err,'log upload failed','remote failures have a stable diagnostic') +T.eq(other:poll(handle).status,'error','a log handle belongs to its own mod') +api:release(handle) +local count=#calls +T.eq(send(nil,'x',{url='https://override.invalid'}),nil,'per-call destination override refused') +T.eq(#calls,count,'invalid options submit no request') +local held={} +for i=1,3 do held[i]=assert(api:get('https://data.invalid'))end +held[4]=assert(send(nil,'one more')) +T.eq(send(nil,'over quota'),nil,'GET and log share the per-mod ceiling') +Net.releaseAll(loader,'logmod') +T.eq(api:poll(held[4]).status,'error','unload invalidates log requests') +local missing=Net.logReporter(loader,{manifest={id='none',permissionSet={network=true}}}) +T.eq(missing(nil,'x'),nil,'missing destination is a normal refusal') +local denied=Net.logReporter(loader,{manifest={id='denied',permissionSet={}}}) +T.raises(function()denied(nil,'x')end,'network','ungranted call names its permission') +Transport.available=function()return false end +T.eq(send(nil,'offline'),nil,'unsupported native transport does not advertise a successful send') +Transport.available=function()return true end +local files={['mods/logmod/manifest.json']=Json.encode(raw()),['mods/logmod/main.lua']=[[ +local mod=...;mod.exports.handle=assert(mod:postLog('from actual loader'));mod.exports.fetch=mod.fetch +]]} +local loaded=T.sdk.loadMod('mods/logmod',{fs=T.sdk.memfs(files)}) +T.eq(loaded.mod.state,'loaded','actual mod loader installs postLog') +T.eq(calls[#calls].body,'from actual loader','actual entry reaches the native transport facade') +loaded.release();Net.unloadAll() +Transport.available=available;for name,method in pairs(original)do Fetch[name]=method end +T.finish('Native fixed-destination log reporting') diff --git a/tests/native_http/BoundedHttpCheck.java b/tests/native_http/BoundedHttpCheck.java new file mode 100644 index 000000000..fdbb82c46 --- /dev/null +++ b/tests/native_http/BoundedHttpCheck.java @@ -0,0 +1,69 @@ +// Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package org.love2d.android; +import java.io.*; +import java.net.*; +import java.util.*; +import org.json.JSONObject; +public final class BoundedHttpCheck { + static int checks=0;static final List seen=Collections.synchronizedList(new ArrayList<>()); + static void check(boolean value,String label) {checks++;if(!value)throw new AssertionError(label);} + static final class Connection extends HttpURLConnection { + final ByteArrayOutputStream sent=new ByteArrayOutputStream();boolean closed; + Connection(URL url) {super(url);seen.add(this);} + public void connect() {} public boolean usingProxy(){return false;} + public synchronized void disconnect(){closed=true;notifyAll();} + public OutputStream getOutputStream(){return sent;} + public int getResponseCode(){return url.getPath().startsWith("/redirect") || url.getPath().equals("/downgrade") ? 307:200;} + public String getHeaderField(String name) { + if(name.equals("Location"))return (url.getPath().equals("/downgrade")?"http":"https")+"://fixture.invalid/target"; + if(name.equals("Content-Length"))return url.getPath().equals("/partial")?"100":null; + return null; + } + public InputStream getInputStream() { + if(url.getPath().equals("/slow"))return new InputStream(){public int read()throws IOException{ + synchronized(Connection.this){while(!closed){try{Connection.this.wait(1000);}catch(InterruptedException e){throw new IOException(e);}}} + throw new IOException("closed by deadline"); + }}; + byte[] bytes=url.getPath().equals("/post")?"private server reply".getBytes():new byte[]{0,(byte)255,13,10}; + if(url.getPath().equals("/chunked"))bytes=new byte[100]; + return new ByteArrayInputStream(bytes); + } + } + static JSONObject request(String method,String path)throws Exception { + JSONObject r=new JSONObject();r.put("boundedVersion",1);r.put("method",method);r.put("url","https://fixture.invalid"+path); + r.put("httpsOnly",true);r.put("discardBody",method.equals("POST"));r.put("maxSeconds",1);r.put("maxBytes",1024); + r.put("userAgent","gen2recomp-mod/native_probe");r.put("accept","*/*");r.put("contentType","text/plain; charset=utf-8"); + if(method.equals("POST"))r.put("bodyBase64",java.util.Base64.getEncoder().encodeToString(new byte[]{0,(byte)255,13,10})); + return r; + } + static JSONObject run(JSONObject r)throws Exception{return new JSONObject(BoundedHttp.request(r));} + public static void main(String[] args)throws Exception { + URL.setURLStreamHandlerFactory(protocol -> new URLStreamHandler(){protected URLConnection openConnection(URL u){return new Connection(u);}}); + JSONObject result=run(request("GET","/binary")); + check(result.getInt("status")==200,"GET status"); + check(Arrays.equals(java.util.Base64.getDecoder().decode(result.getString("bodyBase64")),new byte[]{0,(byte)255,13,10}),"binary response"); + result=run(request("POST","/post")); + check(!result.has("bodyBase64") && !result.has("error"),"one-way response hidden"); + Connection post=seen.get(seen.size()-1); + check(Arrays.equals(post.sent.toByteArray(),new byte[]{0,(byte)255,13,10}),"POST bytes"); + check(post.getRequestProperty("User-Agent").equals("gen2recomp-mod/native_probe"),"fixed identity"); + check(post.getRequestProperty("Accept-Encoding").equals("identity"),"no implicit compression"); + result=run(request("POST","/redirect-post"));check(result.has("error"),"POST redirect refused"); + check(seen.stream().noneMatch(c -> c.getURL().getPath().equals("/target")),"POST never forwarded"); + check(!run(request("GET","/redirect-get")).has("error"),"GET redirect allowed"); + check(run(request("GET","/downgrade")).has("error"),"HTTPS downgrade refused"); + for(String path:new String[]{"/chunked","/partial"}) { + JSONObject r=request("GET",path);r.put("maxBytes",path.equals("/partial")?1024:16);result=run(r); + check(result.has("error") && !result.has("bodyBase64"),"bounded complete response"); + } + JSONObject slow=request("GET","/slow");slow.put("maxSeconds",0.1);long start=System.nanoTime();result=run(slow); + check(result.has("error") && System.nanoTime()-start<700000000L,"absolute deadline closes connection"); + for(String key:new String[]{"boundedVersion","maxBytes","httpsOnly"}) { + JSONObject bad=request("GET","/binary");bad.put(key,key.equals("httpsOnly")?1:true); + check(run(bad).has("error"),"typed wire fields"); + } + JSONObject bad=request("POST","/post");bad.put("url","http://fixture.invalid/post");check(run(bad).has("error"),"POST HTTPS required"); + bad=request("GET","/binary");bad.put("url","file:///secret");check(run(bad).has("error"),"local file rejected"); + System.out.println("Native HTTP Java: "+checks+" PASS; URLConnection fixture, not Android TLS"); + } +} diff --git a/tests/native_http/android/util/Base64.java b/tests/native_http/android/util/Base64.java new file mode 100644 index 000000000..4908d9de3 --- /dev/null +++ b/tests/native_http/android/util/Base64.java @@ -0,0 +1,8 @@ +// Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +// Host-only codec adapter. Android builds use the platform implementation. +package android.util; +public final class Base64 { + public static final int DEFAULT=0,NO_WRAP=2; + public static byte[] decode(String input,int flags) {return java.util.Base64.getMimeDecoder().decode(input);} + public static String encodeToString(byte[] input,int flags) {return java.util.Base64.getEncoder().encodeToString(input);} +} diff --git a/tests/native_http/main.swift b/tests/native_http/main.swift new file mode 100644 index 000000000..59b808b4d --- /dev/null +++ b/tests/native_http/main.swift @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +import Foundation +final class HTTPFixture: URLProtocol { + static let gate=NSLock() + static var seen: [URLRequest]=[] + static var bodies: [Data?]=[] + private var stopped=false + private let state=NSLock() + override class func canInit(with request: URLRequest) -> Bool {request.url?.host=="fixture.invalid"} + override class func canonicalRequest(for request: URLRequest) -> URLRequest {request} + override func stopLoading() {state.lock();stopped=true;state.unlock()} + override func startLoading() { + var body=request.httpBody + if body==nil, let stream=request.httpBodyStream { + stream.open();defer{stream.close()} + var collected=Data();var chunk=[UInt8](repeating:0,count:4096) + while true {let count=stream.read(&chunk,maxLength:chunk.count);if count<=0{break};collected.append(contentsOf:chunk.prefix(count))} + body=collected + } + Self.gate.lock();Self.seen.append(request);Self.bodies.append(body);Self.gate.unlock() + let path=request.url!.path + if path=="/redirect-post" || path=="/redirect-get" || path=="/downgrade" { + let target=URL(string:(path=="/downgrade" ? "http" : "https")+"://fixture.invalid/target")! + var next=request;next.url=target + let response=HTTPURLResponse(url:request.url!,statusCode:307,httpVersion:"HTTP/1.1",headerFields:["Location":target.absoluteString])! + client?.urlProtocol(self,wasRedirectedTo:next,redirectResponse:response) + return + } + if path=="/slow" { + DispatchQueue.global().asyncAfter(deadline:.now()+0.8) { + self.state.lock();let stopped=self.stopped;self.state.unlock() + if !stopped {self.reply(Data([1,2,3]),length:3)} + } + return + } + if path=="/oversized" {reply(Data(repeating:1,count:100),length:100);return} + if path=="/chunked" {reply(Data(repeating:2,count:100),length:nil);return} + if path=="/partial" {reply(Data([1,2]),length:100);return} + reply(path=="/post" ? Data("private server reply".utf8) : Data([0,255,13,10]),length:nil) + } + private func reply(_ bytes: Data,length: Int?) { + let headers=length.map{["Content-Length":String($0)]} ?? [:] + let response=HTTPURLResponse(url:request.url!,statusCode:200,httpVersion:"HTTP/1.1",headerFields:headers)! + client?.urlProtocol(self,didReceive:response,cacheStoragePolicy:.notAllowed) + client?.urlProtocol(self,didLoad:bytes);client?.urlProtocolDidFinishLoading(self) + } +} +var checks=0 +func check(_ ok: @autoclosure () -> Bool,_ label: String) {checks+=1;precondition(ok(),label)} +func wire(_ method: String="GET",_ path: String="/binary",_ extra: [String:Any]=[:]) -> String { + var req: [String:Any] = ["boundedVersion":1,"method":method,"url":"https://fixture.invalid"+path, + "httpsOnly":true,"discardBody":method=="POST","userAgent":"gen2recomp-mod/native_probe", + "accept":"*/*","contentType":"text/plain; charset=utf-8","maxSeconds":1.0,"maxBytes":1024] + if method=="POST" {req["bodyBase64"]=Data([0,255,13,10]).base64EncodedString()} + for (key,value) in extra {req[key]=value} + return String(data:try! JSONSerialization.data(withJSONObject:req),encoding:.utf8)! +} +func run(_ json: String) -> [String:Any] { + let config=URLSessionConfiguration.ephemeral;config.protocolClasses=[HTTPFixture.self] + let result=GRBoundedHTTP.perform(json,configuration:config) + return try! JSONSerialization.jsonObject(with:Data(result.utf8)) as! [String:Any] +} +let binary=run(wire()) +check((binary["status"] as? Int)==200,"GET status") +check(Data(base64Encoded:binary["bodyBase64"] as! String)==Data([0,255,13,10]),"binary reply preserved") +let post=run(wire("POST","/post")) +check((post["status"] as? Int)==200 && post["bodyBase64"]==nil && post["error"]==nil,"one-way POST discards reply") +HTTPFixture.gate.lock();let sent=HTTPFixture.seen.last!;let sentBody=HTTPFixture.bodies.last!;HTTPFixture.gate.unlock() +check(sent.httpMethod=="POST" && sentBody==Data([0,255,13,10]),"request bytes preserved") +check(sent.value(forHTTPHeaderField:"User-Agent")=="gen2recomp-mod/native_probe","fixed identity") +check(sent.value(forHTTPHeaderField:"Accept-Encoding")=="identity","no implicit transfer compression") +let redirected=run(wire("POST","/redirect-post")) +check(redirected["error"] != nil,"POST redirect refused") +HTTPFixture.gate.lock();let targets=HTTPFixture.seen.filter{$0.url?.path=="/target"}.count;HTTPFixture.gate.unlock() +check(targets==0,"POST is never forwarded to another destination") +let getRedirect=run(wire("GET","/redirect-get")) +check((getRedirect["status"] as? Int)==200,"GET follows allowed redirects") +check(run(wire("GET","/downgrade"))["error"] != nil,"HTTPS downgrade refused") +for path in ["/oversized","/chunked","/partial"] { + let result=run(wire("GET",path,["maxBytes":path=="/partial" ? 1024 : 16])) + check(result["error"] != nil && result["bodyBase64"]==nil,"bounded and complete response required") +} +let began=Date();let slow=run(wire("GET","/slow",["maxSeconds":0.1])) +check(slow["error"] != nil && Date().timeIntervalSince(began)<0.7,"absolute native deadline") +for change in [["boundedVersion":true],["maxBytes":true],["httpsOnly":1],["method":"DELETE"],["url":"file:///secret"], + ["maxSeconds":31],["userAgent":"bad\nheader"],["method":"POST","url":"http://fixture.invalid/post"]] as [[String:Any]] { + check(run(wire("GET","/binary",change))["error"] != nil,"invalid native wire refused") +} +print("Native HTTP Swift: \(checks) PASS; URLProtocol fixture, not physical TLS") diff --git a/tools/async-get-check/main.lua b/tools/async-get-check/main.lua index ca68e8911..d7015e3f5 100644 --- a/tools/async-get-check/main.lua +++ b/tools/async-get-check/main.lua @@ -34,12 +34,39 @@ function love.run() local loader = {} local function mod(id, network) return Net.forMod(loader, {manifest={id=id, permissionSet={network=network}}}) end local a, b, denied = mod('a', true), mod('b', true), mod('denied', false) + if os.getenv('GET_TEST_UNTRUSTED')=='1' then + local record={manifest={id='untrusted',permissionSet={network=true},log_url=base..'/must-not-send'}} + local logs=Net.forMod(loader,record) + local h=assert(Net.logReporter(loader,record)(nil,'untrusted certificate probe')) + local reply=await(logs,h) + check(reply.status=='error' and reply.body==nil,'untrusted TLS certificate must be refused') + logs:release(h);Net.unloadAll();Fetch.shutdown();return + end + if base:sub(1,8)=='https://' then + local function reporter(id,path) + local record={manifest={id=id,permissionSet={network=true},log_url=base..path}} + return Net.forMod(loader,record),Net.logReporter(loader,record) + end + local logs,send=reporter('log_text','/post-text') + local h=assert(send(nil,'literal\0\255\r\n$(printf injected) "quote"')) + local reply=await(logs,h) + check(reply.status=='ok' and reply.body==nil and reply.httpStatus==200,'TLS log hides real server reply') + logs:release(h) + logs,send=reporter('log_json','/post-json') + h=assert(send(nil,string.rep('\0',65536),{format='json'}));reply=await(logs,h) + check(reply.status=='ok' and reply.body==nil,'expanded JSON envelope preserves the input byte ceiling') + logs:release(h) + logs,send=reporter('log_redirect','/redirect-post') + h=assert(send(nil,'fixed destination'));reply=await(logs,h) + check(reply.status=='error' and reply.body==nil and reply.httpStatus==307,'POST cannot change its declared endpoint via redirect') + logs:release(h) + end check(not denied:available(), 'network permission required') check(not pcall(denied.get, denied, base), 'ungranted GET refuses') check(a:available(), 'real thread workers available') check(a:capabilities().maxResponseBytes >= 4194304, 'bounded receive ceiling advertised to package consumers') check(a:get('file:///etc/hosts') == nil, 'non HTTP URL denied') - check(a:get(base..'/binary', {boundedHttpsGet=1}) == nil, 'bounded HTTPS rejects HTTP') + check(a:get(base:gsub('^https:','http:')..'/binary', {boundedHttpsGet=1}) == nil, 'bounded HTTPS rejects HTTP') local started = love.timer.getTime() local slow = assert(a:get(base..'/slow', {maxSeconds=3})) check(love.timer.getTime()-started < .1, 'GET starts without waiting for server') diff --git a/tools/check_async_get.py b/tools/check_async_get.py index 0656edc60..e7cf404a2 100644 --- a/tools/check_async_get.py +++ b/tools/check_async_get.py @@ -1,8 +1,16 @@ #!/usr/bin/env python3 """Exercise real LOVE worker HTTP against only a task-owned loopback server.""" -import argparse, http.server, os, pathlib, subprocess, threading, time, tempfile, shutil, uuid +import argparse, http.server, os, pathlib, subprocess, threading, time, tempfile, shutil, uuid, ssl, json class Handler(http.server.BaseHTTPRequestHandler): def log_message(self, *args): pass + def do_POST(self): + length=int(self.headers.get('Content-Length','0')) + if length>512*1024:self.send_error(413);return + body=self.rfile.read(length) + self.server.posts.append(dict(path=self.path,body=body,agent=self.headers.get('User-Agent'),mime=self.headers.get('Content-Type'))) + if self.path=='/redirect-post': + self.send_response(307);self.send_header('Location','/forbidden-post-target');self.end_headers();return + self.send_response(200);self.send_header('Content-Length','18');self.end_headers();self.wfile.write(b'private reply body') def do_GET(self): path=self.path.split('?')[0] if path=='/slow': time.sleep(1.25) @@ -32,7 +40,9 @@ def do_GET(self): try: self.wfile.write(body) except (BrokenPipeError,ConnectionResetError,ConnectionAbortedError): pass def main(): - p=argparse.ArgumentParser();p.add_argument('--love',required=True);p.add_argument('--source-root',type=pathlib.Path);args=p.parse_args() + p=argparse.ArgumentParser();p.add_argument('--love',required=True);p.add_argument('--source-root',type=pathlib.Path) + p.add_argument('--tls',action='store_true',help='also exercise fixed-destination POST against an owned TLS fixture') + args=p.parse_args() root=pathlib.Path(__file__).resolve().parents[1] source=(args.source_root or root).resolve() executable=pathlib.Path(args.love).resolve() @@ -54,16 +64,38 @@ def main(): else: temporary=tempfile.TemporaryDirectory(prefix='gen2recomp-get-check-') project=pathlib.Path(temporary.name) - for name in ('src/net/Fetch.lua','src/net/AsyncGet.lua','src/net/get_worker.lua', + for name in ('src/net/Fetch.lua','src/net/AsyncGet.lua','src/net/get_worker.lua','src/net/BoundedHttp.lua','src/link/Json.lua','src/mods/LogReport.lua', 'src/net/AsyncDownload.lua','src/net/DownloadTransport.lua','src/net/DownloadPublish.lua','src/net/download_worker.lua', 'src/mods/Net.lua','src/core/HostShell.lua'): target=project/name;target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(source/name,target) for name in ('main.lua','conf.lua','download_test_worker.lua'):shutil.copy2(root/'tools/async-get-check'/name,project/name) - server=http.server.ThreadingHTTPServer(('127.0.0.1',0),Handler) + server=http.server.ThreadingHTTPServer(('127.0.0.1',0),Handler);server.posts=[] + cert=None + if args.tls: + config=project/'tls.cnf';cert=project/'tls.crt';key=project/'tls.key' + config.write_text('[req]\ndistinguished_name=dn\nx509_extensions=ext\nprompt=no\n[dn]\nCN=localhost\n[ext]\nsubjectAltName=IP:127.0.0.1,DNS:localhost\n') + result=run(['openssl','req','-x509','-newkey','rsa:2048','-nodes','-days','2','-config',str(config),'-keyout',str(key),'-out',str(cert)],capture_output=True,text=True,timeout=30) + result.check_returncode();key.chmod(0o600) + context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER);context.load_cert_chain(cert,key) + server.socket=context.wrap_socket(server.socket,server_side=True) thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() try: - env=os.environ.copy();env.update(GET_TEST_IDENTITY=identity,GET_TEST_URL=f'http://127.0.0.1:{server.server_port}') + env=os.environ.copy();env.update(GET_TEST_IDENTITY=identity,GET_TEST_URL=f'{"https" if args.tls else "http"}://127.0.0.1:{server.server_port}') + if cert: + untrusted=dict(env,GET_TEST_UNTRUSTED='1') + untrusted.pop('CURL_CA_BUNDLE',None) + rejected=run([str(executable),str(project)],env=untrusted,cwd=root,timeout=15) + rejected.check_returncode();assert not server.posts,'untrusted TLS sent a request body' + if cert:env['CURL_CA_BUNDLE']=str(cert) result=run([str(executable),str(project)],env=env,cwd=root,timeout=45) + if args.tls and result.returncode==0: + text=[r for r in server.posts if r['path']=='/post-text'];encoded=[r for r in server.posts if r['path']=='/post-json'] + assert len(text)==1 and text[0]['body']==b'literal\x00\xff\r\n$(printf injected) "quote"' + assert text[0]['agent']=='gen2recomp-mod/log_text' and text[0]['mime']=='text/plain; charset=utf-8' + assert len(encoded)==1 + envelope=json.loads(encoded[0]['body']);assert envelope['mod']=='log_json' and envelope['body']=='\0'*65536 and envelope['format']=='json' + assert not any(r['path']=='/forbidden-post-target' for r in server.posts),'POST followed a redirect' + print('TLS POST: exact raw bytes, JSON envelope, identity and redirect refusal PASS',flush=True) return result.returncode finally: server.shutdown();server.server_close() diff --git a/tools/qualify_native_http.py b/tools/qualify_native_http.py new file mode 100644 index 000000000..df2c1b318 --- /dev/null +++ b/tools/qualify_native_http.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Exercise the production native HTTP adapters with owned protocol fixtures.""" +# Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +import argparse,atexit,hashlib,json,os,re,subprocess,sys,time +from pathlib import Path +ROOT=Path(__file__).resolve().parents[1] +INPUTS=('mobile/ios/native/GRHTTPBridge.swift', + 'mobile/android/love/src/main/java/org/love2d/android/BoundedHttp.java', + 'tests/native_http/android/util/Base64.java','tests/native_http/main.swift', + 'tests/native_http/BoundedHttpCheck.java') +JSON_SHA256='3cf6cd6892e32e2b4c1c39e0f52f5248a2f5b37646fdfbb79a66b46b618414ed' +def main(): + p=argparse.ArgumentParser(description=__doc__) + for name in ['swiftc','sdk','ios-sdk','java-home','json-jar']:p.add_argument('--'+name,type=Path,required=True) + p.add_argument('--output',type=Path);p.add_argument('--qa-tools',type=Path) + a=p.parse_args();swift=a.swiftc.expanduser().absolute();java=a.java_home.resolve(strict=True) + sdk=a.sdk.resolve(strict=True);ios=a.ios_sdk.resolve(strict=True);jar=a.json_jar.resolve(strict=True) + if not swift.is_file() or hashlib.sha256(jar.read_bytes()).hexdigest()!=JSON_SHA256:raise ValueError('compiler or pinned test dependency invalid') + output=(a.output or ROOT/'.local/qa'/('gen2-native-mod-http-'+str(time.time_ns()))/'candidate').resolve() + run=subprocess.run + if not os.getenv('GITHUB_ACTIONS'): + if a.qa_tools:sys.path.insert(0,str(a.qa_tools.resolve(strict=True))) + from qa_storage import Storage + manager=Storage(ROOT);atexit.register(manager.close) + for source in (ROOT,swift,java,sdk,ios,jar):manager.source(source) + manager.cache(output.parent);manager.copy(output);run=manager.run + inputs={name:(ROOT/name).read_bytes() for name in INPUTS} + output.mkdir(parents=True,exist_ok=False) + for name,data in inputs.items():(output/Path(name).name).write_bytes(data) + def command(name,args): + r=run([str(v) for v in args],cwd=ROOT,capture_output=True,text=True,timeout=120) + (output/(name+'.log')).write_text(r.stdout+r.stderr) + if r.returncode:print(r.stdout+r.stderr,flush=True) + r.check_returncode();return r.stdout + command('swift-build',[swift,'-swift-version','5','-sdk',sdk,'-module-cache-path',output/'mac-modules', + output/'GRHTTPBridge.swift',output/'main.swift','-o',output/'http-swift']) + command('ios-typecheck',[swift,'-swift-version','5','-sdk',ios,'-target','arm64-apple-ios14.0', + '-module-cache-path',output/'ios-modules','-typecheck',output/'GRHTTPBridge.swift']) + command('java-build',[java/'bin/javac','-cp',jar,'-d',output/'classes', + output/'BoundedHttp.java',output/'Base64.java',output/'BoundedHttpCheck.java']) + s=command('swift-test',[output/'http-swift']) + j=command('java-test',[java/'bin/java','-cp',str(output/'classes')+os.pathsep+str(jar),'org.love2d.android.BoundedHttpCheck']) + if any((ROOT/name).read_bytes()!=data for name,data in inputs.items()):raise RuntimeError('native HTTP sources changed during qualification') + report={'passed':True,'checks':{'swift':int(re.search(r'Native HTTP Swift: (\d+) PASS',s)[1]), + 'java':int(re.search(r'Native HTTP Java: (\d+) PASS',j)[1])},'iosTypechecked':True, + 'sourceSha256':{name:hashlib.sha256(data).hexdigest() for name,data in inputs.items()}, + 'jsonTestDependencySha256':JSON_SHA256, + 'scope':'Production Foundation/URLConnection adapter logic with owned URLProtocol/URLConnection fixtures; no real mobile TLS, JNI, phone or console proof.'} + (output/'report.json').write_text(json.dumps(report,indent=2)+'\n');print(json.dumps({'output':str(output),**report}),flush=True) +if __name__=='__main__':main() From d621ced6f9c50c478f20cf83d9638b2bef08183a Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Sun, 25 Oct 2026 23:54:54 +0100 Subject: [PATCH 054/138] Frame empty TLS redirects and retain observed HTTP failure status --- src/core/HostShell.lua | 2 +- tools/async-get-check/main.lua | 2 +- tools/check_async_get.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 7aff5614b..32444e862 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -461,7 +461,7 @@ local function httpBounded(url, opts, method, postBody) if status then body = body:sub(1, -5) end if #body > bytes then return nil, "response exceeds byte limit" end if complaint then - return nil, complaint, tonumber(complaint:match("returned error: (%d%d%d)")) + return nil, complaint, tonumber(complaint:match("returned error: (%d%d%d)")) or status end if not closeOk or done == nil or (type(code) == "number" and code ~= 0) then return nil, "curl failed" diff --git a/tools/async-get-check/main.lua b/tools/async-get-check/main.lua index d7015e3f5..52e141c8a 100644 --- a/tools/async-get-check/main.lua +++ b/tools/async-get-check/main.lua @@ -58,7 +58,7 @@ function love.run() logs:release(h) logs,send=reporter('log_redirect','/redirect-post') h=assert(send(nil,'fixed destination'));reply=await(logs,h) - check(reply.status=='error' and reply.body==nil and reply.httpStatus==307,'POST cannot change its declared endpoint via redirect') + check(reply.status=='error' and reply.body==nil and reply.httpStatus==307,'POST cannot change its declared endpoint via redirect: '..tostring(reply.status)..' HTTP '..tostring(reply.httpStatus)) logs:release(h) end check(not denied:available(), 'network permission required') diff --git a/tools/check_async_get.py b/tools/check_async_get.py index e7cf404a2..634786af6 100644 --- a/tools/check_async_get.py +++ b/tools/check_async_get.py @@ -9,14 +9,14 @@ def do_POST(self): body=self.rfile.read(length) self.server.posts.append(dict(path=self.path,body=body,agent=self.headers.get('User-Agent'),mime=self.headers.get('Content-Type'))) if self.path=='/redirect-post': - self.send_response(307);self.send_header('Location','/forbidden-post-target');self.end_headers();return + self.send_response(307);self.send_header('Location','/forbidden-post-target');self.send_header('Content-Length','0');self.end_headers();return self.send_response(200);self.send_header('Content-Length','18');self.end_headers();self.wfile.write(b'private reply body') def do_GET(self): path=self.path.split('?')[0] if path=='/slow': time.sleep(1.25) if path=='/missing': self.send_error(404); return if path=='/redirect': - self.send_response(302);self.send_header('Location','/binary');self.end_headers();return + self.send_response(302);self.send_header('Location','/binary');self.send_header('Content-Length','0');self.end_headers();return if path=='/empty': self.send_response(204);self.send_header('Content-Length','0');self.end_headers();return if path=='/stream': From b8ebe739478cbb6f32e5eab2917cb1cf2b9d77b8 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:00:40 +0100 Subject: [PATCH 055/138] Implement native Gen2 radio records and wall-radio script lifecycle --- docs/native-gen2-radio.md | 49 ++++++++ src/core/ModCompat.lua | 1 + src/mods/Loader.lua | 7 +- src/mods/RadioContent.lua | 115 +++++++++++++++++++ src/mods/Schemas.lua | 8 ++ src/script/Gen2Commands.lua | 9 +- src/ui/Gen2MapRadio.lua | 60 ++++++++++ src/ui/PokegearMenu.lua | 17 ++- src/world/Gen2Radio.lua | 103 +++++++++++++++++ tests/engine/gen2_radio_content.lua | 170 ++++++++++++++++++++++++++++ tests/engine/gen2_radio_runtime.lua | 88 ++++++++++++++ 11 files changed, 622 insertions(+), 5 deletions(-) create mode 100644 docs/native-gen2-radio.md create mode 100644 src/mods/RadioContent.lua create mode 100644 src/ui/Gen2MapRadio.lua create mode 100644 src/world/Gen2Radio.lua create mode 100644 tests/engine/gen2_radio_content.lua create mode 100644 tests/engine/gen2_radio_runtime.lua diff --git a/docs/native-gen2-radio.md b/docs/native-gen2-radio.md new file mode 100644 index 000000000..844d9df63 --- /dev/null +++ b/docs/native-gen2-radio.md @@ -0,0 +1,49 @@ +# Native Gen2 radio content and wall-radio lifecycle + +The native `MapRadio` special previously only assigned a successful check +result. It now opens a native radio overlay, holds its station announcement +for 100 update frames, and resumes the suspended native script once on A/B. +Closing a covered or already closed overlay cannot pop another state or +resume the script again. The existing `setval` result is preserved. + +`mod.content.radio_channels` provides the shared native station records for +Gold, Silver and Crystal. Records accept an optional `channel` integer +0–255 and optional authored display `name`. Positive wall-radio channel +numbers must be unique. Channel zero remains the automatic Pokemon Channel: +Johto morning selects Pokedex, other Johto hours select Oak, and Kanto +selects Places & People. Region detection uses the loaded map definition +and imported Pallet landmark boundary when available. + +API 1/2 native mods can inspect detached records, patch, +override, register and remove them. The native Pokegear uses the same +records for its existing program slots; a new numbered station is available +to wall radios without inventing a Pokegear tuning frequency. An explicitly +empty dataset remains empty. Built-in names retain engine translation, +whereas authored names are displayed literally. + +Content writes are frozen after load. Failed entries roll back their earlier +writes and subscriptions. Reusing the mounted dataset on reload retires old +mod writes while preserving unrelated external edits and removals. A mod-free +load does not materialize an extra namespace. Other generations receive an unavailable content handle and cannot create +the native target. This standalone patch does not add the fork capability +query or unified API prerequisites. + +`radio.channel` is emitted on tuning, not on drawing or advancing text: +`station` is the stable station ID, `name` is its display name, `channel` is +the wall script byte or native Pokegear knob position, and `source` is +`map_radio` or `pokegear`. + +Focused synthetic acceptance: 113 content/loader checks and 73 native +VM/UI/input checks. These tests use the actual loader, bytecode lowering, +coroutine runner, native state stack and Pokegear input path. They do not +establish GPU or unchanged-mod gameplay acceptance. + +The existing native Pokegear program text is reused. Full cartridge show +state machines, schedule-dependent program data, songs and map-music +restoration are still open parity work. This change is not complete radio +parity and must receive new runtime/package/GPU qualification before release. + +Original contributions by Roxas2712 are offered to Cedric / UNDERdecoded +under the specific contribution permission in +[LICENSE-ROXAS2712.md](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). +Existing project, third-party and previously granted rights remain effective. diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..014cf6cfb 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -67,6 +67,7 @@ ModCompat.ALIASES = { -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", + ["src.ui.gen2.MapRadio"] = "src.ui.Gen2MapRadio", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..ebc79b831 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -717,7 +717,7 @@ function Loader:_contentApi(mod, registry, deprecation) if apiLevel >= 2 then error(err, 0) end Logger.warn("[%s] %s", modId, err) end - return { + local api = { register = function(_, id, value) note() validate("register", id, value) @@ -750,6 +750,10 @@ function Loader:_contentApi(mod, registry, deprecation) return registry:each() end, } + if registry.name == "radio_channels" then + return require("src.mods.RadioContent").api(loader,mod,api,loader:_generation()) + end + return api end -- mod.commands is sugar over the commands registry; the engine's own verbs @@ -1326,6 +1330,7 @@ function Loader:load(data) end end end + require("src.mods.RadioContent").prepare(self, data) -- vanilla content is registrations too, and they land before discovery so -- a mod's register collides with the engine's and has to say override require("src.mods.Builtins").install(self.content, data) diff --git a/src/mods/RadioContent.lua b/src/mods/RadioContent.lua new file mode 100644 index 000000000..083a13941 --- /dev/null +++ b/src/mods/RadioContent.lua @@ -0,0 +1,115 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Virtual native station records; failed entries never mutate a live dial. +local Merge=require("src.mods.Merge") +local Radio=require("src.world.Gen2Radio") +local M={} +local previous=setmetatable({},{__mode="k"}) +local pending=setmetatable({},{__mode="k"}) +local function equal(a,b,seen) + if a==b then return true end + if type(a)~="table" or type(b)~="table" then return false end + seen=seen or {};if seen[a]==b then return true end;seen[a]=b + for k,v in pairs(a)do if not equal(v,b[k],seen)then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +local function finite(value,seen) + if type(value)=="number" then assert(value==value and math.abs(value)0 then self.hold=self.hold-1;return end + local input=self.game.input + if input and (input:wasPressed("a") or input:wasPressed("b"))then self:close();return end + self.frames=self.frames+1 + local lines=self.program and self.program.lines or {} + if self.frames%100==0 and #lines>0 then self.line=self.line+2;if self.line>#lines then self.line=1 end end +end +function M:draw() + self.gear:textbox(0,12,18,4) + local lines=self.program and self.program.lines or {} + if self.hold>0 or #lines==0 then self.gear:text('"'..(self:name() or '')..'"',1,14,Radio.isLiteral(self.game.data,self.station)) + else self.gear:printBoxText(lines[self.line] or "",lines[self.line+1] or "")end +end +M.channelRecord=Radio.channelRecord +M.STATIONS=Radio.STATIONS +function M.registerInto(registry,data,owner) + local n=0 + for id,row in pairs(Radio.defaults())do registry:register(id,row,owner);n=n+1 end + return n +end +return M diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index f0da924a2..a3455893b 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -318,9 +318,10 @@ function PokegearMenu:drawModeArrow() love.graphics.setColor(1, 1, 1, 1) end -function PokegearMenu:text(str, tx, ty) +function PokegearMenu:text(str, tx, ty, literal) love.graphics.setColor(0, 0, 0, 1) - Font.draw(Strings(tostring(str or "")), tx * 8, ty * 8) + local text=tostring(str or "") + Font.draw(literal and text or Strings(text), tx * 8, ty * 8) love.graphics.setColor(1, 1, 1, 1) end @@ -427,6 +428,9 @@ function PokegearMenu:contacts() end function PokegearMenu:stationList() + if require("src.core.GameVersion").isGen2() then + return require("src.world.Gen2Radio").gearList(self.game.data,self.game.save,STATIONS,hasExpn(self.game.save)) + end local list = {} for _, st in ipairs(STATIONS) do list[#list + 1] = st end if hasExpn(self.game.save) then @@ -488,9 +492,11 @@ function PokegearMenu:update(dt) return end end + if card.id ~= "RADIO" then self._radioTuned=nil end if card.id == "RADIO" then local stations = self:stationList() local n = #stations + self.station = math.max(1, math.min(self.station, n)) if input:wasPressed("up") then -- pret: up winds knob toward higher frequencies (clamp, no wrap) if self.station < n then @@ -515,6 +521,11 @@ function PokegearMenu:update(dt) end end local st = stations[self.station] + if require("src.core.GameVersion").isGen2() and st and self._radioTuned~=st.id then + self._radioTuned=st.id + self.radioLine=1 + require("src.world.Gen2Radio").notify(self.game.data,st.id,st.knob,"pokegear") + end if st and st.flute then self.game.save.g2RadioChannel = "POKE_FLUTE" self.game.save.g2MapMusic = "MUSIC_POKE_FLUTE_CHANNEL" @@ -724,7 +735,7 @@ function PokegearMenu:drawRadio() -- Station name at (2,9) — UpdateRadioStation if st and st.name then - self:text(st.name, 2, 9) + self:text(st.name, 2, 9, st.nameLiteral) end -- Tuning knob marker (SPRITE_ANIM_OBJ_RADIO_TUNING_KNOB) diff --git a/src/world/Gen2Radio.lua b/src/world/Gen2Radio.lua new file mode 100644 index 000000000..f9eb6a1ef --- /dev/null +++ b/src/world/Gen2Radio.lua @@ -0,0 +1,103 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Station identities/MapRadio numbers are shared by both native radio UIs. +-- Program text stays in the existing native Pokegear implementation. +local Copy = require("src.mods.Merge").deepCopy +local R = {} +local literal=setmetatable({},{__mode="k"}) +local WALL = { + "OAKS_POKEMON_TALK", "POKEDEX_SHOW", "POKEMON_MUSIC", "LUCKY_CHANNEL", + "UNOWN_RADIO", "PLACES_AND_PEOPLE", "LETS_ALL_SING", "ROCKET_RADIO", +} +local NAMES = { + OAKS_POKEMON_TALK="OAK'S POKéMON TALK", POKEDEX_SHOW="POKéDEX SHOW", + POKEMON_MUSIC="POKéMON MUSIC", LUCKY_CHANNEL="LUCKY CHANNEL", + UNOWN_RADIO="UNOWN RADIO", PLACES_AND_PEOPLE="PLACES & PEOPLE", + LETS_ALL_SING="LET'S ALL SING", ROCKET_RADIO="ROCKET RADIO", + POKE_FLUTE_RADIO="POKé FLUTE", EVOLUTION_RADIO="EVOLUTION RADIO", + BUENAS_PASSWORD="BUENA'S PASSWORD", +} +local GEAR = { "OAKS_POKEMON_TALK", "POKEDEX_SHOW", "POKEMON_MUSIC", + "LUCKY_CHANNEL", "BUENAS_PASSWORD", "PLACES_AND_PEOPLE" } + +function R.defaults() + local rows={} + for id,name in pairs(NAMES)do rows[id]={name=name} end + for channel,id in ipairs(WALL)do rows[id].channel=channel end + return rows +end + +function R.records(data) + local rows=data and data.gen2RadioChannels + -- An explicitly empty mounted table is authoritative, including removals. + return type(rows)=="table" and rows or R.defaults() +end + +function R.literalState(data)return data and literal[data]end +function R.setLiteralState(data,state)if data then literal[data]=state end end +function R.isLiteral(data,id) + local state=data and literal[data] + return state and state[id]==true or state==nil and data and type(data.gen2RadioChannels)=="table" +end + +function R.channelRecord(data,channel) + local ids={} + for id,row in pairs(R.records(data))do + if type(row)=="table" and row.channel==channel then ids[#ids+1]=id end + end + table.sort(ids) + local id=ids[1] + return id and Copy(R.records(data)[id]),id +end + +function R.stationName(data,id) + local row=R.records(data)[id] + if type(row)~="table" then return nil end + return row.name or NAMES[id] or id +end + +function R.resolve(data,channel,map,tod) + if channel~=0 then + local _,id=R.channelRecord(data,channel) + if id then return id end + else + local maps=data and data.maps or {} + local pallet=maps.PALLET_TOWN + local boundary=tonumber(pallet and pallet.landmark) + or (require("src.core.GameVersion").get()=="crystal" and 0x2f or 0x2e) + if tonumber(map and map.landmark) and tonumber(map.landmark)>=boundary then + return R.stationName(data,"PLACES_AND_PEOPLE") and "PLACES_AND_PEOPLE" + end + if tod=="MORNING" or tod=="MORN" then + return R.stationName(data,"POKEDEX_SHOW") and "POKEDEX_SHOW" + end + end + return R.stationName(data,"OAKS_POKEMON_TALK") and "OAKS_POKEMON_TALK" +end + +function R.gearList(data,save,programs,expn) + local rows=R.records(data) + local list={} + for i,id in ipairs(GEAR)do + if type(rows[id])=="table" then + local entry=Copy(programs[i] or {}) + entry.id,entry.name,entry.nameLiteral=id,R.stationName(data,id),R.isLiteral(data,id) + list[#list+1]=entry + end + end + if expn and type(rows.POKE_FLUTE_RADIO)=="table" then + list[#list+1]={id="POKE_FLUTE_RADIO",name=R.stationName(data,"POKE_FLUTE_RADIO"),nameLiteral=R.isLiteral(data,"POKE_FLUTE_RADIO"), + knob=78,lines={"Playing the","POKé FLUTE...","A soothing melody.","..."},flute=true} + end + return list +end + +function R.notify(data,id,channel,source) + local name=id and R.stationName(data,id) + local Runtime=require("src.mods.Runtime") + if name and Runtime.wants("radio.channel") then + Runtime.emit("radio.channel",{station=id,channel=channel,name=name,source=source}) + end +end + +R.STATIONS=WALL +return R diff --git a/tests/engine/gen2_radio_content.lua b/tests/engine/gen2_radio_content.lua new file mode 100644 index 000000000..435a9b11f --- /dev/null +++ b/tests/engine/gen2_radio_content.lua @@ -0,0 +1,170 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Loader=require('src.mods.Loader') +local Json=require('src.link.Json') +local Radio=require('src.world.Gen2Radio') +local Gear=require('src.ui.PokegearMenu') +local Runtime=require('src.mods.Runtime') +local original=V.get() +local function size(t)local n=0;for _ in pairs(t or {})do n=n+1 end;return n end +local function load(data,source,api,format,extra) + -- The real hot-reload path reconstructs engine-seeded namespaces before + -- loading mods. Retain the mounted radio target under test. + for root in pairs(require('src.mods.Builtins').namespaceRoots())do data[root]=nil end + local files={} + if source then + files['mods/radio/manifest.json']=Json.encode({id='radio',name='Radio contract',version='1.0.0', + api=api or 2,content_format=format or 'native',entry='main.lua',affects_link=false}) + files['mods/radio/main.lua']='local mod=...;mod.exports.api=mod; '..source + end + for k,v in pairs(extra or {})do files[k]=v end + return T.sdk.loadMods(source and {'mods/radio'} or {},{data=data,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +local function fixture() + local d=T.fixtures.fresh();d.maps.PALLET_TOWN={landmark=47};return d +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + local d=fixture();local r=load(d) + T.eq(#r.errors,0,'no-mod load succeeds: '..version) + T.eq(d.gen2RadioChannels,nil,'no-mod virtual base creates no namespace') + T.eq(size(Radio.records(d)),11,'all native station identities are readable') + local gear=setmetatable({game={data=d,save={}}},{__index=Gear}) + T.eq(#gear:stationList(),6,'vanilla native tuner count retained') + T.eq(gear:stationList()[1].name,"OAK'S POKéMON TALK",'native program name retained') + T.eq(Radio.resolve(d,0,{landmark=1},'MORNING'),'POKEDEX_SHOW','morning Pokemon Channel') + T.eq(Radio.resolve(d,0,{landmark=1},'DAY'),'OAKS_POKEMON_TALK','day Pokemon Channel') + T.eq(Radio.resolve(d,0,{landmark=47},'MORNING'),'PLACES_AND_PEOPLE','imported Kanto boundary wins over time') + gear.game.save.flags={ENGINE_EXPN_CARD=true} + T.eq(#gear:stationList(),7,'EXPN keeps the native flute slot') + finish(r) +end +V.set('crystal') +for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture() + local r=load(d,[[ + local radio=mod.content.radio_channels + local row=assert(radio:get('OAKS_POKEMON_TALK'));row.name='BORROWED' + assert(radio:get('OAKS_POKEMON_TALK').name~='BORROWED') + for id,value in radio:each()do value.name='ITERATOR MUTATION' end + assert(radio:get('OAKS_POKEMON_TALK').name~='ITERATOR MUTATION') + radio:patch('OAKS_POKEMON_TALK',{name='AUTHORED TALK'}) + radio:register('PIRATE_RADIO',{channel=9,name='PIRATE RADIO'}) + radio:remove('POKEDEX_SHOW') + radio:patch('LUCKY_CHANNEL',{channel=11}) + mod.events:on("radio.channel",function(e) + mod.exports.event={station=e.station,channel=e.channel,name=e.name,source=e.source} + e.name='CALLBACK MUTATION' + end) + ]],mode[1],mode[2]) + T.eq(#r.errors,0,'real loader accepts radio contract '..mode[1]..'/'..mode[2]) + local e=r.loader.exports.radio + T.eq(d.gen2RadioChannels.OAKS_POKEMON_TALK.name,'AUTHORED TALK','merge reaches actual native station records') + T.eq(Radio.resolve(d,9),'PIRATE_RADIO','wall radio consumes a new registered station') + T.eq(Radio.resolve(d,11),'LUCKY_CHANNEL','wall radio consumes a changed channel number') + T.eq(Radio.channelRecord(d,4),nil,'previous channel number is not retained') + T.eq(Radio.resolve(d,0,{landmark=1},'MORNING'),nil,'removed morning station cannot resurrect') + local gear=setmetatable({game={data=d,save={}}},{__index=Gear}) + T.eq(#gear:stationList(),5,'native tuner consumes removal') + T.eq(gear:stationList()[1].name,'AUTHORED TALK','native tuner consumes authored name') + T.eq(gear:stationList()[1].nameLiteral,true,'authored text bypasses engine string catalog') + T.eq(Radio.isLiteral(d,'LUCKY_CHANNEL'),false,'channel-only patch retains built-in translation ownership') + Radio.notify(d,'PIRATE_RADIO',9,'map_radio') + T.eq(e.event.station,'PIRATE_RADIO','real mod event receives station identity') + T.eq(e.event.name,'PIRATE RADIO','real mod event receives authored station name') + T.eq(e.event.channel,9,'real mod event receives script dial number') + T.eq(e.event.source,'map_radio','real mod event identifies wall producer') + T.eq(Radio.stationName(d,'PIRATE_RADIO'),'PIRATE RADIO','callback mutation cannot edit content') + local borrowed=e.api.content.radio_channels:get('PIRATE_RADIO');borrowed.name='OUTSIDE' + T.eq(Radio.stationName(d,'PIRATE_RADIO'),'PIRATE RADIO','post-load get is detached') + T.raises(function()e.api.content.radio_channels:patch('PIRATE_RADIO',{name='LATE'})end,'frozen','station mutations freeze after load') + finish(r) + r=load(d) + T.eq(d.gen2RadioChannels,nil,'reload without mods removes generated namespace') + T.eq(Radio.resolve(d,2),'POKEDEX_SHOW','reload restores removed native station') + T.eq(Radio.channelRecord(d,9),nil,'reload removes mod-only station') + finish(r) +end +-- Failed entries must roll back earlier valid edits and event subscriptions. +for _,bad in ipairs({ + "r:register('DUPLICATE',{channel=1,name='bad'})", + "r:patch('LUCKY_CHANNEL',{channel=256})", + "r:patch('LUCKY_CHANNEL',{channel=0/0})", + "r:patch('LUCKY_CHANNEL',{name=123})", + "local cycle={};cycle.self=cycle;r:register('CYCLE',cycle)", + "r:register('ROCKET_RADIO',{channel=10})", +})do + local d=fixture();local r=load(d,"local r=mod.content.radio_channels;r:patch('OAKS_POKEMON_TALK',{name='SHOULD ROLLBACK'});"..bad) + T.eq(#r.errors,1,'malformed/ambiguous station fails one mod') + T.eq(Radio.stationName(d,'OAKS_POKEMON_TALK'),"OAK'S POKéMON TALK",'failed entry cannot leak prior valid edit') + T.eq(d.gen2RadioChannels,nil,'failed entry leaves no namespace') + finish(r) +end +-- External edits between hot reloads survive without retaining old mod rows. +local d=fixture();local r=load(d,[[ + mod.content.radio_channels:register('PIRATE_RADIO',{channel=9,name='MOD'}) + mod.content.radio_channels:patch('OAKS_POKEMON_TALK',{name='MOD TALK'}) +]]) +d.gen2RadioChannels.OAKS_POKEMON_TALK.name='EXTERNAL' +d.gen2RadioChannels.LUCKY_CHANNEL=nil +finish(r);r=load(d) +T.eq(Radio.stationName(d,'OAKS_POKEMON_TALK'),'EXTERNAL','external edit survives reload') +T.eq(Radio.channelRecord(d,9),nil,'external edit does not pin stale mod-only rows') +T.eq(d.gen2RadioChannels.LUCKY_CHANNEL,nil,'external removal survives reload') +T.eq(Radio.isLiteral(d,'OAKS_POKEMON_TALK'),true,'external authored name stays literal') +finish(r) +d=fixture();d.gen2RadioChannels={AUTHORED={channel=22,name='BASE'}} +local base=d.gen2RadioChannels.AUTHORED +r=load(d,[[ + local r=mod.content.radio_channels + local row={channel=23,name='REPLACEMENT'} + r:override('AUTHORED',row);row.name='LEAK' + r:patch('AUTHORED',{name=mod.DELETE}) + r:register('TEMP',{channel=24,name='TEMP'}) +]]) +T.eq(#r.errors,0,'override and optional field deletion are valid') +T.eq(d.gen2RadioChannels.AUTHORED.name,nil,'DELETE removes optional authored name') +T.eq(d.gen2RadioChannels.AUTHORED.channel,23,'override replaces original dial position') +T.eq(Radio.isLiteral(d,'AUTHORED'),false,'deleted authored name no longer claims literal ownership') +finish(r);r=load(d) +T.eq(d.gen2RadioChannels.AUTHORED,base,'unmodified authored base row identity is restored') +T.eq(d.gen2RadioChannels.AUTHORED.name,'BASE','reload restores original authored name') +T.eq(d.gen2RadioChannels.TEMP,nil,'reload removes temporary station from authored dataset') +finish(r) +-- A failed contribution must not roll back a preceding successful owner. +d=fixture() +local extras={ + ['mods/broken/manifest.json']=Json.encode({id='broken',name='Broken radio',version='1',api=2,entry='main.lua',dependencies={'radio'}}), + ['mods/broken/main.lua']=[[ + local mod=...;mod.content.radio_channels:patch('OAKS_POKEMON_TALK',{name='BROKEN'}) + mod.events:on("radio.channel",function()error('leaked failed listener')end) + mod.content.radio_channels:register('BAD',{channel=1}) + ]], +} +r=load(d,"mod.content.radio_channels:patch('OAKS_POKEMON_TALK',{name='GOOD'})",2,'native',extras) +T.eq(#r.errors,1,'only dependent broken contributor fails') +T.eq(Radio.stationName(d,'OAKS_POKEMON_TALK'),'GOOD','successful contribution survives later rollback') +local errors=#r.errors;Radio.notify(d,'OAKS_POKEMON_TALK',1,'map_radio') +T.eq(#r.errors,errors,'failed contributor listener is removed with its operations') +finish(r) +d=fixture();d.gen2RadioChannels={};r=load(d) +T.eq(size(Radio.records(d)),0,'explicit empty authored namespace stays authoritative') +T.eq(#setmetatable({game={data=d,save={}}},{__index=Gear}):stationList(),0,'empty authored tuner cannot resurrect defaults') +finish(r) +for _,version in ipairs({'red','emerald','platinum','black'})do + V.set(version);d=T.fixtures.fresh() + r=load(d,[[ + assert(mod.content.radio_channels:get('OAKS_POKEMON_TALK')==nil) + assert(mod.content.radio_channels:each()()==nil) + assert(mod.content.radio_channels:register('NOT_NATIVE',{channel=9})==nil) + ]],2,'native') + T.eq(#r.errors,0,'other target has honest unsupported contract: '..version) + T.eq(d.gen2RadioChannels,nil,'other generation creates no radio target') + T.eq(next(r.loader.content.radio_channels.ops),nil,'other generation appends no operation') + finish(r) +end +V.set(original) +T.finish('native radio content, consumers, target isolation and rollback') diff --git a/tests/engine/gen2_radio_runtime.lua b/tests/engine/gen2_radio_runtime.lua new file mode 100644 index 000000000..c791f3dbe --- /dev/null +++ b/tests/engine/gen2_radio_runtime.lua @@ -0,0 +1,88 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get();V.set('crystal') +require('src.core.ModCompat').install() +local Radio=require('src.world.Gen2Radio') +local Wall=require('src.ui.gen2.MapRadio') +local Gear=require('src.ui.PokegearMenu') +local VM=require('src.script.Gen2ScriptVM') +local Runner=require('src.script.ScriptRunner') +local Runtime=require('src.mods.Runtime') +local oldEvents=Runtime.events;local events=require('src.mods.Events').new();Runtime.events=events +local emitted={};events:on("radio.channel",function(e)emitted[#emitted+1]=e end) +local data=T.fixtures.fresh();require('src.render.Font').load(data) +data.maps.PALLET_TOWN={landmark=47} +data.gen2RadioChannels=Radio.defaults();data.gen2RadioChannels.TEST_RADIO={channel=9,name='TEST RADIO'} +local game={data=data,save={},stack=setmetatable({},{__index=require('src.core.StateStack')}),overworld={map={def={landmark=1}}}} +game.stack:init() +function game.overworld:timeOfDay()return 'MORNING'end +local function press(screen,key) + game.input={wasPressed=function(_,k)return k==key end,isDown=function()return false end} + screen:update(1/60) +end +local parent={name='test parent'};game.stack:push(parent) +for _,channel in ipairs({0,1,9})do + local completed=0 + local ir={map_scripts={scripts={probe={{'setval',channel},{'special',0,'MapRadio'},{'setval',77},{'end'}}}}} + local rows=assert(VM.compile(ir,'probe')) + local runner=Runner.new(game,game.overworld) + local before=#emitted + runner:run(rows,{lastCheck=false,onDone=function()completed=completed+1 end}) + local wall=game.stack:top() + T.eq(getmetatable(wall),Wall,'actual special opens the native wall UI') + T.check(runner:isRunning(),'native VM waits for wall radio input') + T.eq(runner.ctx.g2Var,channel,'opening preserves the script dial byte') + T.eq(runner.ctx.lastCheck,channel~=0,'opening retains the result of the preceding setval') + T.eq(#emitted,before+1,'actual tune emits once') + T.eq(emitted[#emitted].channel,channel,'event preserves actual script channel') + T.eq(emitted[#emitted].source,'map_radio','event identifies native wall special') + T.eq(wall.station,channel==0 and 'POKEDEX_SHOW' or channel==9 and 'TEST_RADIO' or 'OAKS_POKEMON_TALK','native imported-map/time resolution') + T.eq(game.save.g2Roamers,nil,'opening wall radio does not initialize unrelated roaming state') + wall:draw() + for _=1,100 do press(wall,'b')end + T.eq(game.stack:top(),wall,'hundred-frame station announcement ignores exit input') + T.eq(completed,0,'announcement cannot resume script early') + wall:draw() + local cover={name='cover'};game.stack:push(cover) + T.eq(wall:close(),false,'covered radio cannot pop another screen') + T.eq(completed,0,'covered radio cannot resume its parent') + T.eq(game.stack:top(),cover,'covered close preserves top owner') + game.stack:pop();press(wall,channel==9 and 'a' or 'b') + T.eq(game.stack:top(),parent,'closing returns to the existing parent') + T.eq(completed,1,'native script resumes exactly once') + T.eq(runner.ctx.g2Var,77,'real VM executes the instruction following the special') + T.eq(runner:isRunning(),false,'real script terminates') + wall:close();T.eq(completed,1,'repeated close cannot replay script continuation') + T.eq(#emitted,before+1,'draw and close do not emit extra tuning events') +end +game.overworld.map.def.landmark=47 +local wall=Wall.new(game,{channel=0}) +T.eq(wall.station,'PLACES_AND_PEOPLE','real loaded map definition selects Kanto') +local alternate={flags={ENGINE_EXPN_CARD=true}} +wall=Wall.new(game,{channel=9,save=alternate}) +T.eq(wall.game.save,alternate,'caller-owned save is retained') +T.eq(game.save==alternate,false,'alternate save does not replace player save') +data.gen2RadioChannels={} +local completed=0;local runner=Runner.new(game,game.overworld) +runner:run(assert(VM.compile({map_scripts={scripts={empty={{'setval',1},{'special',0,'MapRadio'},{'end'}}}}},'empty')), + {lastCheck=false,onDone=function()completed=completed+1 end}) +T.eq(game.stack:top(),parent,'empty station registry cannot strand an empty UI') +T.eq(completed,1,'unavailable station allows script to continue') +T.eq(runner.ctx.lastCheck,true,'unavailable station preserves preceding setval result') +data.gen2RadioChannels=Radio.defaults() +game.save.flags={ENGINE_RADIO_CARD=true} +local gear=Gear.new(game) +for i,card in ipairs(gear.cards)do if card.id=='RADIO' then gear.index=i end end +T.eq(gear:current().id,'RADIO','actual owned card selects native radio') +local before=#emitted +press(gear);press(gear) +T.eq(#emitted,before+1,'Pokegear emits initial tune once') +T.eq(emitted[#emitted].source,'pokegear','Pokegear identifies the other real producer') +press(gear,'up') +T.eq(#emitted,before+2,'changing native tuner emits once') +T.eq(emitted[#emitted].station,'POKEDEX_SHOW','tuner event carries station identity') +press(gear,'a');T.eq(#emitted,before+2,'advancing program text does not retune') +V.set('red');T.raises(function()Wall.new(game)end,'Gen2 host','wall radio rejects another host') +Runtime.events=oldEvents;V.set(original) +T.finish('native radio VM, station lifecycle, Pokegear input and events') From 33b8a5bec5c157a01311c15c573a342a2949237d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:00:58 +0100 Subject: [PATCH 056/138] Keep standalone radio registry visible to the coverage ratchet --- tests/engine/gen2_radio_content.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/engine/gen2_radio_content.lua b/tests/engine/gen2_radio_content.lua index 435a9b11f..a5fa1054e 100644 --- a/tests/engine/gen2_radio_content.lua +++ b/tests/engine/gen2_radio_content.lua @@ -46,7 +46,7 @@ V.set('crystal') for _,mode in ipairs({{1,'native'},{2,'native'}})do local d=fixture() local r=load(d,[[ - local radio=mod.content.radio_channels + local radio=mod.content["radio_channels"] local row=assert(radio:get('OAKS_POKEMON_TALK'));row.name='BORROWED' assert(radio:get('OAKS_POKEMON_TALK').name~='BORROWED') for id,value in radio:each()do value.name='ITERATOR MUTATION' end From 523bf0bdccf42d553c972fedc0b270a17884f40a Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 00:02:30 +0100 Subject: [PATCH 057/138] Pass bounded request bytes through the Android C-string bridge --- .../love/src/jni/love/src/modules/system/wrap_System.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp index 505f5a7df..024e3fa3a 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp @@ -212,7 +212,7 @@ int w_httpRequestBounded(lua_State *L) std::string envelope = "{\"boundedEnvelope\":"; envelope.append(json, size); envelope.push_back('}'); - luax_pushstring(L, instance()->httpRequest(envelope)); + luax_pushstring(L, instance()->httpRequest(envelope.c_str())); } return 1; } From a7c266fdc5cfd036519ed80ca49d9450514abaf2 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:53:10 +0100 Subject: [PATCH 058/138] Expose native Pokegear presentation and optional window-resolution UI --- docs/native-gen2-pokegear-presentation.md | 45 +++++++++++++++++ src/core/Game.lua | 9 +++- src/render/ScreenSpaceUI.lua | 54 ++++++++++++++++++++ src/ui/PokegearMenu.lua | 55 +++++++++++++++++++- tests/engine/gen2_pokegear_presentation.lua | 56 +++++++++++++++++++++ tests/engine/screen_space_ui.lua | 39 ++++++++++++++ 6 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 docs/native-gen2-pokegear-presentation.md create mode 100644 src/render/ScreenSpaceUI.lua create mode 100644 tests/engine/gen2_pokegear_presentation.lua create mode 100644 tests/engine/screen_space_ui.lua diff --git a/docs/native-gen2-pokegear-presentation.md b/docs/native-gen2-pokegear-presentation.md new file mode 100644 index 000000000..854c5c481 --- /dev/null +++ b/docs/native-gen2-pokegear-presentation.md @@ -0,0 +1,45 @@ +# Native Pokegear state and window-resolution presentation + +Actual unchanged Johto GPU inspection found a mismatch that the native +radio command/input assertions alone did not detect. The native card was +RADIO, but a source renderer asked for `card()` (or its legacy cursor), +fell back to CLOCK and painted the wrong panel. It also received the small +cartridge canvas rather than a window-resolution drawing boundary. + +The native Pokegear now exposes `card()`, `cardLabel()`, `stations()` and +`currentStation()` over its existing native selection and tuner. Station +rows carry the real native identity and knob-derived frequency. Its `radio` +view follows native program text advancement and retuning; leaving or closing +the card clears that view. This is presentation of the existing native +program text, not the complete cartridge show or song state machine. + +`drawsWidescreen()` / `drawWidescreen(width, height)` are an optional +screen-space drawing boundary. A replacement of the Pokegear's fallback +drawer opts into it; the unmodified native menu retains its original canvas. +The normal world/compositor runs first. Only the selected wide owner's small +UI drawing is omitted; native overlays above it remain on their own existing +canvas and are composited after the high-resolution owner. This works even +when a mod's `render.compose` hook owns the ordinary world composite. +Graphics state is restored around the window drawer and overlay. + +Focused synthetic tests cover native card navigation, retuning, text +advancement, borrowed-record isolation, ordinary no-mod behavior, window +owner selection, overlay ordering and graphics-state isolation. Real +unchanged-mod GPU acceptance of this correction is still pending. + +The earlier exact `e435f5ff` package passed 36 automatic checks per OpenGL +and Metal backend with actual Crystal and the unchanged Johto package. +All eight fresh OS images were inspected. Wall-radio names were upright, +but the four Pokegear images showed CLOCK instead of RADIO, so that set was +rejected as complete radio visual acceptance and was not published. Metal +deprecation overlays also remain visible at the lower edge. + +Full radio shows/audio, source phone/map facade completion, ordinary story +admission and Windows/Intel hardware GPU gameplay remain open. In particular, +the pre-existing native Snorlax special checks only EXPN; a real native VM +probe incorrectly returns true without flute music or adjacency. That story +gate requires a separate audio/ownership correction and is not fixed here. + +This standalone upstream composition has not received unchanged-mod GPU acceptance. +Original contributions by Roxas2712 are offered under the specific permission in +[LICENSE-ROXAS2712.md](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). Existing project and third-party rights remain effective. diff --git a/src/core/Game.lua b/src/core/Game.lua index 9dc07ce9d..0758a9423 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1013,6 +1013,9 @@ function Game:_draw() -- a world-bg battle keeps the map drawing under whatever it opened, so the -- world pass can run for a frame whose CLEAR is still an opaque menu's local drawFrom = Game.drawBaseInStack(self.stack, base) + local ScreenSpaceUI = require('src.render.ScreenSpaceUI') + local windowUI, windowUIIndex = ScreenSpaceUI.select(self.stack,self.stack,drawFrom) + local windowUIOverlay = false local worldDrawn = self.stack.states[drawFrom] == self.overworld -- A wide battle holds its 304px surface through every menu or prompt it -- opens. States that do not draw the wide battle composition are centred @@ -1089,7 +1092,10 @@ function Game:_draw() local ownsSurface = wideState or (native ~= nil and state == native) or Game.wantsThisSurface(state) - if state and state.draw then + if state == windowUI then + love.graphics.clear(0,0,0,0) + elseif state and state.draw then + if windowUIIndex and i > windowUIIndex then windowUIOverlay = true end -- ...and it is centred by ITS OWN size, not by the Game Boy's (see -- Game.homeOffset). classicOffset still gates it: a surface that is -- the Game Boy's centres nothing, which is every frame outside a wide @@ -1169,6 +1175,7 @@ function Game:_draw() worldZones = self.overworld:sgbWorldZones() end local viewport = Renderer:endFrame(zones, worldZones) + if windowUI then ScreenSpaceUI.draw(windowUI,Renderer,viewport,zones,windowUIOverlay) end -- Persistent tool status is screen-space UI: draw it over the completed -- render pipeline with exact playfield/margin geometry, but below mobile -- controls. It never becomes an updating game state. diff --git a/src/render/ScreenSpaceUI.lua b/src/render/ScreenSpaceUI.lua new file mode 100644 index 000000000..4ca2a5bea --- /dev/null +++ b/src/render/ScreenSpaceUI.lua @@ -0,0 +1,54 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Optional window-resolution UI. Simulation/input remain native; only an +-- explicitly advertised drawer leaves the cartridge-sized UI canvas. +local M = {} +local function visible(stack, state) + return not stack.renderVisible or stack:renderVisible(state) +end +function M.select(stack, scene, first) + for i = #scene.states, first, -1 do + local state = scene.states[i] + if visible(stack, state) and type(state.drawsWidescreen) == 'function' + and type(state.drawWidescreen) == 'function' and state:drawsWidescreen() then + return state, i + end + end +end +function M.classic(draw, renderer, width, height) + local G = love.graphics + local uw, uh = renderer:uiSize() + local p = renderer.uiPresentation + if not p then + local scale = math.max(1, math.floor(math.min(width / uw, height / uh))) + p = { x=(width-uw*scale)/2, y=(height-uh*scale)/2, scaleX=scale, scaleY=scale } + end + G.push('all') + G.origin() + G.translate(p.x+(uw-renderer.WIDTH)*p.scaleX/2, p.y+(uh-renderer.HEIGHT)*p.scaleY/2) + G.scale(p.scaleX,p.scaleY) + local ok, err = pcall(draw) + G.pop() + if not ok then error(err,0) end +end +function M.draw(owner, renderer, viewport, zones, overlay) + local G = love.graphics + G.push('all') + G.origin();G.setShader();G.setScissor();G.setColor(1,1,1,1);G.setBlendMode('alpha') + local ok, err = pcall(owner.drawWidescreen, owner, viewport.width, viewport.height) + G.pop() + if not ok then error(err,0) end + -- The classic canvas contains only states above this owner. Reapply it + -- after the high-resolution drawer so a native textbox keeps its parent. + if overlay then + local p = renderer.uiPresentation + if not p then + p = {x=viewport.gameX,y=viewport.gameY,scaleX=viewport.scale/(viewport.dpiX or 1),scaleY=viewport.scale/(viewport.dpiY or 1)} + end + G.push('all');G.origin();G.setShader();G.setColor(1,1,1,1);G.setBlendMode('alpha','premultiplied') + local overlayOK, overlayError = pcall(renderer.blitCanvas,renderer,renderer.canvas,p.scaleX,p.scaleY,zones,p.scaleX,p.scaleY, + p.x,p.y,0,0,viewport.width,viewport.height,viewport.dpiX or 1,viewport.dpiY or 1) + G.pop() + if not overlayOK then error(overlayError,0) end + end +end +return M diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index a3455893b..e6d6c8e60 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -371,6 +371,18 @@ function PokegearMenu:current() return self.cards[self.index] end +-- Source renderers observe the selected native card. Keep one cursor: +-- falling back to a separate cardIndex leaves them drawing the clock while +-- native input and callbacks are already operating the radio. +function PokegearMenu:card() + return self:current() +end + +function PokegearMenu:cardLabel(card) + card = card or self:current() + return card and Strings(card.label or card.id) or "" +end + -- The registered phone numbers, newest id last. -- -- Gen2 keeps two halves of this and the card used to read neither: which ids @@ -444,6 +456,33 @@ function PokegearMenu:stationList() return list end +-- Read-only presentation of the existing native tuner, not a second tuner +-- or a reproduction of the cartridge's complete radio show state machine. +function PokegearMenu:stations() + if not require("src.core.GameVersion").isGen2() then return {} end + local rows = self:stationList() + for _, row in ipairs(rows) do + row.station = row.id + row.frequency = ("%04.1f"):format(((row.knob or 0) + 2) / 4) + end + return rows +end + +function PokegearMenu:currentStation() + return self:stations()[self.station] +end + +function PokegearMenu:syncRadioView(station) + if not require("src.core.GameVersion").isGen2() or not station then + self.radio = nil + return + end + local view = self.radio + if not view or view.station ~= station.id then view = {}; self.radio = view end + local lines, line = station.lines or {}, self.radioLine or 1 + view.station, view.top, view.bottom = station.id, lines[line] or "", lines[line + 1] or "" +end + function PokegearMenu:update(dt) self.blink = (self.blink + dt) % 1 -- A call in progress owns the card: PokegearPhone_MakePhoneCall does not @@ -461,6 +500,7 @@ function PokegearMenu:update(dt) end local input = self.game.input if input:wasPressed("b") then + self.radio = nil Sound.play(self.game.data, "Press_AB") self.game.stack:pop() if self.onCancel then self.onCancel() end @@ -492,7 +532,7 @@ function PokegearMenu:update(dt) return end end - if card.id ~= "RADIO" then self._radioTuned=nil end + if card.id ~= "RADIO" then self._radioTuned=nil;self.radio=nil end if card.id == "RADIO" then local stations = self:stationList() local n = #stations @@ -526,6 +566,7 @@ function PokegearMenu:update(dt) self.radioLine=1 require("src.world.Gen2Radio").notify(self.game.data,st.id,st.knob,"pokegear") end + self:syncRadioView(st) if st and st.flute then self.game.save.g2RadioChannel = "POKE_FLUTE" self.game.save.g2MapMusic = "MUSIC_POKE_FLUTE_CHANNEL" @@ -935,4 +976,16 @@ function PokegearMenu:draw() love.graphics.setColor(1, 1, 1, 1) end +-- Native presentation keeps its original canvas. A replacement of this +-- optional drawer opts a skin into window-resolution composition. +local classicDraw = PokegearMenu.draw +local function drawWidescreen(self,width,height) + return require('src.render.ScreenSpaceUI').classic(function()classicDraw(self)end, + require('src.render.Renderer'),width,height) +end +PokegearMenu.drawWidescreen = drawWidescreen +function PokegearMenu:drawsWidescreen() + return self.drawWidescreen ~= drawWidescreen +end + return PokegearMenu diff --git a/tests/engine/gen2_pokegear_presentation.lua b/tests/engine/gen2_pokegear_presentation.lua new file mode 100644 index 000000000..305d80b69 --- /dev/null +++ b/tests/engine/gen2_pokegear_presentation.lua @@ -0,0 +1,56 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get();V.set('crystal') +local Gear=require('src.ui.PokegearMenu') +local Radio=require('src.world.Gen2Radio') +local data=T.fixtures.fresh();data.gen2RadioChannels=Radio.defaults() +data.gen2RadioChannels.OAKS_POKEMON_TALK.name='AUTHORED RADIO' +local stack=setmetatable({},{__index=require('src.core.StateStack')});stack:init() +local game={data=data,save={flags={ENGINE_RADIO_CARD=true}},stack=stack} +local function press(menu,key) + game.input={wasPressed=function(_,k)return k==key end,isDown=function()return false end} + menu:update(1/60) +end +-- A source renderer must observe the card reached through native input, +-- not an independent presentation cursor defaulting to CLOCK. +local menu=Gear.new(game);stack:push(menu) +T.eq(menu:card(),menu:current(),'source and native consumers share selected row') +T.eq(menu:cardLabel(),'CLOCK','source label resolves actual selected native card') +for _=1,#menu.cards do if menu:current().id~='RADIO' then press(menu,'right')end end +T.eq(menu:card().id,'RADIO','native card navigation reaches the source renderer') +local stations=menu:stations() +T.eq(stations[1].name,'AUTHORED RADIO','source tuner consumes mounted authored name') +T.eq(stations[1].station,'OAKS_POKEMON_TALK','source tuner receives stable station identity') +T.eq(stations[1].frequency,'04.5','frequency derives from existing native knob units') +T.eq(menu:currentStation().station,'OAKS_POKEMON_TALK','source selection reads native tuner cursor') +T.check(menu.radio and menu.radio.top=='PROF.OAK: Hello!','source panel sees the actual native program line') +local view=menu.radio;press(menu,'a') +T.eq(menu.radio,view,'advancing a program retains its live presentation object') +T.eq(menu.radio.top,'Wild #MON habits.','native line advancement reaches source panel') +press(menu,'up') +T.eq(menu:currentStation().station,'POKEDEX_SHOW','native retune changes source selection') +T.eq(menu.radio.station,'POKEDEX_SHOW','native retune changes the presented broadcast') +T.eq(menu.radio.top,'DJ MARY: Hi!','retune resets visible program text') +stations[1].name='LEAK';stations[1].lines[1]='LEAK' +T.eq(menu:stations()[1].name,'AUTHORED RADIO','source station rows are detached') +T.eq(menu:stations()[1].lines[1],'PROF.OAK: Hello!','source line snapshots cannot corrupt program text') +press(menu,'left');T.eq(menu.radio,nil,'leaving radio clears its active display') +press(menu,'right');T.check(menu.radio~=nil,'returning to radio restores a current program view') +press(menu,'b');T.eq(menu.radio,nil,'closing stops presenting a stale broadcast') +T.eq(stack:top(),nil,'native close retains its original stack ownership') +T.eq(menu:drawsWidescreen(),false,'unmodified native presentation stays on its original canvas') +local wide=menu.drawWidescreen +menu.drawWidescreen=function()end +T.eq(menu:drawsWidescreen(),true,'replacement window drawer opts into source presentation') +menu.drawWidescreen=wide +T.eq(menu:drawsWidescreen(),false,'removing replacement restores native composition') +data.gen2RadioChannels={};menu=Gear.new(game) +for _=1,#menu.cards do if menu:current().id~='RADIO' then press(menu,'right')end end +T.eq(#menu:stations(),0,'removed stations stay absent from source presentation') +T.eq(menu:currentStation(),nil,'empty tuner has no invented source selection') +T.eq(menu.radio,nil,'empty tuner has no fabricated broadcast') +V.set('red');menu=Gear.new(game) +T.eq(#menu:stations(),0,'other generation cannot expose native GSC stations') +V.set(original) +T.finish('native Pokegear input and source presentation agree') diff --git a/tests/engine/screen_space_ui.lua b/tests/engine/screen_space_ui.lua new file mode 100644 index 000000000..bae8bc2cc --- /dev/null +++ b/tests/engine/screen_space_ui.lua @@ -0,0 +1,39 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local UI=require('src.render.ScreenSpaceUI') +local stack=setmetatable({},{__index=require('src.core.StateStack')});stack:init() +local scene={states={}} +T.eq(UI.select(stack,scene,1),nil,'ordinary empty frame requests no window-resolution UI') +local trace={} +local parent={drawsWidescreen=function()return true end,drawWidescreen=function(_,w,h) + trace[#trace+1]='wide';T.eq(w,800,'window width reaches the advertised drawer');T.eq(h,720,'window height reaches the advertised drawer') + love.graphics.setColor(1,0,0,1) +end} +local overlay={draw=function()end} +scene.states={parent,overlay} +local owner,index=UI.select(stack,scene,1) +T.eq(owner,parent,'native overlay retains the visible wide parent') +T.eq(index,1,'wide owner position is preserved for overlay separation') +parent.drawsWidescreen=function()return false end +T.eq(UI.select(stack,scene,1),nil,'drawer alone does not opt in') +parent.drawsWidescreen=function()return true end +T.eq(UI.select(stack,scene,2),nil,'hidden below draw boundary is not resurrected') +local renderer={canvas={},WIDTH=160,HEIGHT=144,uiPresentation={x=0,y=0,scaleX=5,scaleY=5}} +function renderer:uiSize()return 160,144 end +function renderer:blitCanvas(canvas,sx,sy) + trace[#trace+1]='overlay';T.eq(canvas,self.canvas,'overlay reuses native canvas');T.eq(sx,5,'native overlay horizontal scale');T.eq(sy,5,'native overlay vertical scale') +end +local viewport={width=800,height=720,gameX=0,gameY=0,scale=5,dpiX=1,dpiY=1} +love.graphics.setColor(.2,.3,.4,1) +UI.draw(parent,renderer,viewport,nil,true) +T.same(trace,{'wide','overlay'},'native prompt is composited after its window-resolution parent') +local r,g,b=love.graphics.getColor();T.eq(r,.2,'drawer graphics state restored');T.eq(g,.3,'drawer cannot leak color');T.eq(b,.4,'overlay graphics state restored') +trace={};UI.draw(parent,renderer,viewport,nil,false) +T.same(trace,{'wide'},'frame without overlay does not duplicate the cartridge canvas') +parent.drawWidescreen=function()love.graphics.setColor(0,0,0,1);error('deliberate wide drawer failure')end +T.raises(function()UI.draw(parent,renderer,viewport,nil,false)end,'deliberate wide drawer failure','drawer errors remain observable') +T.eq(select(1,love.graphics.getColor()),.2,'failed drawer still restores graphics state') +local drew=0;UI.classic(function()drew=drew+1 end,renderer,800,720) +T.eq(drew,1,'classic fallback executes native draw exactly once') +T.finish('screen-space UI owner, native overlay ordering and graphics isolation') From fd332722acba0edad6a2d9deb13450d9a50e3ee9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:56:13 +0100 Subject: [PATCH 059/138] compat(M1): defer to native providers and avoid failed-import sentinels --- src/core/ModCompat.lua | 19 ++++++++++++++-- src/mods/Gen1Compat.lua | 13 ++++++++++- tests/engine/compat_module_aliases.lua | 30 +++++++++++++++++++++----- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index 31a1015a2..2d4109584 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -137,9 +137,23 @@ function ModCompat.install() if installed then return false end local loaders = package.loaders or package.searchers if type(loaders) ~= "table" then return false end - loaders[#loaders + 1] = function(name) + local searcher + searcher = function(name) local missing = Compatibility.unavailable(name) - if missing then return function() error(missing, 0) end end + if missing then + -- A later host/plugin provider still outranks an unsupported descriptor. + local following = false + for _, candidate in ipairs(loaders) do + if candidate == searcher then following = true + elseif following then + local provider, origin = candidate(name) + if type(provider) == "function" then return provider, origin end + end + end + -- Raise before require installs its in-progress sentinel. Retrying the + -- import must retain the diagnostic instead of reporting a fake loop. + error(missing, 0) + end if not ModCompat.ALIASES[name] then -- the standard "not found" contract: a string the require error joins return "\n\tno mod-compat alias for '" .. tostring(name) .. "'" @@ -153,6 +167,7 @@ function ModCompat.install() return value end end + loaders[#loaders + 1] = searcher installed = true return true end diff --git a/src/mods/Gen1Compat.lua b/src/mods/Gen1Compat.lua index 9004bdc62..fd427e7db 100644 --- a/src/mods/Gen1Compat.lua +++ b/src/mods/Gen1Compat.lua @@ -13,9 +13,20 @@ C.UNAVAILABLE = { ['src.core.game3.pokemon'] = { 'mystery_dungeon_follower' }, ['src.core.game3.player'] = { 'mystery_dungeon_follower' }, } +-- Concrete host modules and registered providers always take precedence. +function C.hasProvider(name) + if type(name) ~= 'string' then return false end + if type(package.loaded[name]) == 'table' or type(package.preload[name]) == 'function' then return true end + if package.searchpath then + if package.searchpath(name, package.path or '') or package.searchpath(name, package.cpath or '') then return true end + end + local path = name:gsub('%.', '/') .. '.lua' + return love and love.filesystem and love.filesystem.getInfo + and love.filesystem.getInfo(path, 'file') ~= nil or false +end function C.unavailable(name) local consumers = C.UNAVAILABLE[name] - if not consumers then return nil end + if not consumers or C.hasProvider(name) then return nil end return ('Compatibility module %s is unavailable in this engine; affected mods: %s') :format(name, table.concat(consumers, ', ')) end diff --git a/tests/engine/compat_module_aliases.lua b/tests/engine/compat_module_aliases.lua index b04529a8e..583ba2234 100644 --- a/tests/engine/compat_module_aliases.lua +++ b/tests/engine/compat_module_aliases.lua @@ -10,10 +10,30 @@ else end local names=require('src.mods.Gen1Compat') for name in pairs(names.UNAVAILABLE)do - local ok,reason=pcall(require,name) - T.check(not ok,'unsupported import fails '..name) - T.check(tostring(reason):find(name,1,true)~=nil,'failure identifies module '..name) - T.check(tostring(reason):find('mystery_dungeon_follower',1,true)~=nil,'failure identifies affected consumer '..name) - T.check(not tostring(reason):find('no file ',1,true),'failure does not masquerade as a filesystem search '..name) + if names.hasProvider(name)then + local ok,module=pcall(require,name) + T.check(ok,'concrete host provider loads '..name) + T.eq(type(module),'table','concrete host provider remains a module '..name) + T.eq(names.unavailable(name),nil,'existing provider is not marked unavailable '..name) + T.eq(require(name),module,'existing provider retains require identity '..name) + T.check(names.hasProvider(name),'provider remains available '..name) + else + local ok,reason=pcall(require,name) + T.check(not ok,'unsupported import fails '..name) + T.check(tostring(reason):find(name,1,true)~=nil,'failure identifies module '..name) + T.check(tostring(reason):find('mystery_dungeon_follower',1,true)~=nil,'failure identifies affected consumer '..name) + T.check(not tostring(reason):find('no file ',1,true),'failure does not masquerade as a filesystem search '..name) + local again,detail=pcall(require,name) + T.check(not again and reason==detail,'repeat import retains the exact diagnostic '..name) + end end + +local providers=package.loaders or package.searchers +local provided={marker='native-late-provider'} +local late=function(name)if name=='src.import.pmd.Sprite'then return function()return provided end end end +providers[#providers+1]=late +T.eq(require('src.import.pmd.Sprite'),provided,'late concrete provider outranks the missing-module descriptor') +T.check(names.hasProvider('src.import.pmd.Sprite'),'loaded provider is recognized') +T.eq(names.unavailable('src.import.pmd.Sprite'),nil,'loaded provider has no missing-module diagnostic') +table.remove(providers);package.loaded['src.import.pmd.Sprite']=nil T.finish() From 2057dd6c645fca2c31aad92a0e6aa10f9194731b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:21:40 +0100 Subject: [PATCH 060/138] Connect native Gen2 radio music and correct Snorlax admission --- docs/native-gen2-radio-audio.md | 34 ++++++++ src/script/Gen2Commands.lua | 19 ++--- src/ui/Gen2MapRadio.lua | 5 ++ src/ui/PokegearMenu.lua | 24 ++++-- src/world/Gen2RadioAudio.lua | 52 ++++++++++++ tests/engine/gen2_radio_audio.lua | 127 ++++++++++++++++++++++++++++++ 6 files changed, 244 insertions(+), 17 deletions(-) create mode 100644 docs/native-gen2-radio-audio.md create mode 100644 src/world/Gen2RadioAudio.lua create mode 100644 tests/engine/gen2_radio_audio.lua diff --git a/docs/native-gen2-radio-audio.md b/docs/native-gen2-radio-audio.md new file mode 100644 index 000000000..c02ac9859 --- /dev/null +++ b/docs/native-gen2-radio-audio.md @@ -0,0 +1,34 @@ +# Native Gen2 radio music and Snorlax admission + +The native tuner and wall-radio script now start the imported channel song +through `src.core.Music`. Retuning starts the new station once; advancing +text or drawing never restarts it. Leaving the card or removing its screen +transfers the actual playing song to the native map owner, so battle music +can restore it. A new map replaces that override normally. Unavailable or +failed channel audio produces silence and restores map music on exit; it +cannot accidentally leave a previously selected flute song active. + +The EXPN Card still controls availability of the flute station. The native +`SnorlaxAwake` special now checks actual flute playback and the player's +local map cell against the five cartridge proximity cells. Owning EXPN, +stale `g2RadioChannel`/`g2MapMusic` fields and rendering coordinates cannot +substitute for those conditions. A music selection hook replacing the flute +is respected and does not manufacture flute admission. + +`tests/engine/gen2_radio_audio.lua` exercises actual Music with a fake audio +device, native Pokegear input/StateStack and compiled Gen2 scripts through +ScriptRunner: 167 checks across Gold, Silver and Crystal. It covers all five +cells, wrong-cell refusal, real wall-radio suspension/return, retuning, +failed audio, close, battle restoration, map replacement, external music +ownership, mod-selected audio and generation isolation. + +This change covers station entry/exit and the native story gate. Complete +ROM-driven show text, timing, schedules, intermediate jingles and music-show +choices are still open. Device-free tests do not establish audible hardware +playback, original story-trigger admission, unchanged-mod GPU acceptance or +a complete playthrough. Exact-source runtime/GPU qualification is required +before inclusion in the existing preview. + +Original implementation by Roxas2712; existing upstream/third-party credits +and licenses remain applicable. See `NOTICE.md` and +`LICENSE-ROXAS2712.md`. diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index 9f870bf0c..962363f14 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -4802,19 +4802,14 @@ function Commands.g2_magnet_train(ctx) end -- `special SnorlaxAwake` ($5F). ROM checks wMapMusic == MUSIC_POKE_FLUTE_CHANNEL. --- That channel is only unlocked with the EXPN Card from Lavender Radio Tower --- (ENGINE_EXPN_CARD / event flag row $03). Gen2 has no usable Poké Flute item --- in normal play; matching ITEM_056 as "flute" was a false positive. +-- The EXPN Card unlocks the tuner; owning it alone does not wake Snorlax. +-- Admission reads actual playback and the native local cell, not stale save +-- hints or globally rebased rendering coordinates. function Commands.g2_snorlax_awake(ctx) - local save = ctx.save - local flags = save.flags or {} - local Gen2Flags = require("src.script.Gen2Flags") - local expnKey = Gen2Flags.engineFlag(3) -- EVENT_GOT_EXPN_CARD - local hasExpn = flags[expnKey] == true - or flags.EVENT_GOT_EXPN_CARD == true - or flags.ENGINE_EXPN_CARD == true - ctx.lastCheck = hasExpn and true or false - ctx.g2Var = hasExpn and 1 or 0 + local awake = require("src.world.Gen2RadioAudio").snorlaxAwake( + ctx.overworld and ctx.overworld.player) + ctx.lastCheck = awake + ctx.g2Var = awake and 1 or 0 end -- --------------------------------------------------------------------------- diff --git a/src/ui/Gen2MapRadio.lua b/src/ui/Gen2MapRadio.lua index e29a96524..f93655920 100644 --- a/src/ui/Gen2MapRadio.lua +++ b/src/ui/Gen2MapRadio.lua @@ -20,9 +20,14 @@ function M.new(game,opts) -- initialize Pokegear cards, roaming encounters or its portrait sheets. self.gear=setmetatable({game=game},{__index=Gear}) for _,row in ipairs(self.gear:stationList())do if row.id==self.station then self.program=row;break end end + if self.station then self.music=require("src.world.Gen2RadioAudio").tune(game,self.station)end Radio.notify(game.data,self.station,channel,"map_radio") return self end +function M:exit() + local playing=self.music;self.music=nil + require("src.world.Gen2RadioAudio").finish(self.game,playing) +end function M:name()return self.station and Radio.stationName(self.game.data,self.station)end function M:resolveStation(channel) local id=Radio.resolve(self.game.data,channel,self.map,self.tod) diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index e6d6c8e60..500e7ab63 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -483,6 +483,16 @@ function PokegearMenu:syncRadioView(station) view.station, view.top, view.bottom = station.id, lines[line] or "", lines[line + 1] or "" end +function PokegearMenu:exitRadioMusic() + local playing = self._radioMusic + self._radioMusic = nil + require("src.world.Gen2RadioAudio").finish(self.game, playing) +end + +function PokegearMenu:exit() + self:exitRadioMusic() +end + function PokegearMenu:update(dt) self.blink = (self.blink + dt) % 1 -- A call in progress owns the card: PokegearPhone_MakePhoneCall does not @@ -532,7 +542,10 @@ function PokegearMenu:update(dt) return end end - if card.id ~= "RADIO" then self._radioTuned=nil;self.radio=nil end + if card.id ~= "RADIO" then + self:exitRadioMusic() + self._radioTuned=nil;self.radio=nil + end if card.id == "RADIO" then local stations = self:stationList() local n = #stations @@ -564,13 +577,14 @@ function PokegearMenu:update(dt) if require("src.core.GameVersion").isGen2() and st and self._radioTuned~=st.id then self._radioTuned=st.id self.radioLine=1 + self._radioMusic=require("src.world.Gen2RadioAudio").tune(self.game,st.id) require("src.world.Gen2Radio").notify(self.game.data,st.id,st.knob,"pokegear") end - self:syncRadioView(st) - if st and st.flute then - self.game.save.g2RadioChannel = "POKE_FLUTE" - self.game.save.g2MapMusic = "MUSIC_POKE_FLUTE_CHANNEL" + if not st and self._radioTuned then + self._radioTuned=nil + self._radioMusic=require("src.world.Gen2RadioAudio").tune(self.game,nil) end + self:syncRadioView(st) elseif card.id == "PHONE" then local list = self:contacts() if self.phoneSubmenu then diff --git a/src/world/Gen2RadioAudio.lua b/src/world/Gen2RadioAudio.lua new file mode 100644 index 000000000..0327f824c --- /dev/null +++ b/src/world/Gen2RadioAudio.lua @@ -0,0 +1,52 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native station playback owns the map cue after leaving the radio. This +-- covers channel entry/exit; cartridge show-driven song changes are separate. +local Music = require("src.core.Music") +local M = {} +local SONGS = { + OAKS_POKEMON_TALK = "Music_ProfOaksPokemonTalk", + POKEDEX_SHOW = "Music_PokemonCenter", + POKEMON_MUSIC = "Music_TitleScreen", + LUCKY_CHANNEL = "Music_GameCorner", + PLACES_AND_PEOPLE = "Music_ViridianCity", + LETS_ALL_SING = "Music_Bicycle", + ROCKET_RADIO = "Music_RocketTheme", + POKE_FLUTE_RADIO = "Music_PokeFluteChannel", + UNOWN_RADIO = "Music_RuinsOfAlphRadio", + EVOLUTION_RADIO = "Music_LakeOfRageRocketRadio", + BUENAS_PASSWORD = "Music_BuenasPassword", +} +M.FLUTE = SONGS.POKE_FLUTE_RADIO + +function M.tune(game, station) + if not require("src.core.GameVersion").isGen2() then return nil end + local song = SONGS[station] + local songs = game.data and game.data.audio and game.data.audio.songs + Music.stop() + if song and songs and songs[song] then + Music.play(game.data, song) + -- Observe the actual selected cue: an audio hook can replace/silence it, + -- and a missing or failed Source must never manufacture flute playback. + local current = Music.current() + if current then return { song = current } end + end + return { restore = true } +end + +function M.finish(game, playing) + if not playing then return end + if playing.restore and Music.current() == nil then + Music.restoreMap(game.data) + elseif Music.current() == playing.song then + Music.setMapSong(playing.song) + end +end + +function M.snorlaxAwake(player) + if Music.current() ~= M.FLUTE or not player then return false end + local x, y = player.cellX, player.cellY + return (x == 33 and y == 8) or (x == 34 and y == 10) + or (x == 35 and y == 10) or (x == 36 and (y == 8 or y == 9)) +end + +return M diff --git a/tests/engine/gen2_radio_audio.lua b/tests/engine/gen2_radio_audio.lua new file mode 100644 index 000000000..d0626b092 --- /dev/null +++ b/tests/engine/gen2_radio_audio.lua @@ -0,0 +1,127 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +-- Replace only the device. Actual Music, native input, StateStack, VM and +-- ScriptRunner own every transition below. +local oldAudio=love.audio +local starts=0 +love.audio={newSource=function(file) + local s={file=file,playing=false} + function s:play()self.playing=true;starts=starts+1 end + function s:stop()self.playing=false end + function s:pause()self.playing=false end + function s:isPlaying()return self.playing end + function s:setLooping()end + function s:setVolume()end + function s:setFilter()end + return s +end} +local Music=require('src.core.Music') +local Radio=require('src.world.Gen2Radio') +local Audio=require('src.world.Gen2RadioAudio') +local Gear=require('src.ui.PokegearMenu') +local VM=require('src.script.Gen2ScriptVM') +local Runner=require('src.script.ScriptRunner') +local Stack=require('src.core.StateStack') +local function gameFor() + local data=T.fixtures.fresh();data.gen2RadioChannels=Radio.defaults() + data.audio={songs={},mapSongs={A='MAP_A',B='MAP_B'},battle={wild='BATTLE'}} + for _,id in ipairs({'MAP_A','MAP_B','BATTLE','ALT','Music_PokeFluteChannel', + 'Music_ProfOaksPokemonTalk','Music_PokemonCenter','Music_RocketTheme'})do + data.audio.songs[id]={file=id..'.ogg'} + end + local stack=setmetatable({},{__index=Stack});stack:init();stack:push({name='parent'}) + return {data=data,save={flags={ENGINE_RADIO_CARD=true,ENGINE_EXPN_CARD=true}}, + stack=stack,overworld={player={cellX=33,cellY=8},map={def={landmark=1}}}} +end +local function press(game,screen,key) + game.input={wasPressed=function(_,k)return k==key end,isDown=function()return false end} + screen:update(1/60) +end +local function open(game) + local menu=Gear.new(game);game.stack:push(menu) + for _=1,#menu.cards do if menu:current().id~='RADIO' then press(game,menu,'right')end end + return menu +end +local function flute(game,menu) + for _=1,#menu:stationList()do + if not (menu:currentStation() and menu:currentStation().flute)then press(game,menu,'up')end + end +end +local function gate(game,want,label) + local runner=Runner.new(game,game.overworld) + runner:run(assert(VM.compile({map_scripts={scripts={probe={{'special',95,'SnorlaxAwake'},{'end'}}}}},'probe'))) + T.eq(runner.ctx.g2Var,want and 1 or 0,label..' native byte') + T.eq(runner.ctx.lastCheck,want,label..' native branch') + T.eq(runner:isRunning(),false,label..' VM terminates') +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);Music.reload();local game=gameFor() + Music.playMap(game.data,'A',false,false) + gate(game,false,version..' EXPN without music refuses') + game.save.g2RadioChannel='POKE_FLUTE';game.save.g2MapMusic='MUSIC_POKE_FLUTE_CHANNEL' + gate(game,false,version..' stale save hints refuse') + local menu=open(game) + T.eq(Music.current(),'Music_ProfOaksPokemonTalk',version..' real native tuner starts its station') + local count=starts;press(game,menu);press(game,menu,'a') + T.eq(starts,count,version..' idle/text advancement does not restart audio') + flute(game,menu) + T.eq(Music.current(),Audio.FLUTE,version..' tuner plays actual flute Source') + T.eq(Music.mapSong(),'MAP_A',version..' open tuner retains previous map owner') + for _,cell in ipairs({{33,8},{34,10},{35,10},{36,8},{36,9}})do + game.overworld.player.cellX,game.overworld.player.cellY=cell[1],cell[2] + gate(game,true,version..' each native adjacent cell') + end + game.overworld.player.cellX,game.overworld.player.cellY=0,0 + game.overworld.player.x,game.overworld.player.y=33,8 + gate(game,false,version..' render coordinates cannot bypass local proximity') + game.overworld.player.cellX,game.overworld.player.cellY=33,8 + press(game,menu,'b') + T.eq(Music.mapSong(),Audio.FLUTE,version..' normal close transfers radio ownership') + T.eq(Music.current(),Audio.FLUTE,version..' normal close keeps flute playing') + Music.playBattle(game.data,'wild');gate(game,false,version..' battle music cannot wake') + Music.restoreMap(game.data);gate(game,true,version..' battle restoration retains tuned flute') + Music.playMap(game.data,'B',false,false) + T.eq(Music.mapSong(),'MAP_B',version..' next map replaces radio owner') + gate(game,false,version..' next map cannot retain stale flute admission') + menu=open(game);flute(game,menu);press(game,menu,'down') + T.eq(Music.current(),nil,version..' missing channel audio cannot leave flute sounding') + gate(game,false,version..' failed retune cannot keep flute admission') + press(game,menu,'b');T.eq(Music.current(),'MAP_B',version..' failed station restores map on exit') + game.save.flags.ENGINE_EXPN_CARD=nil;menu=open(game) + for _,station in ipairs(menu:stationList())do T.eq(station.flute,nil,version..' flute stays locked without EXPN')end + press(game,menu,'b') +end +V.set('crystal');Music.reload();local game=gameFor();Music.playMap(game.data,'A') +local menu=open(game);flute(game,menu);Music.play(game.data,'ALT');press(game,menu,'b') +T.eq(Music.current(),'ALT','external playback is not replaced when closing covered owner') +T.eq(Music.mapSong(),'MAP_A','external owner prevents stale flute transfer') +-- A mod's music selection hook remains authoritative and does not become a +-- forged flute song just because the dial points at that channel. +local Runtime=require('src.mods.Runtime');local oldHooks=Runtime.hooks +Runtime.hooks=require('src.mods.Hooks').new() +Runtime.hooks:wrap('music.select',function(nextSong,song,ctx) + local selected=nextSong(song,ctx);return selected==Audio.FLUTE and 'ALT' or selected +end,nil,'radio-audio-test') +menu=open(game);flute(game,menu) +T.eq(Music.current(),'ALT','station tuning respects actual hook-selected audio') +gate(game,false,'hook replacement is not flute playback') +press(game,menu,'b');T.eq(Music.mapSong(),'ALT','actual selected song transfers to map owner') +Runtime.hooks=oldHooks +Music.playMap(game.data,'A') +local runner=Runner.new(game,game.overworld) +runner:run(assert(VM.compile({map_scripts={scripts={wall={{'setval',8},{'special',40,'MapRadio'},{'setval',77},{'end'}}}}},'wall'))) +local wall=game.stack:top() +T.eq(Music.current(),'Music_RocketTheme','native imported wall-radio special plays its channel') +T.eq(runner:isRunning(),true,'wall music does not bypass native announcement wait') +for _=1,100 do press(game,wall)end +press(game,wall,'b') +T.eq(runner.ctx.g2Var,77,'wall close resumes native script after transferring music') +T.eq(Music.mapSong(),'Music_RocketTheme','wall close transfers actual channel music') +T.eq(Music.current(),'Music_RocketTheme','wall song continues after close') +local current=Music.current();V.set('red') +T.eq(Audio.tune(game,'POKE_FLUTE_RADIO'),nil,'other generations reject native station tuning') +T.eq(Music.current(),current,'generation rejection leaves current audio intact') +Music.reload();love.audio=oldAudio;V.set(original) +T.finish('native radio audio ownership and actual Snorlax VM admission') From 746511478bd0977997abb69a99d053079f45d47b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:24:01 +0100 Subject: [PATCH 061/138] Include native map-cue accessors for standalone radio ownership --- src/core/Music.lua | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/core/Music.lua b/src/core/Music.lua index e5b95baf6..01b2ef2ad 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -380,6 +380,16 @@ function Music.current() return state.current end +-- Original contribution by Roxas2712; see the offered PR's contribution +-- permission. Radio ownership changes the restorable cue, not playback. +function Music.mapSong() + return state.mapSong +end + +function Music.setMapSong(song) + state.mapSong = song +end + function Music.stop() local previous = state.current stopSource(state.source) From eca35240aaf6fb794dfee4cb879fa3e59b3eb1a8 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:25:26 +0100 Subject: [PATCH 062/138] Link upstream radio contribution permission explicitly --- docs/native-gen2-radio-audio.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/native-gen2-radio-audio.md b/docs/native-gen2-radio-audio.md index c02ac9859..e494dd9dd 100644 --- a/docs/native-gen2-radio-audio.md +++ b/docs/native-gen2-radio-audio.md @@ -30,5 +30,5 @@ a complete playthrough. Exact-source runtime/GPU qualification is required before inclusion in the existing preview. Original implementation by Roxas2712; existing upstream/third-party credits -and licenses remain applicable. See `NOTICE.md` and -`LICENSE-ROXAS2712.md`. +and licenses remain applicable. Specific contribution permission and required credits: +. From 5507ef2f5f5a4b869470d7861ad6fec39daa1422 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:47:27 +0100 Subject: [PATCH 063/138] perf(P1): skip redundant software pacing after VSync presentation --- docs/frame-pacing.md | 47 +++++++++ main.lua | 23 +---- src/core/FrameCap.lua | 44 +++++++++ tests/engine/frame_pacing.lua | 153 ++++++++++++++++++++++++++++++ tools/check_frame_pacing.py | 50 ++++++++++ tools/frame-pacing-check/conf.lua | 7 ++ tools/frame-pacing-check/main.lua | 94 ++++++++++++++++++ 7 files changed, 400 insertions(+), 18 deletions(-) create mode 100644 docs/frame-pacing.md create mode 100644 tests/engine/frame_pacing.lua create mode 100644 tools/check_frame_pacing.py create mode 100644 tools/frame-pacing-check/conf.lua create mode 100644 tools/frame-pacing-check/main.lua diff --git a/docs/frame-pacing.md b/docs/frame-pacing.md new file mode 100644 index 000000000..d3b291cf2 --- /dev/null +++ b/docs/frame-pacing.md @@ -0,0 +1,47 @@ +# P1: VSync and the software deadline + +The run loop records whether it actually presented a frame. After presentation, +`FrameCap.pace` skips the software wait when VSync is active and the FPS cap is +at least the reported display refresh rate. Without presentation, with VSync +off, with an unknown rate, or with a lower FPS cap, the existing carried +deadline, one-millisecond sleep chunks and stall re-anchor remain active. + +The default-on rollback switch is independent of all other performance work: + +```lua +require("src.core.FrameCap").vsyncPacing = false +``` + +Display flags are sampled at most once per second to avoid allocating their +table each frame. Provider/VSync changes invalidate that sample; other display +rate changes are observed on the next periodic sample. Existing option values, +simulation timing, mod interfaces and callback call sites are unchanged. + +`frame pacing: sleep` in the existing F11 FrameProfile measures the helper, +including its software wait. `present (GPU wait / vsync)` retains its original +scope. Removing a redundant software wait can move wall time into present +without changing FPS. + +The owned-window probe runs the real helper and FrameProfile with the switch +off/on, each after 120 warm-up frames and over 120 measured frames. Run through +the guarded launcher: + +```sh +python3 tools/check_frame_pacing.py --love /path/to/love --report /path/to/owned/qa/report.json +``` + +An older public checkout can use `--qa-tools /path/to/fork/tools`. The launcher +pins inputs, uses a separate QA identity, retains raw profile logs and records +SHA-256 hashes of the actual measured source files alongside the Git parent. + +Observed macOS/arm64, 60-Hz display, VSync 1, cap 60: + +| Backend | Software wait off/on, ms/frame | Present off/on, ms/frame | CPU off/on, ms/frame | +| --- | ---: | ---: | ---: | +| LÖVE 11.5 OpenGL | 0.00 / 0.01 | 16.59 / 16.54 | 0.672 / 0.790 | +| LÖVE 12 Metal | 16.43 / 0.01 | 0.13 / 16.56 | 0.769 / 0.629 | + +Both enabled runs made zero extra sleep calls and stayed near 60 FPS, with no +measured interval exceeding 1.5 display periods. These short blank-window +checks establish the pacing decision and its profiler scope. They do not +establish a universal CPU/FPS gain, loaded-world behavior, or handheld results. diff --git a/main.lua b/main.lua index 18325ffdf..3b1f7c1ac 100644 --- a/main.lua +++ b/main.lua @@ -1261,12 +1261,14 @@ function love.run() closeU() end + local presented = false if love.graphics and love.graphics.isActive() then love.graphics.origin() love.graphics.clear(love.graphics.getBackgroundColor()) if love.draw then love.draw() end local closeP = FrameProfile.section("present (GPU wait / vsync)") love.graphics.present() + presented = true closeP() end -- Counted after present, so a frame in the trace means a frame that was @@ -1299,24 +1301,9 @@ function love.run() if love.timer then 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 - nextFrame = nextFrame + budget - local now = love.timer.getTime() - -- A stall (alt-tab, a GC pause, a blocked import) can leave the - -- deadline more than a full budget in the past; re-anchor to now so - -- we pace the next frame rather than burst uncapped to catch up. - if now - nextFrame > budget then - nextFrame = now - 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) - end + local closeSleep = FrameProfile.section("frame pacing: sleep") + nextFrame = FrameCap.pace(love.timer, love.window, presented, nextFrame) + closeSleep() else love.timer.sleep(0.001) end diff --git a/src/core/FrameCap.lua b/src/core/FrameCap.lua index 2e5b36feb..09dbd7e98 100644 --- a/src/core/FrameCap.lua +++ b/src/core/FrameCap.lua @@ -25,6 +25,50 @@ FrameCap.DEFAULT = 60 -- save editor are paced before any save applies its stored option. FrameCap.current = FrameCap.DEFAULT +-- P1 rollback switch. Each frame still falls back to software pacing when +-- there was no present, VSync is off, or the display rate is unavailable. +FrameCap.vsyncPacing = true +local _refreshWindow, _refreshVsync, _refreshAt, _refreshRate +local REFRESH_POLL_SECONDS = 1 + +local function presentationPaces(window, now) + if not (window and window.getVSync and window.getMode) then return false end + local vsync = window.getVSync() + if type(vsync) ~= "number" or vsync == 0 then + _refreshVsync = vsync + return false + end + -- getMode returns a new flags table: sample it once a second rather than + -- allocate one each frame. Provider/VSync changes invalidate the sample; + -- display-rate changes are observed on the next periodic sample. + if window ~= _refreshWindow or vsync ~= _refreshVsync or not _refreshAt + or now < _refreshAt or now - _refreshAt >= REFRESH_POLL_SECONDS then + local _, _, flags = window.getMode() + local rate = type(flags) == "table" and tonumber(flags.refreshrate) + _refreshWindow, _refreshVsync, _refreshAt = window, vsync, now + _refreshRate = rate and rate > 0 and rate < math.huge and rate or nil + end + return _refreshRate ~= nil and FrameCap.current >= _refreshRate +end + +-- The run loop owns the deadline and passes back the returned value. Keeping +-- the original fallback here lets tests exercise the real pacing operation. +function FrameCap.pace(timer, window, presented, nextFrame) + local now = timer.getTime() + if FrameCap.vsyncPacing and presented and presentationPaces(window, now) then + return now + end + local budget = 1 / FrameCap.current + nextFrame = nextFrame + budget + if now - nextFrame > budget then nextFrame = now end + while true do + local remaining = nextFrame - timer.getTime() + if remaining <= 0 then break end + timer.sleep(remaining < 0.001 and remaining or 0.001) + end + return nextFrame +end + -- Nearest valid step for an arbitrary value (a hand-edited options.lua or -- an old save with no fpsCap key), so a bad number degrades to something -- sane; nil / non-numbers fall back to the default. A value below MIN or diff --git a/tests/engine/frame_pacing.lua b/tests/engine/frame_pacing.lua new file mode 100644 index 000000000..27a96b39e --- /dev/null +++ b/tests/engine/frame_pacing.lua @@ -0,0 +1,153 @@ +local T = require("tests.harness") +local Cap = require("src.core.FrameCap") +local function clock(start) + local now, sleeps, total = start or 0, 0, 0 + return { + getTime = function() + return now + end, + sleep = function(dt) + now = now + dt + sleeps = sleeps + 1 + total = total + dt + end, + advance = function(dt) + now = now + dt + end, + sleeps = function() + return sleeps, total + end, + } +end +local function window(sync, rate) + local calls = 0 + return { + getVSync = function() + return sync + end, + getMode = function() + calls = calls + 1 + return 320, 240, { refreshrate = rate } + end, + modeCalls = function() + return calls + end, + set = function(s, r) + sync, rate = s, r + end, + } +end +Cap.current = 60 +T.eq(Cap.vsyncPacing, true, "P1 switch defaults on") +for _, sync in ipairs({ 1, -1, 2 }) do + local timer, w = clock(), window(sync, 60) + local nextFrame = 0 + for _ = 1, 60 do + timer.advance(1 / 60) + nextFrame = Cap.pace(timer, w, true, nextFrame) + end + T.eq(timer.sleeps(), 0, "active VSync at the cap receives no additional sleep") + T.eq(w.modeCalls(), 1, "display flags are not allocated each frame") +end +for _, case in ipairs({ + { sync = 0, rate = 60, presented = true }, + { sync = 1, rate = 144, presented = true }, + { sync = 1, rate = 0, presented = true }, + { sync = 1, rate = 60, presented = false }, +}) do + local timer, w = clock(), window(case.sync, case.rate) + local deadline = Cap.pace(timer, w, case.presented, 0) + T.check(timer.sleeps() > 0, "software cap remains for disabled/unknown/faster/unpresented cases") + T.check(math.abs(deadline - 1 / 60) < 1e-9, "software deadline remains one frame ahead") +end +local timer, w = clock(), window(1, 60) +Cap.vsyncPacing = false +Cap.pace(timer, w, true, 0) +T.check(timer.sleeps() > 0, "P1 switch restores software pacing") +T.eq(w.modeCalls(), 0, "disabled P1 does not query display flags") +Cap.vsyncPacing = true +timer, w = clock(), window(1, 60) +local deadline = Cap.pace(timer, w, true, 0) +w.set(0, 60) +deadline = Cap.pace(timer, w, true, deadline) +T.check(math.abs(deadline - 1 / 60) < 1e-9, "turning VSync off resumes the carried software deadline") +timer.advance(1) +deadline = Cap.pace(timer, w, true, deadline) +T.eq(deadline, timer.getTime(), "stalls re-anchor instead of accumulating catch-up frames") +deadline = Cap.pace(timer, w, true, deadline) +T.eq(deadline, timer.getTime(), "software pacing resumes after a stall") +timer, w = clock(), window(1, 60) +Cap.pace(timer, w, true, 0) +w.set(1, 144) +timer.advance(1.01) +deadline = Cap.pace(timer, w, true, timer.getTime()) +T.check(timer.sleeps() > 0, "display rate changes refresh the software-cap decision") +T.eq(w.modeCalls(), 2, "display sample refresh is bounded") +timer = clock() +Cap.pace(timer, nil, true, 0) +T.check(timer.sleeps() > 0, "headless provider absence retains the software cap") + +-- Execute the actual run-loop body with native present/timer boundaries +-- substituted, so a correct helper wired to the wrong loop still fails. +local file = assert(io.open("main.lua", "r")) +local source = file:read("*a") +file:close() +local start = assert(source:find("function love.run()", 1, true)) +for _, active in ipairs({ true, false }) do + local fake = clock() + fake.step = function() + return 1 / 60 + end + local presents = 0 + local noop = function() end + local env = { + love = { + timer = fake, + window = window(1, 60), + graphics = { + isActive = function() + return active + end, + origin = noop, + clear = noop, + getBackgroundColor = function() + return 0, 0, 0, 1 + end, + present = function() + presents = presents + 1 + fake.advance(1 / 60 - 0.001) + end, + }, + }, + BootTrace = { mark = noop, frame = noop }, + Frames = { tick = noop }, + FrameProfile = { + section = function() + return noop + end, + }, + WindowFrame = { + draw = function(fn) + fn() + end, + }, + GraphicsStack = { drain = noop }, + pacingEnabled = function() + return true + end, + lastLogFlush = 0, + } + setmetatable(env, { __index = _G }) + local chunk + if setfenv then + chunk = assert(loadstring(source:sub(start), "@main.lua:love.run")) + setfenv(chunk, env) + else + chunk = assert(load(source:sub(start), "@main.lua:love.run", "t", env)) + end + chunk() + env.love.run()() + T.eq(presents, active and 1 or 0, "actual main loop presents only active graphics") + T.eq(fake.sleeps() > 0, not active, "actual main loop bypasses sleep only after native presentation") +end +T.finish() diff --git a/tools/check_frame_pacing.py b/tools/check_frame_pacing.py new file mode 100644 index 000000000..952e314da --- /dev/null +++ b/tools/check_frame_pacing.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Run owned-window VSync pacing measurements through the local QA guard.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--love', type=Path, required=True) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(args.qa_tools or root / 'tools')) + try: + from qa_storage import qa_source, qa_cache, qa_profile, qa_run + except ImportError: + parser.error('QA storage guard is required; provide --qa-tools') + qa_source(root) + qa_source(args.love.resolve()) + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True, exist_ok=True) + identity = 'oasc-gen2recomp-qa-frame-pacing-' + str(time.time_ns()) + qa_profile(Path.home() / 'Library/Application Support/LOVE' / identity) + result = qa_run([str(args.love.resolve()), str(root / 'tools/frame-pacing-check')], + cwd=root, env=dict(os.environ, POKEPORT_IDENTITY=identity, + FRAME_PACING_REPO=str(root), FRAME_PACING_QA_GUARDED='1'), + capture_output=True, text=True, timeout=90) + args.report.with_suffix('.log').write_text(result.stdout + result.stderr) + records = [line.removeprefix('P1_NATIVE_RESULT ') for line in result.stdout.splitlines() + if line.startswith('P1_NATIVE_RESULT ')] + if result.returncode or len(records) != 1: + raise RuntimeError('native pacing probe failed; see ' + str(args.report.with_suffix('.log'))) + report = json.loads(records[0]) + report['source'] = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=root, text=True).strip() + files = ('main.lua', 'src/core/FrameCap.lua', 'src/core/FrameProfile.lua', + 'tools/frame-pacing-check/main.lua', 'tools/frame-pacing-check/conf.lua') + report['sha256'] = {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in files} + args.report.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/tools/frame-pacing-check/conf.lua b/tools/frame-pacing-check/conf.lua new file mode 100644 index 000000000..79551e6d7 --- /dev/null +++ b/tools/frame-pacing-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv("FRAME_PACING_QA_GUARDED") == "1", "use tools/check_frame_pacing.py") + t.identity = assert(os.getenv("POKEPORT_IDENTITY")) + t.window.title = "Frame pacing QA" + t.window.width, t.window.height, t.window.vsync = 320, 180, 1 + t.modules.audio = false +end diff --git a/tools/frame-pacing-check/main.lua b/tools/frame-pacing-check/main.lua new file mode 100644 index 000000000..e280a2d74 --- /dev/null +++ b/tools/frame-pacing-check/main.lua @@ -0,0 +1,94 @@ +local root = assert(os.getenv("FRAME_PACING_REPO")) +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +local Cap = require("src.core.FrameCap") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local phase, frame, deadline, last, started, cpuStarted = 1, 0, 0, 0, 0, 0 +local sleeps, intervals, results = 0, {}, {} +local WINDOW, WARMUP = 120, 120 +local timer = { + getTime = love.timer.getTime, + sleep = function(dt) + sleeps = sleeps + 1 + love.timer.sleep(dt) + end, +} +local _, _, mode = love.window.getMode() +local refresh = tonumber(mode.refreshrate) or 0 +Cap.current = refresh > 0 and refresh or 60 +local function startPhase() + Profile.setEnabled(false) + Cap.vsyncPacing = phase == 2 + frame, sleeps, intervals = 0, 0, {} + deadline = timer.getTime() +end +local function finishPhase() + local long = 0 + for _, interval in ipairs(intervals) do + if refresh > 0 and interval > 1.5 / refresh then + long = long + 1 + end + end + table.sort(intervals) + results[phase] = { + enabled = Cap.vsyncPacing, + frames = WINDOW, + seconds = timer.getTime() - started, + medianMs = intervals[math.floor(#intervals / 2)] * 1000, + p95Ms = intervals[math.ceil(#intervals * 0.95)] * 1000, + maxMs = intervals[#intervals] * 1000, + framesOver1_5Periods = long, + extraSleepCalls = sleeps, + processCpuMsPerFrame = (os.clock() - cpuStarted) / WINDOW * 1000, + } + if phase == 2 then + if refresh > 0 and love.window.getVSync() ~= 0 then + assert(sleeps == 0, "VSync phase still slept") + end + print("P1_NATIVE_RESULT " .. Json.encode({ + refresh = refresh, + vsync = love.window.getVSync(), + cap = Cap.current, + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + renderer = love.graphics.getRendererInfo(), + phases = results, + })) + return true + end + phase = 2 + startPhase() +end +function love.run() + startPhase() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + sleeps = 0 + Profile.setEnabled(true) + started, last = timer.getTime(), timer.getTime() + cpuStarted = os.clock() + end + love.graphics.clear(0.03, 0.04, 0.06) + local close = Profile.section("present (GPU wait / vsync)") + love.graphics.present() + close() + close = Profile.section("frame pacing: sleep") + deadline = Cap.pace(timer, love.window, true, deadline) + close() + frame = frame + 1 + if frame > WARMUP then + local now = timer.getTime() + intervals[#intervals + 1], last = now - last, now + Profile.frame() + end + if frame == WARMUP + WINDOW and finishPhase() then + return 0 + end + end +end From 65c30879f50bae1e9912f79f227fb45a7b670643 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:12:49 +0100 Subject: [PATCH 064/138] perf(P2): tune incremental GC and spend bounded idle deadline time --- docs/gc-frame-budget.md | 51 ++++++++++++ main.lua | 6 +- src/core/FrameCap.lua | 11 ++- src/core/GCFrameBudget.lua | 86 ++++++++++++++++++++ src/core/Game.lua | 16 ++-- src/core/Performance.lua | 12 +++ tests/engine/gc_frame_budget.lua | 126 ++++++++++++++++++++++++++++++ tools/check_frame_pacing.py | 11 ++- tools/frame-pacing-check/gc.lua | 124 +++++++++++++++++++++++++++++ tools/frame-pacing-check/main.lua | 4 + 10 files changed, 429 insertions(+), 18 deletions(-) create mode 100644 docs/gc-frame-budget.md create mode 100644 src/core/GCFrameBudget.lua create mode 100644 tests/engine/gc_frame_budget.lua create mode 100644 tools/frame-pacing-check/gc.lua diff --git a/docs/gc-frame-budget.md b/docs/gc-frame-budget.md new file mode 100644 index 000000000..abee388ba --- /dev/null +++ b/docs/gc-frame-budget.md @@ -0,0 +1,51 @@ +# P2: incremental GC in unused deadline time + +`Performance.gcBudget` defaults to `true`. At boot the collector uses pause 120 +and step multiplier 300. Each concrete `Performance.CAPS` row additionally has +`gcPause`, `gcStepMul` and `gcBudgetSeconds`; tiers may reduce the default +0.0005-second idle allowance. No options are rewritten. + +The ordinary engine's fixed `step(1)` moves out of `Game:update`. The run loop +uses no-argument incremental steps before its remaining software wait. It starts +no further step after the earlier of the actual deadline and the half-millisecond +allowance, and stops on completion of a cycle. An expired deadline or a stopped +collector receives no work. VSync never gets an invented future budget; only +the actual remainder of its carried deadline is eligible, and no extra sleep +is added after VSync. + +The budget is cooperative: a single indivisible collector step can finish past +the boundary. It is not a preemption guarantee for an arbitrary heap/finalizer. +The F11 child section `frame pacing: gc budget` is nested in the existing +`frame pacing: sleep` section, so the parent includes collection plus waiting. + +The fork's pre-existing Gen3 `Game3.stepGC` delegation stays at its original +update boundary with the original arguments/behavior. A frame owned by that +public seam gets no second idle GC service. Its delegated/custom work remains +outside the engine idle allowance. This preserves consumers that replace it. + +The independent rollback switch is: + +```lua +require("src.core.Performance").gcBudget = false +``` + +The next frame restores the previous pause/multiplier and uses the old native +update step. The separate P1 switch and callback interfaces are unchanged. + +`tools/check_frame_pacing.py --gc --love /path/to/love --report /owned/qa/gc.json` +runs the guarded native comparison. P1 stays enabled for both phases; only P2 +changes. The workload allocates 512 short tables per frame, with 120 warm-up +and 120 measured frames per phase. Raw FrameProfile logs, original tuning, +heap samples and measured-source SHA-256 hashes are retained. + +Observed composed fork, macOS/arm64, 60 Hz, VSync 1, cap 60: + +| Backend | Original fixed step, ms/frame | New idle mean / max, ms | Heap peak before / after, KiB | +| --- | ---: | ---: | ---: | +| LÖVE 11.5 OpenGL | 0.00 (rounded) | 0.098 / 0.499 | 918 / 775 | +| LÖVE 12 Metal | 0.00 (rounded) | 0.120 / 0.278 | 1,179 / 692 | + +Short-workload CPU means were 1.012 → 0.920 ms/frame and 0.717 → 0.651 ms/frame, +respectively. Both retained approximately 60 FPS and restored the original +200/200 tuning when disabled. These are bounded synthetic allocation checks, +not full-game heap, long-session, arbitrary-finalizer or handheld measurements. diff --git a/main.lua b/main.lua index 3b1f7c1ac..0416fe53f 100644 --- a/main.lua +++ b/main.lua @@ -1208,6 +1208,9 @@ end function love.run() BootTrace.mark("love.run enter") + local Performance = require("src.core.Performance") + local GCFrameBudget = require("src.core.GCFrameBudget") + GCFrameBudget.configure(Performance) if love.load then love.load(love.arg.parseGameArguments(arg), arg) end BootTrace.mark("love.load returned") @@ -1245,6 +1248,7 @@ function love.run() -- update dt if love.timer then dt = love.timer.step() end + GCFrameBudget.beginFrame(Performance) -- call update and draw -- @@ -1302,7 +1306,7 @@ function love.run() if love.timer then if paced then local closeSleep = FrameProfile.section("frame pacing: sleep") - nextFrame = FrameCap.pace(love.timer, love.window, presented, nextFrame) + nextFrame = FrameCap.pace(love.timer, love.window, presented, nextFrame, GCFrameBudget.idle) closeSleep() else love.timer.sleep(0.001) diff --git a/src/core/FrameCap.lua b/src/core/FrameCap.lua index 09dbd7e98..763e116f2 100644 --- a/src/core/FrameCap.lua +++ b/src/core/FrameCap.lua @@ -53,14 +53,19 @@ end -- The run loop owns the deadline and passes back the returned value. Keeping -- the original fallback here lets tests exercise the real pacing operation. -function FrameCap.pace(timer, window, presented, nextFrame) +function FrameCap.pace(timer, window, presented, nextFrame, idleWork) local now = timer.getTime() + local budget = 1 / FrameCap.current + nextFrame = nextFrame + budget if FrameCap.vsyncPacing and presented and presentationPaces(window, now) then + if idleWork then + idleWork(timer, nextFrame) + return timer.getTime() + end return now end - local budget = 1 / FrameCap.current - nextFrame = nextFrame + budget if now - nextFrame > budget then nextFrame = now end + if idleWork then idleWork(timer, nextFrame) end while true do local remaining = nextFrame - timer.getTime() if remaining <= 0 then break end diff --git a/src/core/GCFrameBudget.lua b/src/core/GCFrameBudget.lua new file mode 100644 index 000000000..146d95f48 --- /dev/null +++ b/src/core/GCFrameBudget.lua @@ -0,0 +1,86 @@ +-- Cooperative incremental collection in otherwise unused frame time. +local Profile = require("src.core.FrameProfile") +local Budget = {} +local MAX_SECONDS = 0.0005 +local _collector, _priorPause, _priorMul, _pause, _mul +local _enabled, _seconds, _canQueryRunning, _facadeStepped = false, 0, false, false + +local function nonnegative(value, fallback) + if type(value) ~= "number" or value ~= value or value < 0 or value == math.huge then + return fallback + end + return value +end + +local function restoreTuning() + if _collector and _priorPause ~= nil then + _collector("setpause", _priorPause) + _collector("setstepmul", _priorMul) + end + _collector, _priorPause, _priorMul, _pause, _mul = nil, nil, nil, nil, nil +end + +function Budget.configure(performance, collector) + collector = collector or collectgarbage + if not performance.gcBudget or type(collector) ~= "function" then + restoreTuning() + _enabled = false + return + end + if _collector ~= collector then + restoreTuning() + end + local caps = performance.CAPS[performance.tier] or {} + local pause = math.floor(nonnegative(caps.gcPause, performance.GC_PAUSE)) + local mul = math.floor(nonnegative(caps.gcStepMul, performance.GC_STEP_MUL)) + if not _collector then + _collector = collector + _priorPause = collector("setpause", pause) + _priorMul = collector("setstepmul", mul) + _canQueryRunning = pcall(collector, "isrunning") + else + if pause ~= _pause then + collector("setpause", pause) + end + if mul ~= _mul then + collector("setstepmul", mul) + end + end + _pause, _mul = pause, mul + _seconds = math.min(MAX_SECONDS, nonnegative(caps.gcBudgetSeconds, performance.GC_BUDGET_SECONDS)) + _enabled = true +end + +function Budget.beginFrame(performance, collector) + _facadeStepped = false + Budget.configure(performance, collector) +end + +-- An existing public compatibility method retains ownership of its frame. +-- Its call, arguments and return behavior are not replaced by idle collection. +function Budget._facadeHandled() + _facadeStepped = true +end + +function Budget.idle(timer, deadline) + if not _enabled or _facadeStepped or _seconds <= 0 then + return 0 + end + local start = timer.getTime() + local stop = math.min(deadline, start + _seconds) + if stop <= start or (_canQueryRunning and not _collector("isrunning")) then + return 0 + end + local close = Profile.section(" frame pacing: gc budget") + local steps = 0 + while timer.getTime() < stop do + steps = steps + 1 + if _collector("step") then + break + end + end + close() + return steps +end + +return Budget diff --git a/src/core/Game.lua b/src/core/Game.lua index 9dc07ce9d..9be08083e 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -10,6 +10,8 @@ local Renderer = require("src.render.Renderer") local SaveData = require("src.core.SaveData") local StateStack = require("src.core.StateStack") local FrameProfile = require("src.core.FrameProfile") +local Performance = require("src.core.Performance") +local GCFrameBudget = require("src.core.GCFrameBudget") local TouchControls = require("src.core.TouchControls") local ModLoader = require("src.mods.Loader") local ModRuntime = require("src.mods.Runtime") @@ -635,19 +637,11 @@ function Game:update(dt) pcall(function() require("src.core.DiscordPresence").update(dt) end) close() end - -- Steady-state memory backstop: advance the incremental collector one - -- small step every rendered frame. The heavy GPU objects are now freed - -- explicitly (map eviction, battle exit, canvas/renderer swaps), so this - -- only has to keep ordinary Lua-heap garbage (per-frame tables/closures) - -- from drifting upward over a long session, and to spread collection out - -- so the default lazy schedule never batches it into a visible pause. - -- ONE INCREMENTAL GC STEP PER FRAME. Cheap on a small heap and not - -- obviously cheap under a mod that allocates a world's worth of geometry, - -- which is exactly the case being measured -- so it is named rather than - -- assumed. + -- Ordinary engine collection moves to spare deadline time in love.run. + -- P2 disabled retains the original native update step. if collectgarbage then local close = FrameProfile.section(" update: gc step") - collectgarbage("step", 1) + if not Performance.gcBudget then collectgarbage("step", 1) end close() end end diff --git a/src/core/Performance.lua b/src/core/Performance.lua index 861121ddd..359ba0811 100644 --- a/src/core/Performance.lua +++ b/src/core/Performance.lua @@ -42,6 +42,13 @@ local Performance = {} +-- P2 is independently reversible. Tier rows can reduce the idle budget or +-- tune the collector without changing the persisted presentation options. +Performance.gcBudget = true +Performance.GC_PAUSE = 120 +Performance.GC_STEP_MUL = 300 +Performance.GC_BUDGET_SECONDS = 0.0005 + -- Option-row order (auto first, then most to least capable). Performance.TIERS = { "auto", "high", "balanced", "low" } @@ -60,6 +67,11 @@ Performance.CAPS = { balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false }, low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 }, } +for _, caps in pairs(Performance.CAPS) do + caps.gcPause = Performance.GC_PAUSE + caps.gcStepMul = Performance.GC_STEP_MUL + caps.gcBudgetSeconds = Performance.GC_BUDGET_SECONDS +end -- Live resolved tier (never "auto"); Game:applyOptions sets it and the -- renderer / options row read it back. Defaults to the unclamped tier so diff --git a/tests/engine/gc_frame_budget.lua b/tests/engine/gc_frame_budget.lua new file mode 100644 index 000000000..974ba084e --- /dev/null +++ b/tests/engine/gc_frame_budget.lua @@ -0,0 +1,126 @@ +local T = require("tests.harness") +local Performance = require("src.core.Performance") +local Budget = require("src.core.GCFrameBudget") +local Cap = require("src.core.FrameCap") + +local function setup(cost) + local state = { now = 0, pause = 200, mul = 200, steps = 0, running = true, stepArgs = {}, sleeps = 0 } + local timer = { + getTime = function() + return state.now + end, + sleep = function(dt) + state.now = state.now + dt + state.sleeps = state.sleeps + 1 + end, + } + local function collector(command, ...) + local value = (...) + if command == "setpause" then + local old = state.pause + state.pause = value + return old + end + if command == "setstepmul" then + local old = state.mul + state.mul = value + return old + end + if command == "isrunning" then + return state.running + end + assert(command == "step", "unexpected collector command") + state.steps = state.steps + 1 + state.stepArgs[#state.stepArgs + 1] = select("#", ...) + state.now = state.now + (cost or 0.0001) + return state.completeAt and state.steps >= state.completeAt or false + end + return state, timer, collector +end +T.eq(Performance.gcBudget, true, "P2 defaults on") +T.eq(Performance.GC_PAUSE, 120, "requested collector pause") +T.eq(Performance.GC_STEP_MUL, 300, "requested collector step multiplier") +for _, tier in ipairs({ "high", "balanced", "low" }) do + T.eq(Performance.CAPS[tier].gcBudgetSeconds, 0.0005, "tier carries the default idle budget") +end +local state, timer, gc = setup() +Performance.tier = "high" +Budget.beginFrame(Performance, gc) +T.eq(state.pause, 120, "frame applies requested pause") +T.eq(state.mul, 300, "frame applies requested step multiplier") +local steps = Budget.idle(timer, 0.01) +T.eq(steps, 5, "idle work stops at the half-millisecond budget") +for _, n in ipairs(state.stepArgs) do + T.eq(n, 0, "incremental step has no fixed kilobyte argument") +end +local before = state.steps +T.eq(Budget.idle(timer, state.now), 0, "no collector work starts at an exhausted deadline") +T.eq(state.steps, before, "expired deadline leaves collector untouched") +state, timer, gc = setup() +Budget.beginFrame(Performance, gc) +T.eq(Budget.idle(timer, 0.00025), 3, "shorter deadline bounds started steps") +T.check(state.now <= 0.00035, "indivisible work can overshoot by at most one tested step") +state, timer, gc = setup() +state.completeAt = 2 +Budget.beginFrame(Performance, gc) +T.eq(Budget.idle(timer, 0.01), 2, "completed cycle does not immediately start another cycle") +state.running = false +T.eq(Budget.idle(timer, 0.01), 0, "a stopped collector is not restarted") +state.running = true +Budget._facadeHandled() +T.eq(Budget.idle(timer, 0.01), 0, "existing public GC delegation retains its frame") +Budget.beginFrame(Performance, gc) +T.check(Budget.idle(timer, 0.01) > 0, "next frame resets compatibility delegation ownership") +Performance.gcBudget = false +Budget.beginFrame(Performance, gc) +T.eq(state.pause, 200, "disable restores previous collector pause") +T.eq(state.mul, 200, "disable restores previous step multiplier") +T.eq(Budget.idle(timer, 0.01), 0, "disabled P2 does no idle collector work") +Performance.gcBudget = true +local caps = Performance.CAPS.low +local oldPause, oldMul, oldSeconds = caps.gcPause, caps.gcStepMul, caps.gcBudgetSeconds +caps.gcPause, caps.gcStepMul, caps.gcBudgetSeconds = 140, 250, 0.0002 +Performance.tier = "low" +state, timer, gc = setup() +Budget.beginFrame(Performance, gc) +T.eq(state.pause, 140, "tier-specific pause is respected") +T.eq(state.mul, 250, "tier-specific step multiplier is respected") +T.eq(Budget.idle(timer, 0.01), 2, "tier can reduce the idle budget") +caps.gcBudgetSeconds = 0.01 +Budget.beginFrame(Performance, gc) +before = state.steps +Budget.idle(timer, state.now + 0.1) +T.check(state.steps - before <= 6, "tier override cannot exceed the half-millisecond ceiling") +caps.gcPause, caps.gcStepMul, caps.gcBudgetSeconds = oldPause, oldMul, oldSeconds +Performance.tier = "high" +state, timer, gc = setup() +Budget.beginFrame(Performance, gc) +Cap.current, Cap.vsyncPacing = 60, false +local deadline = Cap.pace(timer, nil, false, 0, Budget.idle) +T.eq(state.steps, 5, "actual software pacing performs bounded GC before sleeping") +T.check(state.sleeps > 0, "software pacing sleeps after collection") +T.check(math.abs(deadline - 1 / 60) < 1e-9, "collection does not move the software deadline") +state, timer, gc = setup() +Budget.beginFrame(Performance, gc) +Cap.vsyncPacing = true +local window = { + getVSync = function() + return 1 + end, + getMode = function() + return 320, 180, { refreshrate = 60 } + end, +} +state.now = 1 / 60 - 0.0003 +Cap.pace(timer, window, true, 0, Budget.idle) +T.check(state.steps > 0 and state.steps <= 4, "VSync uses only actual remaining deadline time") +T.eq(state.sleeps, 0, "VSync receives no extra sleep after collection") +state, timer, gc = setup() +Budget.beginFrame(Performance, gc) +state.now = 1 / 60 + 0.001 +Cap.pace(timer, window, true, 0, Budget.idle) +T.eq(state.steps, 0, "late VSync present receives no invented idle budget") +Performance.gcBudget = false +Budget.configure(Performance, gc) +Performance.gcBudget = true +T.finish() diff --git a/tools/check_frame_pacing.py b/tools/check_frame_pacing.py index 952e314da..f9addab0a 100644 --- a/tools/check_frame_pacing.py +++ b/tools/check_frame_pacing.py @@ -15,6 +15,7 @@ def main(): parser.add_argument('--love', type=Path, required=True) parser.add_argument('--report', type=Path, required=True) parser.add_argument('--qa-tools', type=Path) + parser.add_argument('--gc', action='store_true', help='compare fixed-step GC with the P2 idle budget') args = parser.parse_args() root = Path(__file__).resolve().parents[1] sys.path.insert(0, str(args.qa_tools or root / 'tools')) @@ -30,17 +31,21 @@ def main(): qa_profile(Path.home() / 'Library/Application Support/LOVE' / identity) result = qa_run([str(args.love.resolve()), str(root / 'tools/frame-pacing-check')], cwd=root, env=dict(os.environ, POKEPORT_IDENTITY=identity, - FRAME_PACING_REPO=str(root), FRAME_PACING_QA_GUARDED='1'), + FRAME_PACING_REPO=str(root), FRAME_PACING_QA_GUARDED='1', + FRAME_PACING_GC='1' if args.gc else '0'), capture_output=True, text=True, timeout=90) args.report.with_suffix('.log').write_text(result.stdout + result.stderr) - records = [line.removeprefix('P1_NATIVE_RESULT ') for line in result.stdout.splitlines() - if line.startswith('P1_NATIVE_RESULT ')] + prefix = 'P2_NATIVE_RESULT ' if args.gc else 'P1_NATIVE_RESULT ' + records = [line.removeprefix(prefix) for line in result.stdout.splitlines() if line.startswith(prefix)] if result.returncode or len(records) != 1: raise RuntimeError('native pacing probe failed; see ' + str(args.report.with_suffix('.log'))) report = json.loads(records[0]) report['source'] = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=root, text=True).strip() files = ('main.lua', 'src/core/FrameCap.lua', 'src/core/FrameProfile.lua', 'tools/frame-pacing-check/main.lua', 'tools/frame-pacing-check/conf.lua') + if args.gc: + files += ('src/core/Performance.lua', 'src/core/GCFrameBudget.lua', 'src/core/Game.lua', + 'tools/frame-pacing-check/gc.lua') report['sha256'] = {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in files} args.report.write_text(json.dumps(report, indent=2) + '\n') print(json.dumps(report, indent=2)) diff --git a/tools/frame-pacing-check/gc.lua b/tools/frame-pacing-check/gc.lua new file mode 100644 index 000000000..80067fa1e --- /dev/null +++ b/tools/frame-pacing-check/gc.lua @@ -0,0 +1,124 @@ +local Cap = require("src.core.FrameCap") +local Performance = require("src.core.Performance") +local Budget = require("src.core.GCFrameBudget") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local phase, frame, deadline, started, cpuStarted = 1, 0, 0, 0, 0 +local WINDOW, WARMUP = 120, 120 +local results, _trash = {}, nil +local witness = { key = "retained native data", value = 2712 } +local legacySteps, idleSteps, idleTotal, idleMax, heapPeak = 0, 0, 0, 0, 0 +local timer = { getTime = love.timer.getTime, sleep = love.timer.sleep } +local _, _, mode = love.window.getMode() +Cap.current = tonumber(mode.refreshrate) or 60 +if Cap.current <= 0 then + Cap.current = 60 +end +Cap.vsyncPacing = true +local originalPause = collectgarbage("setpause", 120) +collectgarbage("setpause", originalPause) +local originalMul = collectgarbage("setstepmul", 300) +collectgarbage("setstepmul", originalMul) + +local function resetStats() + legacySteps, idleSteps, idleTotal, idleMax, heapPeak = 0, 0, 0, 0, 0 +end +local function idle(clock, target) + local start = clock.getTime() + local steps = Budget.idle(clock, target) + if steps > 0 then + local duration = clock.getTime() - start + idleSteps, idleTotal = idleSteps + steps, idleTotal + duration + idleMax = math.max(idleMax, duration) + end +end +local function startPhase() + Profile.setEnabled(false) + Performance.gcBudget = phase == 2 + Budget.configure(Performance) + collectgarbage("collect") + frame = 0 + resetStats() + deadline = timer.getTime() +end +local function finishPhase() + assert(witness.key == "retained native data" and witness.value == 2712) + assert(_trash[512][2] == 512) + results[phase] = { + enabled = Performance.gcBudget, + frames = WINDOW, + seconds = timer.getTime() - started, + processCpuMsPerFrame = (os.clock() - cpuStarted) / WINDOW * 1000, + legacySteps = legacySteps, + idleSteps = idleSteps, + idleMeanMs = idleTotal / WINDOW * 1000, + idleMaxMs = idleMax * 1000, + heapPeakKiB = heapPeak, + } + if phase == 2 then + Performance.gcBudget = false + Budget.configure(Performance) + local pause = collectgarbage("setpause", originalPause) + local mul = collectgarbage("setstepmul", originalMul) + assert(pause == originalPause and mul == originalMul, "disable did not restore GC tuning") + print("P2_NATIVE_RESULT " .. Json.encode({ + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + renderer = love.graphics.getRendererInfo(), + refresh = mode.refreshrate, + vsync = love.window.getVSync(), + cap = Cap.current, + originalPause = originalPause, + originalStepMul = originalMul, + phases = results, + })) + return true + end + phase = 2 + startPhase() +end +function love.run() + startPhase() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + resetStats() + Profile.setEnabled(true) + started, cpuStarted = timer.getTime(), os.clock() + end + Budget.beginFrame(Performance) + local close = Profile.section("update: allocation workload") + local rows = {} + for index = 1, 512 do + rows[index] = { frame, index, a = index * 2, b = index * 3 } + end + _trash = rows + close() + if not Performance.gcBudget then + close = Profile.section(" update: gc step") + collectgarbage("step", 1) + legacySteps = legacySteps + 1 + close() + end + love.graphics.clear(0.03, 0.04, 0.06) + close = Profile.section("present (GPU wait / vsync)") + love.graphics.present() + close() + close = Profile.section("frame pacing: sleep") + deadline = Cap.pace(timer, love.window, true, deadline, idle) + close() + frame = frame + 1 + if frame > WARMUP then + heapPeak = math.max(heapPeak, collectgarbage("count")) + Profile.frame() + end + if frame == WARMUP + WINDOW and finishPhase() then + return 0 + end + end +end diff --git a/tools/frame-pacing-check/main.lua b/tools/frame-pacing-check/main.lua index e280a2d74..5bb9948d3 100644 --- a/tools/frame-pacing-check/main.lua +++ b/tools/frame-pacing-check/main.lua @@ -1,5 +1,9 @@ local root = assert(os.getenv("FRAME_PACING_REPO")) package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +if os.getenv("FRAME_PACING_GC") == "1" then + require("tools.frame-pacing-check.gc") + return +end local Cap = require("src.core.FrameCap") local Profile = require("src.core.FrameProfile") local Json = require("src.link.Json") From 0ef4570098dd77ef0c4b6467ba2c72ea4229af6a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:18:28 +0100 Subject: [PATCH 065/138] Retain native Gen2 phone identities and restore Crystal Buena contact --- docs/native-gen2-phone-identities.md | 43 ++++++ src/import/Gen2PhoneIdentities.lua | 183 +++++++++++++++++++++++++ src/import/RomExtractorGen2.lua | 49 ++++--- src/import/RomImporter.lua | 2 +- tests/engine/gen2_phone_identities.lua | 111 +++++++++++++++ 5 files changed, 368 insertions(+), 20 deletions(-) create mode 100644 docs/native-gen2-phone-identities.md create mode 100644 src/import/Gen2PhoneIdentities.lua create mode 100644 tests/engine/gen2_phone_identities.lua diff --git a/docs/native-gen2-phone-identities.md b/docs/native-gen2-phone-identities.md new file mode 100644 index 000000000..d0e3f3ce5 --- /dev/null +++ b/docs/native-gen2-phone-identities.md @@ -0,0 +1,43 @@ +# Native Gen2 phone identities and Buena import + +The importer used a Gold-sized four-name list for all cartridges. Actual +Crystal contact37 uses non-trainer identity5, so Buena and both of her phone +script pointers were discarded. Read the name range referenced by the actual +native contact rows instead. Native outgoing/incoming pointers and masks stay +in their original directions. Empty and reserved rows remain uncallable. + +Each non-trainer row now preserves `contactNumber` independently of `number`, +which remains the native **map number**. `map_scripts.phoneInfo` carries the +full reserved table size, actual rows read and imported non-trainer names. +Even a completely empty book retains its reserved bound. + +Native constants also retain explicit phone-contact and trainer class/member +orders. These are constant identifiers from reference535a5ec0, not localized +display-name guesses or ROM dialogue. Existing authored identity metadata is +authoritative; explicit manifest metadata supports alternative cartridges. +A partial authored identity set does not silently inherit unrelated retail +roster rows. Retail fallback is limited to Gold/Silver/Crystal. Legacy +`PHONE_OAK` is retained as a compatibility spelling for slot2; the actual +imported display name remains authoritative. + +The `gen2-phone-identities-v1` marker rebuilds only stock G/S/C imports once. +Existing saves are not modified by this change. Other edition cache markers +are unchanged. + +Validation:138 synthetic checks exercise both native directions through +Gen2ScriptVM/ScriptRunner, Buena's actual name consumer, reserved/empty rows, +separate map/contact numbers, copied metadata, authored precedence, generation +isolation and the real cache-readiness gate. A read-only actual Crystal table +probe changes32 to33 callable rows (28 trainer /5 non-trainer),38 reserved +rows and62 to64 queued phone scripts; all trainer members resolve through +explicit metadata. The original ROM is unchanged. This is table extraction, +not full-import, GPU, story-call or complete phone-facade acceptance. + +The semantic `phone_contacts` registry writer, stable added-contact allocation, +rollback/reload and the remaining legacy Phone facade still need implementation. +These fields are prerequisites for connecting those APIs to native consumers; +the native numeric saved phone book remains authoritative. + +Original implementation by Roxas2712. Existing upstream and third-party +licenses/credits remain applicable; see the specific contribution permission and credits: +. diff --git a/src/import/Gen2PhoneIdentities.lua b/src/import/Gen2PhoneIdentities.lua new file mode 100644 index 000000000..2ac555308 --- /dev/null +++ b/src/import/Gen2PhoneIdentities.lua @@ -0,0 +1,183 @@ +-- Original selection/copy logic by Roxas2712. See offered PR contribution permission. +-- Compatibility constant identifiers and numeric orders only; no ROM bytes, +-- artwork, dialogue or scripts. Reference: Gen1Recomp535a5ec0 manifest constants. +-- Existing engine/third-party credits and rights remain unchanged. +local M = {} +local gold = { + ["phoneContactOrder"] = {"PHONE_00", "PHONE_MOM", "PHONE_OAK", "PHONE_BILL", "PHONE_ELM", "PHONE_SCHOOLBOY_JACK", "PHONE_POKEFAN_BEVERLY", "PHONE_SAILOR_HUEY", "PHONE_UNUSED", "PHONE_UNUSED", "PHONE_UNUSED", "PHONE_COOLTRAINERM_GAVEN", "PHONE_COOLTRAINERF_BETH", "PHONE_BIRDKEEPER_JOSE", "PHONE_COOLTRAINERF_REENA", "PHONE_YOUNGSTER_JOEY", "PHONE_BUG_CATCHER_WADE", "PHONE_FISHER_RALPH", "PHONE_PICNICKER_LIZ", "PHONE_HIKER_ANTHONY", "PHONE_CAMPER_TODD", "PHONE_PICNICKER_GINA", "PHONE_JUGGLER_IRWIN", "PHONE_BUG_CATCHER_ARNIE", "PHONE_SCHOOLBOY_ALAN", "PHONE_UNUSED", "PHONE_LASS_DANA", "PHONE_SCHOOLBOY_CHAD", "PHONE_POKEFANM_DEREK", "PHONE_FISHER_CHRIS", "PHONE_POKEMANIAC_BRENT", "PHONE_PICNICKER_TIFFANY", "PHONE_BIRDKEEPER_VANCE", "PHONE_FISHER_WILTON", "PHONE_BLACKBELT_KENJI", "PHONE_HIKER_PARRY", "PHONE_PICNICKER_ERIN"}, + ["trainerClassMembers"] = { + ["BEAUTY"] = {"VICTORIA", "SAMANTHA", "JULIE", "JACLYN", "BRENDA", "CASSIE", "CAROLINE", "CARLENE", "JESSICA", "RACHAEL", "ANGELICA", "KENDRA", "VERONICA", "JULIA", "THERESA", "VALERIE"}, + ["BIKER"] = {"KAZU1", "KAZU2", "DWAYNE", "HARRIS", "ZEKE", "CHARLES", "RILEY", "JOEL", "GLENN"}, + ["BIRD_KEEPER"] = {"ROD", "ABE", "BRYAN", "THEO", "TOBY", "DENIS", "VANCE1", "HANK", "ROY", "BORIS", "BOB", "JOSE1", "PETER", "JOSE2", "PERRY", "BRET", "JOSE3", "VANCE2", "VANCE3"}, + ["BLACKBELT_T"] = {"KENJI1", "YOSHI", "KENJI2", "LAO", "NOB", "KIYO", "LUNG", "KENJI3", "WAI"}, + ["BLAINE"] = {"BLAINE1"}, + ["BLUE"] = {"BLUE1"}, + ["BOARDER"] = {"RONALD", "BRAD", "DOUGLAS"}, + ["BROCK"] = {"BROCK1"}, + ["BRUNO"] = {"BRUNO1"}, + ["BUGSY"] = {"BUGSY1"}, + ["BUG_CATCHER"] = {"DON", "ROB", "ED", "WADE1", "BENNY", "AL", "JOSH", "ARNIE1", "KEN", "WADE2", "WADE3", "DOUG", "ARNIE2", "ARNIE3"}, + ["BURGLAR"] = {"DUNCAN", "EDDIE", "COREY"}, + ["CAL"] = {"CAL1", "CAL2", "CAL3"}, + ["CAMPER"] = {"ROLAND", "TODD1", "IVAN", "ELLIOT", "BARRY", "LLOYD", "DEAN", "SID", "HARVEY", "DALE", "TED", "TODD2", "TODD3", "THOMAS", "LEROY", "DAVID", "JOHN", "JERRY", "SPENCER"}, + ["CHAMPION"] = {"LANCE"}, + ["CHUCK"] = {"CHUCK1"}, + ["CLAIR"] = {"CLAIR1"}, + ["COOLTRAINERF"] = {"GWEN", "LOIS", "FRAN", "LOLA", "KATE", "IRENE", "KELLY", "JOYCE", "BETH1", "REENA1", "MEGAN", "BETH2", "CAROL", "QUINN", "EMMA", "CYBIL", "JENN", "BETH3", "REENA2", "REENA3"}, + ["COOLTRAINERM"] = {"NICK", "AARON", "PAUL", "CODY", "MIKE", "GAVEN1", "GAVEN2", "RYAN", "JAKE", "GAVEN3", "BLAKE", "BRIAN", "ERICK", "ANDY", "TYLER", "SEAN", "KEVIN", "STEVE", "ALLEN"}, + ["ERIKA"] = {"ERIKA1"}, + ["EXECUTIVEF"] = {"EXECUTIVEF_1", "EXECUTIVEF_2"}, + ["EXECUTIVEM"] = {"EXECUTIVEM_1", "EXECUTIVEM_2", "EXECUTIVEM_3", "EXECUTIVEM_4"}, + ["FALKNER"] = {"FALKNER1"}, + ["FIREBREATHER"] = {"OTIS", "DICK", "NED", "BURT", "BILL", "WALT", "RAY", "LYLE"}, + ["FISHER"] = {"JUSTIN", "RALPH1", "ARNOLD", "KYLE", "HENRY", "MARVIN", "CHRIS1", "ANDRE", "RAYMOND", "WILTON1", "EDGAR", "JONAH", "MARTIN", "STEPHEN", "BARNEY", "RALPH2", "RALPH3", "CHRIS2", "CHRIS3", "WILTON2", "SCOTT", "WILTON3"}, + ["GENTLEMAN"] = {"PRESTON", "EDWARD", "GREGORY", "VIRGIL", "ALFRED"}, + ["GRUNTF"] = {"GRUNTF_1", "GRUNTF_2", "GRUNTF_3", "GRUNTF_4", "GRUNTF_5"}, + ["GRUNTM"] = {"GRUNTM_1", "GRUNTM_2", "GRUNTM_3", "GRUNTM_4", "GRUNTM_5", "GRUNTM_6", "GRUNTM_7", "GRUNTM_8", "GRUNTM_9", "GRUNTM_10", "GRUNTM_11", "GRUNTM_12", "GRUNTM_13", "GRUNTM_14", "GRUNTM_15", "GRUNTM_16", "GRUNTM_17", "GRUNTM_18", "GRUNTM_19", "GRUNTM_20", "GRUNTM_21", "GRUNTM_22", "GRUNTM_23", "GRUNTM_24", "GRUNTM_25", "GRUNTM_26", "GRUNTM_27", "GRUNTM_28", "GRUNTM_29", "GRUNTM_30", "GRUNTM_31"}, + ["GUITARIST"] = {"CLYDE", "VINCENT"}, + ["HIKER"] = {"ANTHONY1", "RUSSELL", "PHILLIP", "LEONARD", "ANTHONY2", "BENJAMIN", "ERIK", "MICHAEL", "PARRY1", "TIMOTHY", "BAILEY", "ANTHONY3", "TIM", "NOLAND", "SIDNEY", "KENNY", "JIM", "DANIEL", "PARRY2", "PARRY3"}, + ["JANINE"] = {"JANINE1"}, + ["JASMINE"] = {"JASMINE1"}, + ["JUGGLER"] = {"IRWIN1", "FRITZ", "HORTON", "IRWIN2", "IRWIN3"}, + ["KAREN"] = {"KAREN1"}, + ["KIMONO_GIRL"] = {"KYOKO", "NAOKO", "SAYO", "ZUKI", "KUNI", "MIKI"}, + ["KOGA"] = {"KOGA1"}, + ["LASS"] = {"CARRIE", "BRIDGET", "ALICE", "KRISE", "CONNIE1", "LINDA", "LAURA", "SHANNON", "MICHELLE", "DANA1", "ELLEN", "CONNIE2", "CONNIE3", "DANA2", "DANA3"}, + ["LT_SURGE"] = {"LT_SURGE1"}, + ["MEDIUM"] = {"MARTHA", "GRACE", "BETHANY", "MARGRET", "ETHEL", "REBECCA", "DORIS"}, + ["MISTY"] = {"MISTY1"}, + ["MORTY"] = {"MORTY1"}, + ["OFFICER"] = {"KEITH", "DIRK"}, + ["PICNICKER"] = {"LIZ1", "GINA1", "BROOKE", "KIM", "CINDY", "HOPE", "SHARON", "DEBRA", "GINA2", "ERIN1", "LIZ2", "LIZ3", "HEIDI", "EDNA", "GINA3", "TIFFANY1", "TIFFANY2", "ERIN2", "TANYA", "TIFFANY3", "ERIN3"}, + ["POKEFANF"] = {"BEVERLY1", "RUTH", "BEVERLY2", "BEVERLY3", "GEORGIA"}, + ["POKEFANM"] = {"WILLIAM", "DEREK1", "ROBERT", "JOSHUA", "CARTER", "TREVOR", "BRANDON", "JEREMY", "COLIN", "DEREK2", "DEREK3", "ALEX"}, + ["POKEMANIAC"] = {"LARRY", "ANDREW", "CALVIN", "SHANE", "BEN", "BRENT1", "RON", "ETHAN", "BRENT2", "BRENT3", "ISSAC", "DONALD", "ZACH"}, + ["POKEMON_PROF"] = {}, + ["PRYCE"] = {"PRYCE1"}, + ["PSYCHIC_T"] = {"NATHAN", "FRANKLIN", "HERMAN", "FIDEL", "GREG", "NORMAN", "MARK", "PHIL", "RICHARD", "GILBERT", "JARED", "RODNEY"}, + ["RED"] = {"RED1"}, + ["RIVAL1"] = {"RIVAL1_1_CHIKORITA", "RIVAL1_1_CYNDAQUIL", "RIVAL1_1_TOTODILE", "RIVAL1_2_CHIKORITA", "RIVAL1_2_CYNDAQUIL", "RIVAL1_2_TOTODILE", "RIVAL1_3_CHIKORITA", "RIVAL1_3_CYNDAQUIL", "RIVAL1_3_TOTODILE", "RIVAL1_4_CHIKORITA", "RIVAL1_4_CYNDAQUIL", "RIVAL1_4_TOTODILE", "RIVAL1_5_CHIKORITA", "RIVAL1_5_CYNDAQUIL", "RIVAL1_5_TOTODILE"}, + ["RIVAL2"] = {"RIVAL2_1_CHIKORITA", "RIVAL2_1_CYNDAQUIL", "RIVAL2_1_TOTODILE", "RIVAL2_2_CHIKORITA", "RIVAL2_2_CYNDAQUIL", "RIVAL2_2_TOTODILE"}, + ["SABRINA"] = {"SABRINA1"}, + ["SAGE"] = {"CHOW", "NICO", "JIN", "TROY", "JEFFREY", "PING", "EDMOND", "NEAL", "LI"}, + ["SAILOR"] = {"EUGENE", "HUEY1", "TERRELL", "KENT", "ERNEST", "JEFF", "GARRETT", "KENNETH", "STANLY", "HARRY", "HUEY2", "HUEY3"}, + ["SCHOOLBOY"] = {"JACK1", "KIPP", "ALAN1", "JOHNNY", "DANNY", "TOMMY", "DUDLEY", "JOE", "BILLY", "CHAD1", "NATE", "RICKY", "JACK2", "JACK3", "ALAN2", "ALAN3", "CHAD2", "CHAD3"}, + ["SCIENTIST"] = {"ROSS", "MITCH", "JED", "MARC", "RICH"}, + ["SKIER"] = {"ROXANNE", "CLARISSA"}, + ["SUPER_NERD"] = {"ERIC_UNUSED", "ERIC", "GREGG", "JAY", "DAVE", "SAM", "TOM", "PAT", "SHAWN", "TERU", "RUSS", "NORTON"}, + ["SWIMMERF"] = {"ELAINE", "PAULA", "KAYLEE", "SUSIE", "DENISE", "KARA", "WENDY", "LISA", "JILL", "MARY", "KATIE", "DAWN", "TARA", "NICOLE", "LORI", "JODY", "NIKKI", "DIANA", "BRIANA"}, + ["SWIMMERM"] = {"HAROLD", "SIMON", "RANDALL", "CHARLIE", "GEORGE", "BERKE", "KIRK", "MATHEW", "HAL", "PATON", "DARYL", "WALTER", "TONY", "JEROME", "TUCKER", "RICK", "CAMERON", "SETH", "JAMES", "LEWIS", "PARKER"}, + ["TEACHER"] = {"COLETTE", "HILLARY", "SHIRLEY"}, + ["TRAINER_NONE"] = {}, + ["TWINS"] = {"AMYANDMAY1", "ANNANDANNE1", "ANNANDANNE2", "AMYANDMAY2", "JOANDZOE1", "JOANDZOE2", "MEGANDPEG1", "MEGANDPEG2"}, + ["WHITNEY"] = {"WHITNEY1"}, + ["WILL"] = {"WILL1"}, + ["YOUNGSTER"] = {"JOEY1", "MIKEY", "ALBERT", "GORDON", "SAMUEL", "IAN", "JOEY2", "JOEY3", "WARREN", "JIMMY", "OWEN", "JASON"}, + }, + ["trainerClassOrder"] = {"TRAINER_NONE", "FALKNER", "WHITNEY", "BUGSY", "MORTY", "PRYCE", "JASMINE", "CHUCK", "CLAIR", "RIVAL1", "POKEMON_PROF", "WILL", "CAL", "BRUNO", "KAREN", "KOGA", "CHAMPION", "BROCK", "MISTY", "LT_SURGE", "SCIENTIST", "ERIKA", "YOUNGSTER", "SCHOOLBOY", "BIRD_KEEPER", "LASS", "JANINE", "COOLTRAINERM", "COOLTRAINERF", "BEAUTY", "POKEMANIAC", "GRUNTM", "GENTLEMAN", "SKIER", "TEACHER", "SABRINA", "BUG_CATCHER", "FISHER", "SWIMMERM", "SWIMMERF", "SAILOR", "SUPER_NERD", "RIVAL2", "GUITARIST", "HIKER", "BIKER", "BLAINE", "BURGLAR", "FIREBREATHER", "JUGGLER", "BLACKBELT_T", "EXECUTIVEM", "PSYCHIC_T", "PICNICKER", "CAMPER", "EXECUTIVEF", "SAGE", "MEDIUM", "BOARDER", "POKEFANM", "KIMONO_GIRL", "TWINS", "POKEFANF", "RED", "BLUE", "OFFICER", "GRUNTF"}, +} +local crystal = { + ["phoneContactOrder"] = {"PHONE_00", "PHONE_MOM", "PHONE_OAK", "PHONE_BILL", "PHONE_ELM", "PHONE_SCHOOLBOY_JACK", "PHONE_POKEFAN_BEVERLY", "PHONE_SAILOR_HUEY", "PHONE_UNUSED", "PHONE_UNUSED", "PHONE_UNUSED", "PHONE_COOLTRAINERM_GAVEN", "PHONE_COOLTRAINERF_BETH", "PHONE_BIRDKEEPER_JOSE", "PHONE_COOLTRAINERF_REENA", "PHONE_YOUNGSTER_JOEY", "PHONE_BUG_CATCHER_WADE", "PHONE_FISHER_RALPH", "PHONE_PICNICKER_LIZ", "PHONE_HIKER_ANTHONY", "PHONE_CAMPER_TODD", "PHONE_PICNICKER_GINA", "PHONE_JUGGLER_IRWIN", "PHONE_BUG_CATCHER_ARNIE", "PHONE_SCHOOLBOY_ALAN", "PHONE_UNUSED", "PHONE_LASS_DANA", "PHONE_SCHOOLBOY_CHAD", "PHONE_POKEFANM_DEREK", "PHONE_FISHER_TULLY", "PHONE_POKEMANIAC_BRENT", "PHONE_PICNICKER_TIFFANY", "PHONE_BIRDKEEPER_VANCE", "PHONE_FISHER_WILTON", "PHONE_BLACKBELT_KENJI", "PHONE_HIKER_PARRY", "PHONE_PICNICKER_ERIN", "PHONE_BUENA"}, + ["trainerClassMembers"] = { + ["BEAUTY"] = {"VICTORIA", "SAMANTHA", "JULIE", "JACLYN", "BRENDA", "CASSIE", "CAROLINE", "CARLENE", "JESSICA", "RACHAEL", "ANGELICA", "KENDRA", "VERONICA", "JULIA", "THERESA", "VALERIE", "OLIVIA"}, + ["BIKER"] = {"BIKER_BENNY", "KAZU", "DWAYNE", "HARRIS", "ZEKE", "CHARLES", "RILEY", "JOEL", "GLENN"}, + ["BIRD_KEEPER"] = {"ROD", "ABE", "BRYAN", "THEO", "TOBY", "DENIS", "VANCE1", "HANK", "ROY", "BORIS", "BOB", "JOSE1", "PETER", "JOSE2", "PERRY", "BRET", "JOSE3", "VANCE2", "VANCE3"}, + ["BLACKBELT_T"] = {"KENJI1", "YOSHI", "KENJI2", "LAO", "NOB", "KIYO", "LUNG", "KENJI3", "WAI"}, + ["BLAINE"] = {"BLAINE1"}, + ["BLUE"] = {"BLUE1"}, + ["BOARDER"] = {"RONALD", "BRAD", "DOUGLAS"}, + ["BROCK"] = {"BROCK1"}, + ["BRUNO"] = {"BRUNO1"}, + ["BUGSY"] = {"BUGSY1"}, + ["BUG_CATCHER"] = {"DON", "ROB", "ED", "WADE1", "BUG_CATCHER_BENNY", "AL", "JOSH", "ARNIE1", "KEN", "WADE2", "WADE3", "DOUG", "ARNIE2", "ARNIE3", "WADE4", "WADE5", "ARNIE4", "ARNIE5", "WAYNE"}, + ["BURGLAR"] = {"DUNCAN", "EDDIE", "COREY"}, + ["CAL"] = {"CAL1", "CAL2", "CAL3"}, + ["CAMPER"] = {"ROLAND", "TODD1", "IVAN", "ELLIOT", "BARRY", "LLOYD", "DEAN", "SID", "HARVEY", "DALE", "TED", "TODD2", "TODD3", "THOMAS", "LEROY", "DAVID", "JOHN", "JERRY", "SPENCER", "TODD4", "TODD5", "QUENTIN"}, + ["CHAMPION"] = {"LANCE"}, + ["CHUCK"] = {"CHUCK1"}, + ["CLAIR"] = {"CLAIR1"}, + ["COOLTRAINERF"] = {"GWEN", "LOIS", "FRAN", "LOLA", "KATE", "IRENE", "KELLY", "JOYCE", "BETH1", "REENA1", "MEGAN", "BETH2", "CAROL", "QUINN", "EMMA", "CYBIL", "JENN", "BETH3", "REENA2", "REENA3", "CARA"}, + ["COOLTRAINERM"] = {"NICK", "AARON", "PAUL", "CODY", "MIKE", "GAVEN1", "GAVEN2", "RYAN", "JAKE", "GAVEN3", "BLAKE", "BRIAN", "ERICK", "ANDY", "TYLER", "SEAN", "KEVIN", "STEVE", "ALLEN", "DARIN"}, + ["ERIKA"] = {"ERIKA1"}, + ["EXECUTIVEF"] = {"EXECUTIVEF_1", "EXECUTIVEF_2"}, + ["EXECUTIVEM"] = {"EXECUTIVEM_1", "EXECUTIVEM_2", "EXECUTIVEM_3", "EXECUTIVEM_4"}, + ["FALKNER"] = {"FALKNER1"}, + ["FIREBREATHER"] = {"OTIS", "DICK", "NED", "BURT", "BILL", "WALT", "RAY", "LYLE"}, + ["FISHER"] = {"JUSTIN", "RALPH1", "ARNOLD", "KYLE", "HENRY", "MARVIN", "TULLY1", "ANDRE", "RAYMOND", "WILTON1", "EDGAR", "JONAH", "MARTIN", "STEPHEN", "BARNEY", "RALPH2", "RALPH3", "TULLY2", "TULLY3", "WILTON2", "SCOTT", "WILTON3", "RALPH4", "RALPH5", "TULLY4"}, + ["GENTLEMAN"] = {"PRESTON", "EDWARD", "GREGORY", "VIRGIL", "ALFRED"}, + ["GRUNTF"] = {"GRUNTF_1", "GRUNTF_2", "GRUNTF_3", "GRUNTF_4", "GRUNTF_5"}, + ["GRUNTM"] = {"GRUNTM_1", "GRUNTM_2", "GRUNTM_3", "GRUNTM_4", "GRUNTM_5", "GRUNTM_6", "GRUNTM_7", "GRUNTM_8", "GRUNTM_9", "GRUNTM_10", "GRUNTM_11", "GRUNTM_12", "GRUNTM_13", "GRUNTM_14", "GRUNTM_15", "GRUNTM_16", "GRUNTM_17", "GRUNTM_18", "GRUNTM_19", "GRUNTM_20", "GRUNTM_21", "GRUNTM_22", "GRUNTM_23", "GRUNTM_24", "GRUNTM_25", "GRUNTM_26", "GRUNTM_27", "GRUNTM_28", "GRUNTM_29", "GRUNTM_30", "GRUNTM_31"}, + ["GUITARIST"] = {"CLYDE", "VINCENT"}, + ["HIKER"] = {"ANTHONY1", "RUSSELL", "PHILLIP", "LEONARD", "ANTHONY2", "BENJAMIN", "ERIK", "MICHAEL", "PARRY1", "TIMOTHY", "BAILEY", "ANTHONY3", "TIM", "NOLAND", "SIDNEY", "KENNY", "JIM", "DANIEL", "PARRY2", "PARRY3", "ANTHONY4", "ANTHONY5"}, + ["JANINE"] = {"JANINE1"}, + ["JASMINE"] = {"JASMINE1"}, + ["JUGGLER"] = {"IRWIN1", "FRITZ", "HORTON", "IRWIN2", "IRWIN3", "IRWIN4"}, + ["KAREN"] = {"KAREN1"}, + ["KIMONO_GIRL"] = {"NAOKO_UNUSED", "NAOKO", "SAYO", "ZUKI", "KUNI", "MIKI"}, + ["KOGA"] = {"KOGA1"}, + ["LASS"] = {"CARRIE", "BRIDGET", "ALICE", "KRISE", "CONNIE1", "LINDA", "LAURA", "SHANNON", "MICHELLE", "DANA1", "ELLEN", "CONNIE2", "CONNIE3", "DANA2", "DANA3", "DANA4", "DANA5"}, + ["LT_SURGE"] = {"LT_SURGE1"}, + ["MEDIUM"] = {"MARTHA", "GRACE", "BETHANY", "MARGRET", "ETHEL", "REBECCA", "DORIS"}, + ["MISTY"] = {"MISTY1"}, + ["MORTY"] = {"MORTY1"}, + ["MYSTICALMAN"] = {"EUSINE"}, + ["OFFICER"] = {"KEITH", "DIRK"}, + ["PICNICKER"] = {"LIZ1", "GINA1", "BROOKE", "KIM", "CINDY", "HOPE", "SHARON", "DEBRA", "GINA2", "ERIN1", "LIZ2", "LIZ3", "HEIDI", "EDNA", "GINA3", "TIFFANY1", "TIFFANY2", "ERIN2", "TANYA", "TIFFANY3", "ERIN3", "LIZ4", "LIZ5", "GINA4", "GINA5", "TIFFANY4"}, + ["POKEFANF"] = {"BEVERLY1", "RUTH", "BEVERLY2", "BEVERLY3", "GEORGIA", "JAIME"}, + ["POKEFANM"] = {"WILLIAM", "DEREK1", "ROBERT", "JOSHUA", "CARTER", "TREVOR", "BRANDON", "JEREMY", "COLIN", "DEREK2", "DEREK3", "ALEX", "REX", "ALLAN"}, + ["POKEMANIAC"] = {"LARRY", "ANDREW", "CALVIN", "SHANE", "BEN", "BRENT1", "RON", "ETHAN", "BRENT2", "BRENT3", "ISSAC", "DONALD", "ZACH", "BRENT4", "MILLER"}, + ["POKEMON_PROF"] = {}, + ["PRYCE"] = {"PRYCE1"}, + ["PSYCHIC_T"] = {"NATHAN", "FRANKLIN", "HERMAN", "FIDEL", "GREG", "NORMAN", "MARK", "PHIL", "RICHARD", "GILBERT", "JARED", "RODNEY"}, + ["RED"] = {"RED1"}, + ["RIVAL1"] = {"RIVAL1_1_CHIKORITA", "RIVAL1_1_CYNDAQUIL", "RIVAL1_1_TOTODILE", "RIVAL1_2_CHIKORITA", "RIVAL1_2_CYNDAQUIL", "RIVAL1_2_TOTODILE", "RIVAL1_3_CHIKORITA", "RIVAL1_3_CYNDAQUIL", "RIVAL1_3_TOTODILE", "RIVAL1_4_CHIKORITA", "RIVAL1_4_CYNDAQUIL", "RIVAL1_4_TOTODILE", "RIVAL1_5_CHIKORITA", "RIVAL1_5_CYNDAQUIL", "RIVAL1_5_TOTODILE"}, + ["RIVAL2"] = {"RIVAL2_1_CHIKORITA", "RIVAL2_1_CYNDAQUIL", "RIVAL2_1_TOTODILE", "RIVAL2_2_CHIKORITA", "RIVAL2_2_CYNDAQUIL", "RIVAL2_2_TOTODILE"}, + ["SABRINA"] = {"SABRINA1"}, + ["SAGE"] = {"CHOW", "NICO", "JIN", "TROY", "JEFFREY", "PING", "EDMOND", "NEAL", "LI", "GAKU", "MASA", "KOJI"}, + ["SAILOR"] = {"EUGENE", "HUEY1", "TERRELL", "KENT", "ERNEST", "JEFF", "GARRETT", "KENNETH", "STANLY", "HARRY", "HUEY2", "HUEY3", "HUEY4"}, + ["SCHOOLBOY"] = {"JACK1", "KIPP", "ALAN1", "JOHNNY", "DANNY", "TOMMY", "DUDLEY", "JOE", "BILLY", "CHAD1", "NATE", "RICKY", "JACK2", "JACK3", "ALAN2", "ALAN3", "CHAD2", "CHAD3", "JACK4", "JACK5", "ALAN4", "ALAN5", "CHAD4", "CHAD5"}, + ["SCIENTIST"] = {"ROSS", "MITCH", "JED", "MARC", "RICH"}, + ["SKIER"] = {"ROXANNE", "CLARISSA"}, + ["SUPER_NERD"] = {"STAN", "ERIC", "GREGG", "JAY", "DAVE", "SAM", "TOM", "PAT", "SHAWN", "TERU", "RUSS", "NORTON", "HUGH", "MARKUS"}, + ["SWIMMERF"] = {"ELAINE", "PAULA", "KAYLEE", "SUSIE", "DENISE", "KARA", "WENDY", "LISA", "JILL", "MARY", "KATIE", "DAWN", "TARA", "NICOLE", "LORI", "JODY", "NIKKI", "DIANA", "BRIANA"}, + ["SWIMMERM"] = {"HAROLD", "SIMON", "RANDALL", "CHARLIE", "GEORGE", "BERKE", "KIRK", "MATHEW", "HAL", "PATON", "DARYL", "WALTER", "TONY", "JEROME", "TUCKER", "RICK", "CAMERON", "SETH", "JAMES", "LEWIS", "PARKER"}, + ["TEACHER"] = {"COLETTE", "HILLARY", "SHIRLEY"}, + ["TRAINER_NONE"] = {}, + ["TWINS"] = {"AMYANDMAY1", "ANNANDANNE1", "ANNANDANNE2", "AMYANDMAY2", "JOANDZOE1", "JOANDZOE2", "MEGANDPEG1", "MEGANDPEG2", "LEAANDPIA1", "LEAANDPIA2"}, + ["WHITNEY"] = {"WHITNEY1"}, + ["WILL"] = {"WILL1"}, + ["YOUNGSTER"] = {"JOEY1", "MIKEY", "ALBERT", "GORDON", "SAMUEL", "IAN", "JOEY2", "JOEY3", "WARREN", "JIMMY", "OWEN", "JASON", "JOEY4", "JOEY5"}, + }, + ["trainerClassOrder"] = {"TRAINER_NONE", "FALKNER", "WHITNEY", "BUGSY", "MORTY", "PRYCE", "JASMINE", "CHUCK", "CLAIR", "RIVAL1", "POKEMON_PROF", "WILL", "CAL", "BRUNO", "KAREN", "KOGA", "CHAMPION", "BROCK", "MISTY", "LT_SURGE", "SCIENTIST", "ERIKA", "YOUNGSTER", "SCHOOLBOY", "BIRD_KEEPER", "LASS", "JANINE", "COOLTRAINERM", "COOLTRAINERF", "BEAUTY", "POKEMANIAC", "GRUNTM", "GENTLEMAN", "SKIER", "TEACHER", "SABRINA", "BUG_CATCHER", "FISHER", "SWIMMERM", "SWIMMERF", "SAILOR", "SUPER_NERD", "RIVAL2", "GUITARIST", "HIKER", "BIKER", "BLAINE", "BURGLAR", "FIREBREATHER", "JUGGLER", "BLACKBELT_T", "EXECUTIVEM", "PSYCHIC_T", "PICNICKER", "CAMPER", "EXECUTIVEF", "SAGE", "MEDIUM", "BOARDER", "POKEFANM", "KIMONO_GIRL", "TWINS", "POKEFANF", "RED", "BLUE", "OFFICER", "GRUNTF", "MYSTICALMAN"}, +} +local function copy(value) + if type(value) ~= "table" then return value end + local out = {} + for key, item in pairs(value) do out[key] = copy(item) end + return out +end +function M.forVersion(version) + local data = (version == "gold" or version == "silver") and gold + or version == "crystal" and crystal or nil + return data and copy(data) +end +function M.install(constants, manifest, version) + local supplied = manifest and manifest.constants or {} + local keys = { "phoneContactOrder", "trainerClassOrder", "trainerClassMembers" } + local authored = false + for _, key in ipairs(keys) do + if constants[key] ~= nil or supplied[key] ~= nil then authored = true end + end + -- A partial custom identity set must not silently mix with a retail roster. + local fallback = not authored and M.forVersion(version) or {} + for _, key in ipairs(keys) do + -- Authored native constants win. Manifests can explicitly describe an + -- alternative cartridge; retail identities never guess a hack's roster. + if constants[key] == nil then + local value = type(supplied[key]) == "table" and supplied[key] or fallback[key] + if value then constants[key] = copy(value) end + end + end + return constants +end +return M diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 90b74029e..27bacb8bf 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -961,6 +961,9 @@ function RomExtractorGen2:constants() -- Generation is runtime metadata, not cartridge data. Battle formulas use -- it to select the Gen II critical-hit ladder without guessing from a ROM id. self._constants.generation = 2 + -- Numeric phone/trainer identities are independent of localized names. + require("src.import.Gen2PhoneIdentities").install( + self._constants, self.manifest, self.version) local badges = {} -- A CARTRIDGE MAY HAVE ITS OWN SET. Prism awards TWENTY badges across -- three engine-flag bytes, and its flag keys keep the ENGINE_ prefix that @@ -8431,7 +8434,6 @@ end -- POKeGEAR had no dialogue at all to show. local GEN2_PHONE_ROW_BYTES = 12 local GEN2_PHONE_MAX = 64 -local GEN2_NONTRAINER_NAMES = 5 -- db class, id | db group, number | db ptime, bank, dw callee script (the one -- that runs when YOU ring them) | db ctime, bank, dw caller script (the one -- that runs when THEY ring you -- ELM's is the same $41E1 @@ -8447,14 +8449,30 @@ function RomExtractorGen2:gen2PhoneContacts(pool) local sym = self:symbol("PhoneContacts") if not (sym and self.rom) then return nil end + -- SpecialPhoneCallList closes the table, including the reserved holes. + local limit = GEN2_PHONE_MAX + local stop = self:symbol("SpecialPhoneCallList") + if stop and stop.bank == sym.bank and stop.address > sym.address then + limit = math.floor((stop.address - sym.address) / GEN2_PHONE_ROW_BYTES) + end + local rows, rowsRead, lastName = {}, 0, 0 + for id = 0, limit - 1 do + local ok, row = pcall(self.rom.bytes, self.rom, sym.bank, + sym.address + id * GEN2_PHONE_ROW_BYTES, GEN2_PHONE_ROW_BYTES) + if not ok then break end + rows[id], rowsRead = row, rowsRead + 1 + if row[1] == 0 then lastName = math.max(lastName, row[2]) end + end + -- Polished names can contain a Huffman-compressed suffix ($5D). - -- Decode the string stream before applying its charmap. + -- Decode the string stream before applying its charmap. Read every name + -- referenced by the actual table: the Gold-sized four-name bound discarded + -- Crystal's Buena row (non-trainer identity5), including both call scripts. local names = {} local nsym = self:symbol("NonTrainerCallerNames") if nsym then local polished = self.version == "polishedcrystal" - local count = polished and 7 or GEN2_NONTRAINER_NAMES - for id = 1, count - 1 do + for id = 1, lastName do local ok, address = pcall(self.rom.word, self.rom, nsym.bank, nsym.address + id * 2) if ok and self:gen2InRom(nsym.bank, address) then @@ -8470,18 +8488,9 @@ function RomExtractorGen2:gen2PhoneContacts(pool) end end - -- SpecialPhoneCallList closes the table; sizing off it beats guessing - local limit = GEN2_PHONE_MAX - local stop = self:symbol("SpecialPhoneCallList") - if stop and stop.bank == sym.bank and stop.address > sym.address then - limit = math.floor((stop.address - sym.address) / GEN2_PHONE_ROW_BYTES) - end - local out = {} - for id = 0, limit - 1 do - local ok, row = pcall(self.rom.bytes, self.rom, sym.bank, - sym.address + id * GEN2_PHONE_ROW_BYTES, GEN2_PHONE_ROW_BYTES) - if not ok then break end + for id = 0, rowsRead - 1 do + local row = rows[id] local class, trainer = row[1], row[2] -- the unused rows are class 0 / id 0, which GetCallerName renders as the -- "----------" placeholder; skip them so the POKeGEAR can never list one @@ -8490,7 +8499,8 @@ function RomExtractorGen2:gen2PhoneContacts(pool) entry = { class = class, trainer = trainer } elseif trainer ~= 0 and names[trainer] then local name, role = names[trainer]:match("^([^\n\f\v]+)[\n\f\v]%s*(.*)$") - entry = { name = (name or names[trainer]):gsub(":$", ""), role = role } + entry = { name = (name or names[trainer]):gsub(":$", ""), role = role, + contactNumber = trainer } end -- The contact's OWN map, bytes 3 and 4. GetAvailableCallers (36:$40DE) -- compares it against wMapGroup/wMapNumber and drops the contact from the @@ -8510,8 +8520,9 @@ function RomExtractorGen2:gen2PhoneContacts(pool) end out[id] = entry end - if not next(out) then return nil end - return out + local info = { size = limit, rowsRead = rowsRead, nonTrainerNames = names } + if not next(out) then return nil, info end + return out, info end -- `SpecialPhoneCallList` ([36,$45F6] .. PhoneOutOfAreaScript, 48 bytes) is @@ -8900,7 +8911,7 @@ function RomExtractorGen2:extractMapScripts() self:beginStage("Gen2 map scripts -- drawn in. self._gen2Spawns = out.spawns self._gen2MapDefs = maps - out.phone = self:gen2PhoneContacts(pool) + out.phone, out.phoneInfo = self:gen2PhoneContacts(pool) out.specialCalls = self:gen2SpecialPhoneCalls(pool) -- wVariableSprites as InitializeEventsScript leaves it on a new game, so an -- object_event whose sprite byte is $F0 or more resolves before the map's diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 858974555..1b31573e8 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1306,7 +1306,7 @@ local function markerFor(version) -- Only these three caches need rebuilding; other markers remain stable. -- v8: field.gen2TradeAnim, GSC's own trade movie art (Gen2TradeArt). if version == "gold" or version == "silver" or version == "crystal" then - revision = "gen2-field-encounters-v8:" + revision = "gen2-field-encounters-v8:gen2-phone-identities-v1:" end -- field.tradeArt: the cable-trade movie's tiles out of the ROM -- (Gen1TradeArt); without it the trade ran on drawn stand-ins. diff --git a/tests/engine/gen2_phone_identities.lua b/tests/engine/gen2_phone_identities.lua new file mode 100644 index 000000000..4b1350476 --- /dev/null +++ b/tests/engine/gen2_phone_identities.lua @@ -0,0 +1,111 @@ +-- Copyright (c) 2026 Roxas2712. See offered PR contribution permission. +-- Synthetic native ROM rows distinguish contact numbers from map numbers. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Ids=require('src.import.Gen2PhoneIdentities') +local Extractor=require('src.import.RomExtractorGen2') +local V=require('src.core.GameVersion');local initial=V.get() +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + local ids=Ids.forVersion(version) + T.eq(#ids.phoneContactOrder,version=='crystal' and 38 or 37,version..' retail row bound includes holes') + T.eq(ids.phoneContactOrder[9],'PHONE_UNUSED',version..' reserved contact hole is explicit') + T.eq(ids.phoneContactOrder[2],'PHONE_MOM',version..' contact identity is numeric-order metadata') + T.eq(ids.phoneContactOrder[3],'PHONE_OAK',version..' retain reference spelling without renaming native slot2') + ids.phoneContactOrder[2]='LEAK';ids.trainerClassMembers.FALKNER[1]='LEAK' + T.eq(Ids.forVersion(version).phoneContactOrder[2],'PHONE_MOM',version..' contact metadata detached') + T.neq(Ids.forVersion(version).trainerClassMembers.FALKNER[1],'LEAK',version..' nested trainer metadata detached') + local bytes={};for i=1,0x8000 do bytes[i]=0 end + local function put(at,values)for i,v in ipairs(values)do bytes[at+i]=v end end + -- Four rows: unused0, non-trainer1, trainer2 and a reserved unused3. + put(0x4000+12,{0,2,9,42,7,1,0x00,0x50,2,1,0x10,0x50}) + put(0x4000+24,{12,3,5,6,1,1,0x20,0x50,4,1,0x30,0x50}) + -- Pointer slot2 names the actual imported display string; it is deliberately + -- unrelated to the legacy PHONE_OAK compatibility identifier at index2. + put(0x4204,{0x00,0x43});put(0x4300,{0x81,0x88,0x8A,0x84,0x7F,0x92,0x87,0x8E,0x8F,0x50}) + -- Crystal's late non-trainer record at native index37 names slot5. Earlier + -- code read only names1..4 and silently discarded this entire native row. + put(0x4000+37*12,{0,5,3,77,7,1,0x40,0x50,7,1,0x50,0x50}) + put(0x420a,{0x10,0x43});put(0x4310,{0x81,0x94,0x84,0x8d,0x80,0x50}) + local chunks={};for i=1,#bytes,256 do chunks[#chunks+1]=string.char(unpack(bytes,i,math.min(i+255,#bytes)))end + local e=Extractor.new(table.concat(chunks),version,{symbols={PhoneContacts={1,0x4000},SpecialPhoneCallList={1,0x4030},NonTrainerCallerNames={1,0x4200}}}) + function e:readSourceTable()return {}end + local queued={} + function e:gen2QueueScript(bank,address) + local id=string.format('S%02X_%04X',bank,address);queued[#queued+1]=id;return id + end + local rows,info=e:gen2PhoneContacts({}) + T.eq(info.size,4,version..' imported full native bound retains unused slots') + T.eq(info.rowsRead,4,version..' actual imported row count is explicit') + T.eq(rows[0],nil,version..' unused0 is not invented as a callable contact') + T.eq(rows[3],nil,version..' reserved hole is retained by bound, not a fake contact') + T.eq(rows[1].contactNumber,2,version..' actual non-trainer identity retained') + T.eq(rows[1].number,42,version..' actual map number remains separate') + T.eq(rows[1].group,9,version..' actual map group remains native') + T.eq(rows[1].name,'BIKE SHOP',version..' native display text remains authoritative') + T.eq(info.nonTrainerNames[2],'BIKE SHOP',version..' imported non-trainer lookup retained') + T.eq(rows[1].call,'S01_5000',version..' callee pointer remains outgoing') + T.eq(rows[1].callTime,7,version..' callee time remains outgoing') + T.eq(rows[1].receive,'S01_5010',version..' caller pointer remains incoming') + T.eq(rows[1].receiveTime,2,version..' caller time remains incoming') + T.eq(rows[2].class,12,version..' native trainer class is numeric and unchanged') + T.eq(rows[2].trainer,3,version..' native roster member is unchanged') + T.eq(rows[2].contactNumber,nil,version..' trainer member is not a non-trainer contact number') + T.eq(rows[2].number,6,version..' trainer map number is not replaced by member') + T.eq(#queued,4,version..' both native phone directions are queued') + local constants=e:constants() + T.eq(constants.phoneContactOrder[2],'PHONE_MOM',version..' actual constants producer installs phone identities') + T.eq(constants.generation,2,version..' new metadata preserves native generation') + local empty=Extractor.new(string.rep('\0',0x8000),version,{symbols={PhoneContacts={1,0x4000},SpecialPhoneCallList={1,0x4030}}}) + local none,bounds=empty:gen2PhoneContacts({}) + T.eq(none,nil,version..' empty native book has no fake callable records') + T.eq(bounds.size,4,version..' empty native book still reserves its complete row range') + local buena=Extractor.new(table.concat(chunks),version,{symbols={PhoneContacts={1,0x4000},SpecialPhoneCallList={1,0x41c8},NonTrainerCallerNames={1,0x4200}}}) + local book,meta=buena:gen2PhoneContacts({scripts={},queue={}}) + T.eq(book[37] and book[37].name,'BUENA',version..' referenced name5 retains the complete native contact') + T.eq(book[37] and book[37].contactNumber,5,version..' late non-trainer keeps its own identity') + T.eq(book[37] and book[37].number,77,version..' late non-trainer map number remains independent') + T.eq(meta.size,38,version..' full native row range remains reserved') + local data=T.fixtures.fresh();data.map_scripts={phone=book,scripts={S01_5040={{'setval',41},{'end'}},S01_5050={{'setval',42},{'end'}}}} + local C=require('src.script.Gen2Commands') + T.eq(C.phoneName(data,37),'BUENA',version..' real native phone-name consumer can resolve the restored contact') + local game={data=data,save={},overworld={}} + for _,direction in ipairs({'call','receive'})do + local rows=direction=='call' and C.phoneCallScript(data,37) or C.phoneReceiveScript(data,37) + T.check(rows~=nil,version..' restored '..direction..' script is actually compiled') + local runner=require('src.script.ScriptRunner').new(game,game.overworld);runner:run(rows or {}) + T.eq(runner.ctx.g2Var,direction=='call' and 41 or 42,version..' real native '..direction..' executes correct script') + T.eq(runner:isRunning(),false,version..' native '..direction..' finishes') + end +end +for _,version in ipairs({'red','firered','emerald','prism','polishedcrystal'})do + T.eq(Ids.forVersion(version),nil,version..' does not receive guessed retail identities') +end +local authored={phoneContactOrder={'CUSTOM'},trainerClassOrder={'CUSTOM_CLASS'},trainerClassMembers={CUSTOM_CLASS={'ONE'}}} +local manifest={constants=authored};local mounted={} +Ids.install(mounted,manifest,'prism') +T.eq(mounted.phoneContactOrder[1],'CUSTOM','manifest explicitly describes an alternative cartridge') +authored.trainerClassMembers.CUSTOM_CLASS[1]='LEAK' +T.eq(mounted.trainerClassMembers.CUSTOM_CLASS[1],'ONE','manifest roster metadata is detached') +local original={phoneContactOrder={'NATIVE'}};Ids.install(original,manifest,'gold') +T.eq(original.phoneContactOrder[1],'NATIVE','existing authored constants remain authoritative') +local custom={trainerClassOrder={'CUSTOM'}};Ids.install(custom,nil,'gold') +T.eq(custom.trainerClassMembers,nil,'partial authored roster cannot inherit unrelated retail members') +T.eq(custom.phoneContactOrder,nil,'partial authored identity set cannot guess retail contact names') +local Importer=require('src.import.RomImporter');local marker +for i=1,100 do local name,value=debug.getupvalue(Importer.readyReport,i);if not name then break end;if name=='markerFor'then marker=value;break end end +assert(marker) +for _,version in ipairs({'gold','silver','crystal'})do + local current=marker(version) + T.check(current:find('gen2-phone-identities-v1:',1,true)~=nil,version..' existing native import must rebuild once') + local old=current:gsub('gen2%-phone%-identities%-v1:','') + local V=require('src.core.GameVersion');local previous=V.get();V.set(version) + love.filesystem.write(V.cachePrefix(version)..'rom-cache.complete',old) + T.eq(Importer.readyReport(version).ok,false,version..' real readiness gate rejects missing phone metadata') + V.set(previous) +end +for _,version in ipairs({'red','firered','emerald','prism','polishedcrystal'})do + T.check(not marker(version):find('gen2-phone-identities-v1:',1,true),version..' unrelated cache marker is unchanged') +end +V.set(initial) +T.finish('native phone/trainer identities and reserved-row import metadata') From 0956e2e1773739fb619475dc8f1d8578532bc8cd Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:32:56 +0100 Subject: [PATCH 066/138] Respect already-loaded native GC delegation without loading a provider --- src/core/Game.lua | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/core/Game.lua b/src/core/Game.lua index 9be08083e..8aa932b54 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -637,11 +637,14 @@ function Game:update(dt) pcall(function() require("src.core.DiscordPresence").update(dt) end) close() end + -- Keep an already-loaded native GC delegation at its update boundary. -- Ordinary engine collection moves to spare deadline time in love.run. - -- P2 disabled retains the original native update step. if collectgarbage then local close = FrameProfile.section(" update: gc step") - if not Performance.gcBudget then collectgarbage("step", 1) end + local facade = package.loaded["src.core.Game3"] + local handled = type(facade) == "table" and type(facade._stepGC) == "function" and facade._stepGC(self) + if handled then GCFrameBudget._facadeHandled() end + if not handled and not Performance.gcBudget then collectgarbage("step", 1) end close() end end From c63cd5d150bb5b8ad9bc1d7756d417e204cd22db Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:41:33 +0100 Subject: [PATCH 067/138] Connect native Gen2 phone contacts to the semantic content registry --- docs/native-gen2-phone-content.md | 32 +++++ src/mods/Loader.lua | 8 +- src/mods/PhoneContent.lua | 211 ++++++++++++++++++++++++++++ src/mods/Schemas.lua | 13 ++ tests/engine/gen2_phone_content.lua | 165 ++++++++++++++++++++++ 5 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 docs/native-gen2-phone-content.md create mode 100644 src/mods/PhoneContent.lua create mode 100644 tests/engine/gen2_phone_content.lua diff --git a/docs/native-gen2-phone-content.md b/docs/native-gen2-phone-content.md new file mode 100644 index 000000000..01ff19004 --- /dev/null +++ b/docs/native-gen2-phone-content.md @@ -0,0 +1,32 @@ +# Semantic contacts in the native Gen2 phone book + +`phone_contacts` presents imported numeric records through explicit phone and +trainer identity metadata. Changes reach `map_scripts.phone`, the actual +Pokegear, native incoming-call selector and Gen2ScriptVM phone scripts. The +non-trainer `number` is kept separate from native map group/number fields. +Names remain imported or authored text; localized trainer names never select +trainer identities. Ambiguous map spellings are not guessed. + +New contacts append after the full imported row bound, including reserved +holes. Existing indices cannot move; removed records retain reservations +within the load. Explicit indices are recommended when changing enabled mod +sets. Saved numeric slots are never rewritten. Reads and iterator rows are +detached; finite/cycle, identity, index, mask and native-script validation +happens before journaling the operation. Failed entries roll back, reload +retires unchanged owned rows, and external native edits survive. Other +generations report an unavailable target and perform no native writes. + +Caller/callee names and `callerKey`/`calleeKey` currently address existing +imported native script keys. Native VM execution remains authoritative. +Authored new scripts, forward references, the complete legacy Phone facade, +rematch-event semantics and actual story/hardware acceptance remain open. +This is a native content bridge, not a complete phone parity claim. + +The focused synthetic suite runs the actual Loader, Pokegear saved book, +incoming selector, Gen2ScriptVM and ScriptRunner across Gold/Silver/Crystal, +API1/2 native. It also checks invalid-entry rollback, +index ownership, detached reads, reload, external changes and target isolation. +No GPU or complete playthrough claim follows from these tests. + +Original implementation by Roxas2712; existing upstream and third-party rights +and credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..f290104d2 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -717,7 +717,7 @@ function Loader:_contentApi(mod, registry, deprecation) if apiLevel >= 2 then error(err, 0) end Logger.warn("[%s] %s", modId, err) end - return { + local api = { register = function(_, id, value) note() validate("register", id, value) @@ -750,6 +750,10 @@ function Loader:_contentApi(mod, registry, deprecation) return registry:each() end, } + if registry.name == "phone_contacts" then + return require("src.mods.PhoneContent").api(loader,mod,api,loader:_generation()) + end + return api end -- mod.commands is sugar over the commands registry; the engine's own verbs @@ -1328,6 +1332,7 @@ function Loader:load(data) end -- vanilla content is registrations too, and they land before discovery so -- a mod's register collides with the engine's and has to say override + require("src.mods.PhoneContent").prepare(self, data) require("src.mods.Builtins").install(self.content, data) self:_loadState() self:_discover() @@ -1551,6 +1556,7 @@ function Loader:load(data) stampAudioOwners(data, name, registry) end end + require("src.mods.PhoneContent").apply(self, data) -- dangling f.id references are attributed to the id's last writer; -- api 1 mods keep the warning-only compat path if data then diff --git a/src/mods/PhoneContent.lua b/src/mods/PhoneContent.lua new file mode 100644 index 000000000..71878786c --- /dev/null +++ b/src/mods/PhoneContent.lua @@ -0,0 +1,211 @@ +-- Copyright (c) 2026 Roxas2712. See the offered PR contribution permission in docs/native-gen2-phone-content.md. +-- Semantic contacts project onto the native numeric book, never onto saves. +local Merge = require('src.mods.Merge') +local M = {} +local previous = setmetatable({}, {__mode='k'}) +local mounted = setmetatable({}, {__mode='k'}) +local copy = Merge.deepCopy +local function equal(a,b) + if a==b then return true end + if type(a)~='table' or type(b)~='table' then return false end + for k,v in pairs(a) do if not equal(v,b[k]) then return false end end + for k in pairs(b) do if a[k]==nil then return false end end + return true +end +local function finite(v,seen) + if type(v)=='number' then assert(v==v and math.abs(v)=1 and index<=255,'phone_contacts: invalid or exhausted index') + assert(not used[index] or used[index]==id,'phone_contacts: index collision') + assert(old or index>state.floor,'phone_contacts: native rows and holes are reserved') + return index +end +local function nativeRow(loader,id,row) + local state=assert(mounted[loader]);local data=state.data + local old=state.native[id] or {} + local out=copy(old) + out.name=row.name;out.contactNumber=row.number;out.rematchEvent=row.rematchEvent + if row.class or row.member then + assert(row.class and row.member,'phone_contacts: class and member are required together') + assert(row.number==nil,'phone_contacts: trainer and non-trainer identities are exclusive') + local c=data.constants or {} + out.class=assert(position(c.trainerClassOrder,row.class),'phone_contacts: unknown trainer class') + out.trainer=assert(position((c.trainerClassMembers or {})[row.class],row.member),'phone_contacts: unknown trainer member') + out.contactNumber=nil + elseif not (old.class and state.base[id] and not state.base[id].class) then + out.class=nil;out.trainer=nil + end + if row.map then + local def=loader.content.maps:get(row.map) + assert(type(def)=='table' and type(def.group)=='number' and type(def.number)=='number', + 'phone_contacts: map requires native group/number metadata') + out.group,out.number=def.group,def.number + elseif state.base[id] and state.base[id].map then + out.group,out.number=nil,nil + end + out.call=row.calleeKey or row.callee + out.receive=row.callerKey or row.caller + out.callTime,out.receiveTime=row.calleeTime or 0,row.callerTime or 0 + for _,field in ipairs({'call','receive'}) do + local key=out[field];local mask=out[field..'Time'] + assert(mask==0 or type(key)=='string' and key~='','phone_contacts: enabled direction needs a script key') + -- This version delegates existing imported scripts to the real native VM. + -- It does not interpret Gen1 bytecode or fabricate missing script labels. + if key then assert(data.map_scripts and data.map_scripts.scripts and data.map_scripts.scripts[key], + 'phone_contacts: script key is not in the native script pool: '..tostring(key)) end + end + assert(type(row.name)=='string' and row.name~='' or out.class, + 'phone_contacts: contact needs a name or trainer identity') + return out +end +function M.apply(loader,data) + local registry=loader.content.phone_contacts + if loader:_generation()~=2 or not data or not next(registry.ops) then return end + local state=assert(mounted[loader]);local plan={} + for _,id in ipairs(registry.order) do + if registry.ops[id] then + local row=registry:get(id);local old=state.base[id] + local index=row and row.index or old and old.index + if index then plan[#plan+1]={index=index,value=row and nativeRow(loader,id,row)} end + end + end + -- Validate every conversion before the first native write. + local createdPool=data.map_scripts==nil + data.map_scripts=data.map_scripts or {} + local createdPhone=data.map_scripts.phone==nil + data.map_scripts.phone=data.map_scripts.phone or {} + local target=data.map_scripts.phone;local undo={createdPool=createdPool,createdPhone=createdPhone,pool=data.map_scripts,target=target} + for _,entry in ipairs(plan) do + undo[#undo+1]={target=target,index=entry.index,before=target[entry.index], + value=entry.value,snapshot=copy(entry.value)} + target[entry.index]=entry.value + end + previous[data]=undo +end +function M.api(loader,mod,native,generation) + local registry=loader.content.phone_contacts;local available=generation==2 + local reason=not available and 'unsupported: phone_contacts has no native target in this dataset' or nil + local out={available=available,supports=function() return available,reason end} + local function view(row) + row=copy(row) + if row then row.callee=row.calleeKey or row.callee;row.caller=row.callerKey or row.caller end + return row + end + function out:get(id) if not available then return nil,reason end;return view(native:get(id)) end + function out:has(id) return self:get(id)~=nil end + function out:each() + if not available then return function() end end + local nextRow=native:each() + return function() local id,row=nextRow();return id,view(row) end + end + for _,verb in ipairs({'register','override','patch','remove'}) do + out[verb]=function(_,id,value) + if not available then return nil,reason end + assert(type(id)=='string' and id~='','phone_contacts: id is required') + if verb=='remove' then native:remove(id);return nil end + finite(value);assert(type(value)=='table' and value~=Merge.DELETE,'phone_contacts: expected record') + for key in pairs(value) do assert(registry.spec.fields[key],'phone_contacts: unknown field '..tostring(key)) end + local ok,err=require('src.mods.Schemas').check(registry.spec,'phone_contacts',id,value,verb) + assert(ok,err) + local payload=copy(value) + for _,direction in ipairs({'callee','caller'}) do + if payload[direction]~=nil then + local key=direction..'Key' + assert(payload[key]==nil or payload[key]==payload[direction], + 'phone_contacts: conflicting script aliases') + payload[key]=payload[direction];payload[direction]=nil + end + end + payload.index=indexFor(registry,assert(mounted[loader]),id,value) + local effective=Merge.deepMerge(verb=='patch' and copy(registry:get(id) or {}) or {},payload,'record') + nativeRow(loader,id,effective) + if verb~='patch' then payload=effective end + native[verb](native,id,payload) + return out:get(id) + end + end + return out +end +return M diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 9014835cc..7ab049905 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -1259,6 +1259,19 @@ R.text_pointers = { -- ------- persistence +R.phone_contacts = { + semantics = "record", generations = "2", + fields = { + index=f.opt(f.int(1,255)), number=f.opt(f.int(0,255)), + name=f.opt(f.str), class=f.opt(f.str), member=f.opt(f.str), + map=f.opt(f.id("maps")), rematchEvent=f.opt(f.int(0,2047)), + calleeTime=f.opt(f.int(0,7)), callerTime=f.opt(f.int(0,7)), + callee=f.opt(f.str), caller=f.opt(f.str), calleeKey=f.opt(f.str), callerKey=f.opt(f.str), + }, + example='mod.content.phone_contacts:patch("PHONE_YOUNGSTER_JOEY", { map="ROUTE_31" })', + notes="Gen2 native numeric phone book. Existing indices and reserved holes remain fixed; new contacts append at 1..255. Caller/callee keys must name existing native imported scripts, not Gen1 bytecode. New authored scripts, forward script/map references and the legacy Phone facade are separate work.", +} + -- compose, keyed by the owning mod id: the runner walks each owner's chain -- in semver order against the versions recorded in the save R.migrations = { diff --git a/tests/engine/gen2_phone_content.lua b/tests/engine/gen2_phone_content.lua new file mode 100644 index 000000000..6e90948da --- /dev/null +++ b/tests/engine/gen2_phone_content.lua @@ -0,0 +1,165 @@ +-- Copyright (c) 2026 Roxas2712. See the offered PR contribution permission in docs/native-gen2-phone-content.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +local Json=require('src.link.Json');local Loader=require('src.mods.Loader') +local C=require('src.script.Gen2Commands');local Gear=require('src.ui.PokegearMenu') +local function fixture() + local d=T.fixtures.fresh() + d.constants.phoneContactOrder={'PHONE_00','PHONE_MOM','PHONE_JOEY','PHONE_UNUSED'} + d.constants.trainerClassOrder={'YOUNGSTER'} + d.constants.trainerClassMembers={YOUNGSTER={'JOEY1'}} + d.maps.PALLET_TOWN={group=1,number=1};d.maps.ROUTE_31={group=1,number=2} + d.trainers={OPP_LOCALIZED={index=1,name='LOCAL CLASS',partyNames={'LOCAL JOEY'}}} + d.map_scripts={phoneInfo={size=4},phone={ + [1]={name='NATIVE MOM',contactNumber=1,group=1,number=1,call='OUT',callTime=7,receive='IN',receiveTime=0}, + [2]={class=1,trainer=1,group=1,number=2,call='OUT',callTime=7,receive='IN',receiveTime=7}, + },scripts={OUT={{'setval',41},{'end'}},IN={{'setval',42},{'end'}}}} + return d +end +local function load(d,source,api,format) + for root in pairs(require('src.mods.Builtins').namespaceRoots()) do d[root]=nil end + local files={} + if source then + files['mods/phone/manifest.json']=Json.encode({id='phone',name='Phone content',version='1.0.0', + api=api or 2,content_format=format or 'native',entry='main.lua',affects_link=false}) + files['mods/phone/main.lua']='local mod=...;mod.exports.api=mod;'..source + end + return T.sdk.loadMods(source and {'mods/phone'} or {},{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r) Loader.teardown();r.release() end +for _,version in ipairs({'gold','silver','crystal'}) do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}}) do + local d=fixture();local native=d.map_scripts.phone[2] + local r=load(d,[[ + assert(mod.content.phone_contacts:supports()) + local p=mod.content.phone_contacts + assert(p:get('PHONE_MOM').number==1) + assert(p:get('PHONE_MOM').map=='PALLET_TOWN') + assert(p:get('PHONE_MOM').callee=='OUT' and p:get('PHONE_MOM').caller=='IN') + assert(p:get('PHONE_JOEY').class=='YOUNGSTER') + assert(p:get('PHONE_JOEY').member=='JOEY1') + local row=p:get('PHONE_MOM');row.name='LEAK' + assert(p:get('PHONE_MOM').name=='NATIVE MOM') + for id,value in p:each() do value.name='LEAK' end + assert(p:get('PHONE_MOM').name=='NATIVE MOM') + p:patch('PHONE_MOM',{name='AUTHORED MOM',map='ROUTE_31',callerTime=7}) + p:register('PHONE_EXTRA',{name='EXTRA',map='PALLET_TOWN',calleeTime=7,callerTime=7,calleeKey='OUT',callerKey='IN'}) + mod.exports.index=p:get('PHONE_EXTRA').index + p:remove('PHONE_JOEY') + ]],mode[1],mode[2]) + local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' real loader accepts semantic contacts') + local e=r.loader.exports.phone;local index=e and e.index + T.eq(index,4,label..' added contact skips all native holes') + T.eq(C.phoneName(d,1),'AUTHORED MOM',label..' native name consumer uses authored row') + T.eq(d.map_scripts.phone[1].contactNumber,1,label..' contact number stays separate') + T.eq(d.map_scripts.phone[1].number,2,label..' actual map number changes') + T.eq(d.map_scripts.phone[1].receiveTime,7,label..' native incoming time changes') + T.eq(C.phoneName(d,2),nil,label..' native removed contact stays absent') + T.eq(native.trainer,1,label..' source row remains unchanged') + if index then + local game={data=d,save={g2Phone={[index]=true}},overworld={}} + local gear=setmetatable({game=game},{__index=Gear}) + T.eq(#gear:contacts(),1,label..' actual native saved phone book sees addition') + for _,direction in ipairs({'call','receive'}) do + local rows=direction=='call' and C.phoneCallScript(d,index) or C.phoneReceiveScript(d,index) + T.check(rows~=nil,label..' '..direction..' compiles through native VM') + local runner=require('src.script.ScriptRunner').new(game,game.overworld);runner:run(rows or {}) + T.eq(runner.ctx.g2Var,direction=='call' and 41 or 42,label..' '..direction..' executes correct native direction') + end + T.eq(C.availableCallers(d,game.save,d.maps.ROUTE_31,'DAY')[1],index,label..' native incoming selector sees added contact') + T.eq(#C.availableCallers(d,game.save,d.maps.PALLET_TOWN,'DAY'),0,label..' same-map incoming suppression remains native') + end + finish(r) + local clean=load(d) + T.eq(#clean.errors,0,label..' disabling/reloading succeeds') + T.eq(C.phoneName(d,1),'NATIVE MOM',label..' disabling restores base name') + T.eq(C.phoneName(d,2),'LOCAL JOEY',label..' disabling restores native trainer') + T.eq(d.map_scripts.phone[4],nil,label..' disabling removes added numeric row') + finish(clean) + end +end +V.set('crystal') +do + local d=fixture();local r=load(d,[[local p=mod.content.phone_contacts + p:patch('PHONE_MOM',{callee='IN',caller='OUT',callerTime=7}) + ]]) + T.eq(#r.errors,0,'caller and callee aliases are accepted') + T.eq(d.map_scripts.phone[1].call,'IN','callee alias replaces previous outgoing key') + T.eq(d.map_scripts.phone[1].receive,'OUT','caller alias replaces previous incoming key') + local p=r.loader.exports.phone.api.content.phone_contacts + T.eq(p:get('PHONE_MOM').callee,'IN','public callee alias reflects the effective native key') + T.eq(p:get('PHONE_MOM').caller,'OUT','public caller alias reflects the effective native key') + finish(r) +end +do + local d=fixture();local r=load(d,[[local p=mod.content.phone_contacts + p:register('FIRST',{name='FIRST',index=255}) + p:register('SECOND',{name='SECOND'}) + assert(p:get('SECOND').index==4) + p:remove('SECOND') + p:register('THIRD',{name='THIRD'}) + assert(p:get('THIRD').index==5) + p:patch('PHONE_JOEY',{name='TRANSLATED'}) + ]]) + T.eq(#r.errors,0,'explicit high index and removed additions retain reservations') + T.eq(C.phoneName(d,255),'FIRST','native byte upper bound can be used explicitly') + T.eq(C.phoneName(d,4),nil,'added then removed contact leaves no callable row') + T.eq(C.phoneName(d,5),'THIRD','next addition cannot steal removed contact index') + T.eq(d.map_scripts.phone[2].class,1,'display override retains canonical trainer identity') + T.eq(d.map_scripts.phone[2].trainer,1,'display override retains trainer member') + local api=r.loader.exports.phone.api.content.phone_contacts + T.check(not pcall(function() api:patch('PHONE_MOM',{name='LATE'}) end),'frozen content rejects late mutation') + T.eq(C.phoneName(d,1),'NATIVE MOM','late mutation cannot change native data') + d.map_scripts.phone[2].name='EXTERNAL AFTER LOAD' + finish(r) + local clean=load(d) + T.eq(C.phoneName(d,2),'EXTERNAL AFTER LOAD','reload preserves an externally edited native contact') + T.eq(d.map_scripts.phone[255],nil,'reload retires unchanged added high-index contact') + finish(clean) +end +do + local d=fixture();d.map_scripts=nil + local r=load(d,"mod.content.phone_contacts:register('NEW',{name='NEW'})") + T.eq(#r.errors,0,'a named silent contact needs no native script pool') + T.eq(C.phoneName(d,4),'NEW','new native book respects explicit metadata floor') + finish(r) + local clean=load(d) + T.eq(d.map_scripts,nil,'reload removes an exclusively owned empty pool namespace') + finish(clean) +end +for _,source in ipairs({ + "p:register('BAD',{name='BAD',index=3})", + "p:register('BAD',{name='BAD',index=2})", + "p:patch('PHONE_MOM',{index=7})", + "p:register('BAD',{name='BAD',callerTime=7})", + "p:register('BAD',{name='BAD',callerTime=7,callerKey='MISSING'})", + "p:register('BAD',{class='YOUNGSTER',member='MISSING'})", + "p:register('BAD',{name='BAD',map='MISSING'})", + "p:register('BAD',{name='BAD',callerTime=0/0})", + "local x={};x.x=x;p:register('BAD',x)", + "p:register('BAD',{name='BAD',index=256})", + "p:register('BAD',{name='BAD',index=0})", + "p:register('BAD',{name='BAD',unknownField=true})", + "p:register('BAD',{class='YOUNGSTER',member='JOEY1',number=1})", + "p:register('BAD',{name='BAD',callee='IN',calleeKey='OUT'})", + "p:patch('PHONE_MOM',{name='FAILED'});error('entry failure')", +}) do + local d=fixture();local r=load(d,'local p=mod.content.phone_contacts;'..source) + T.check(#r.errors>0,'invalid entry is rejected and attributed: '..source) + T.eq(C.phoneName(d,1),'NATIVE MOM','failed entry rolls back earlier phone operations') + T.eq(d.map_scripts.phone[4],nil,'failed entry cannot leak a native numeric contact') + finish(r) +end +for _,version in ipairs({'red','firered','emerald','heartgold'}) do + V.set(version);local d=fixture();local originalRow=d.map_scripts.phone[1] + local r=load(d,[[local p=mod.content.phone_contacts;assert(not p:supports());assert(p:get('PHONE_MOM')==nil);p:register('OTHER',{name='NO WRITE'})]]) + T.eq(#r.errors,0,version..' unsupported target reports through capability handle') + T.eq(d.map_scripts.phone[1],originalRow,version..' other generations retain native-looking data unchanged') + T.eq(d.map_scripts.phone[4],nil,version..' cannot acquire a Gen2 phone book') + finish(r) +end +V.set(original) +T.finish('semantic native phone contacts, actual VM/phone book, rollback and isolation') From cb62f9404efd9bfbf34c92c5187d2357c0084cd2 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:42:49 +0100 Subject: [PATCH 068/138] Validate measured source fingerprints before and after native probes --- tools/check_frame_pacing.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/tools/check_frame_pacing.py b/tools/check_frame_pacing.py index f9addab0a..160706770 100644 --- a/tools/check_frame_pacing.py +++ b/tools/check_frame_pacing.py @@ -5,7 +5,6 @@ import json import os from pathlib import Path -import subprocess import sys import time @@ -25,6 +24,14 @@ def main(): parser.error('QA storage guard is required; provide --qa-tools') qa_source(root) qa_source(args.love.resolve()) + files = ('main.lua', 'src/core/FrameCap.lua', 'src/core/FrameProfile.lua', + 'tools/frame-pacing-check/main.lua', 'tools/frame-pacing-check/conf.lua') + if args.gc: + files += ('src/core/Performance.lua', 'src/core/GCFrameBudget.lua', 'src/core/Game.lua', + 'tools/frame-pacing-check/gc.lua') + hashes = {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in files} + source = qa_run(['git', 'rev-parse', 'HEAD'], cwd=root, text=True, + capture_output=True, check=True).stdout.strip() qa_cache(args.report.parent) args.report.parent.mkdir(parents=True, exist_ok=True) identity = 'oasc-gen2recomp-qa-frame-pacing-' + str(time.time_ns()) @@ -40,13 +47,15 @@ def main(): if result.returncode or len(records) != 1: raise RuntimeError('native pacing probe failed; see ' + str(args.report.with_suffix('.log'))) report = json.loads(records[0]) - report['source'] = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=root, text=True).strip() - files = ('main.lua', 'src/core/FrameCap.lua', 'src/core/FrameProfile.lua', - 'tools/frame-pacing-check/main.lua', 'tools/frame-pacing-check/conf.lua') - if args.gc: - files += ('src/core/Performance.lua', 'src/core/GCFrameBudget.lua', 'src/core/Game.lua', - 'tools/frame-pacing-check/gc.lua') - report['sha256'] = {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in files} + if any(hashlib.sha256((root / name).read_bytes()).hexdigest() != digest for name, digest in hashes.items()): + raise RuntimeError('Measured source changed during the pacing probe') + if qa_run(['git', 'rev-parse', 'HEAD'], cwd=root, text=True, + capture_output=True, check=True).stdout.strip() != source: + raise RuntimeError('Measured commit changed during the pacing probe') + report['source'] = source + report['sha256'] = hashes + report['minimumFreeGiB'] = 20 + report['playerProfilesUsed'] = False args.report.write_text(json.dumps(report, indent=2) + '\n') print(json.dumps(report, indent=2)) From 344531498c94e245d9c7a35f82f4f7c03c8b885a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:43:27 +0100 Subject: [PATCH 069/138] Keep explicit phone script keys authoritative during read edit write --- src/mods/PhoneContent.lua | 5 ++--- tests/engine/gen2_phone_content.lua | 9 ++++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/mods/PhoneContent.lua b/src/mods/PhoneContent.lua index 71878786c..b1a6372d5 100644 --- a/src/mods/PhoneContent.lua +++ b/src/mods/PhoneContent.lua @@ -193,9 +193,8 @@ function M.api(loader,mod,native,generation) for _,direction in ipairs({'callee','caller'}) do if payload[direction]~=nil then local key=direction..'Key' - assert(payload[key]==nil or payload[key]==payload[direction], - 'phone_contacts: conflicting script aliases') - payload[key]=payload[direction];payload[direction]=nil + if payload[key]==nil then payload[key]=payload[direction] end + payload[direction]=nil end end payload.index=indexFor(registry,assert(mounted[loader]),id,value) diff --git a/tests/engine/gen2_phone_content.lua b/tests/engine/gen2_phone_content.lua index 6e90948da..58450ec76 100644 --- a/tests/engine/gen2_phone_content.lua +++ b/tests/engine/gen2_phone_content.lua @@ -82,6 +82,14 @@ for _,version in ipairs({'gold','silver','crystal'}) do end end V.set('crystal') +do + local d=fixture();local r=load(d,[[local p=mod.content.phone_contacts + local row=p:get('PHONE_MOM');row.calleeKey='IN';p:override('PHONE_MOM',row) + ]]) + T.eq(#r.errors,0,'read/edit/write accepts an explicit script key alongside the prior alias') + T.eq(d.map_scripts.phone[1].call,'IN','explicit calleeKey owns the effective outgoing script') + finish(r) +end do local d=fixture();local r=load(d,[[local p=mod.content.phone_contacts p:patch('PHONE_MOM',{callee='IN',caller='OUT',callerTime=7}) @@ -144,7 +152,6 @@ for _,source in ipairs({ "p:register('BAD',{name='BAD',index=0})", "p:register('BAD',{name='BAD',unknownField=true})", "p:register('BAD',{class='YOUNGSTER',member='JOEY1',number=1})", - "p:register('BAD',{name='BAD',callee='IN',calleeKey='OUT'})", "p:patch('PHONE_MOM',{name='FAILED'});error('entry failure')", }) do local d=fixture();local r=load(d,'local p=mod.content.phone_contacts;'..source) From 884d963e76ac01ccb7d484d206466225c581c9a4 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:44:12 +0100 Subject: [PATCH 070/138] Name the native phone registry in its public API coverage test --- tests/engine/gen2_phone_content.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/engine/gen2_phone_content.lua b/tests/engine/gen2_phone_content.lua index 58450ec76..344faeb6c 100644 --- a/tests/engine/gen2_phone_content.lua +++ b/tests/engine/gen2_phone_content.lua @@ -34,7 +34,7 @@ for _,version in ipairs({'gold','silver','crystal'}) do local d=fixture();local native=d.map_scripts.phone[2] local r=load(d,[[ assert(mod.content.phone_contacts:supports()) - local p=mod.content.phone_contacts + local p=mod.content["phone_contacts"] assert(p:get('PHONE_MOM').number==1) assert(p:get('PHONE_MOM').map=='PALLET_TOWN') assert(p:get('PHONE_MOM').callee=='OUT' and p:get('PHONE_MOM').caller=='IN') From f0d4234a0c61f72fc1ec266626439563143b47ad Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:26:44 +0100 Subject: [PATCH 071/138] perf(P3): cache hot dependencies and retain private render scratch --- docs/hot-path-dispatch.md | 51 +++++++++++ src/core/Game.lua | 47 +++++++--- src/core/HotModules.lua | 34 ++++++++ src/core/Performance.lua | 2 + src/render/PaletteFX.lua | 3 +- src/render/Renderer.lua | 100 ++++++++++++++------- tests/engine/hot_modules.lua | 52 +++++++++++ tests/engine/hot_renderer_records.lua | 121 ++++++++++++++++++++++++++ tools/check_frame_pacing.py | 12 ++- tools/frame-pacing-check/hot.lua | 114 ++++++++++++++++++++++++ tools/frame-pacing-check/main.lua | 8 ++ 11 files changed, 494 insertions(+), 50 deletions(-) create mode 100644 docs/hot-path-dispatch.md create mode 100644 src/core/HotModules.lua create mode 100644 tests/engine/hot_modules.lua create mode 100644 tests/engine/hot_renderer_records.lua create mode 100644 tools/frame-pacing-check/hot.lua diff --git a/docs/hot-path-dispatch.md b/docs/hot-path-dispatch.md new file mode 100644 index 000000000..2905c39dd --- /dev/null +++ b/docs/hot-path-dispatch.md @@ -0,0 +1,51 @@ +# P3: native update/draw dependency dispatch + +`Performance.hotPathHoist` defaults to `true`. Set it to `false` to restore +per-call require/pcall dispatch, the per-frame blit closure and fresh native +presentation records independently of P1/P2. + +`HotModules` lazily retains successful module values. Live fields and explicit +replacement or clearing of `package.loaded` remain observable; failed optional +loads are not cached. The Discord update uses its existing protected call with +the original `dt`, without allocating a wrapper closure after initialization. +No module path, hook argument or hook ordering changes. + +The renderer's blit adapter carries DPI as explicit arguments and still calls +the public `blitCanvas` method. Its existing `uiPresentation` field becomes an +engine-owned live view. A table installed there by a consumer is never mutated +by the reuse path. + +Only the unwrapped native renderer may borrow the private viewport scratch. +Public `endFrame` calls always return detached records, and replacing that +method bypasses the private shortcut. HUD hook arguments are detached at the +moment of dispatch, including a hook registered during compose/output. The +fork's window UI also retains its public record path. Scratch is never included +in a hook record. + +Run `tools/check_frame_pacing.py --hot-path --love /path/to/love --report +/owned/qa/hot.json` through the QA storage guard. The owned synthetic state draws +a rectangle through real `Game:draw`, canvases and composition. Each switch +phase has 120 warm-up and 120 measured frames at 60 Hz, VSync 1, P1 enabled. +The log contains the actual F11 `draw: whole frame` section; raw JSON also records +total draw wall time, process CPU, source fingerprints and an untimed require +counter. This is not a complete overworld, battle or mod scene. Profiling is on +in both measured phases. + +Measured on macOS arm64 (milliseconds/frame, switch off → on): + +| Tree / backend | F11 draw (rounded) | Raw draw mean | Process CPU | require calls | +| --- | ---: | ---: | ---: | ---: | +| Fork / LÖVE 11.5 OpenGL | 0.16 → 0.21 | 0.166 → 0.218 | 0.787 → 0.919 | 15 → 6 | +| Fork / LÖVE 12 Metal | 16.36 → 16.38 | 16.373 → 16.383 | 1.134 → 1.026 | 15 → 6 | +| Public adaptation / LÖVE 11.5 OpenGL | 0.30 → 0.22 | 0.315 → 0.222 | 1.227 → 0.992 | 12 → 5 | +| Public adaptation / LÖVE 12 Metal | 16.43 → 16.40 | 16.440 → 16.410 | 0.849 → 1.003 | 12 → 5 | + +Queue submission can absorb GPU/VSync waiting in Metal; these are wall-time +sections, not isolated GPU durations. The short runs show reduced dependency +dispatch and known allocations, with mixed CPU/timing results. They do not +establish a general frame-rate or CPU improvement. Dedicated ARM handheld, +Android, long-session and full-game measurements: not measured. + +Regression checks cover cached dependency identity/replacement/retry, detached +public and late HUD records, method wrappers, DPI forwarding and private native +reuse. Existing output/hook tests and the compatibility acceptance are retained. diff --git a/src/core/Game.lua b/src/core/Game.lua index 8aa932b54..044c6546c 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -12,6 +12,7 @@ local StateStack = require("src.core.StateStack") local FrameProfile = require("src.core.FrameProfile") local Performance = require("src.core.Performance") local GCFrameBudget = require("src.core.GCFrameBudget") +local HotModules = require("src.core.HotModules") local TouchControls = require("src.core.TouchControls") local ModLoader = require("src.mods.Loader") local ModRuntime = require("src.mods.Runtime") @@ -588,7 +589,7 @@ function Game:updateCameraStick(dt) end function Game:update(dt) - require('src.pokemon.Gen4PoketchState').tick(self, dt) + HotModules.get('src.pokemon.Gen4PoketchState').tick(self, dt) local panel=self.secondScreenPointerOwner local top=self.stack and self.stack.top and self.stack:top() if panel and panel~=top and panel.updatePresentation then @@ -614,7 +615,7 @@ function Game:update(dt) local close = FrameProfile.section(" update: music") while self.audioAccum >= step do self.audioAccum = self.audioAccum - step - require("src.core.Music").update(Data) + HotModules.get("src.core.Music").update(Data) end close() end @@ -622,19 +623,24 @@ function Game:update(dt) -- frame dt (not the fixed logic step) for a smooth ~0.25s glide. do local close = FrameProfile.section(" update: tilt") - require("src.render.Tilt").update(dt) + HotModules.get("src.render.Tilt").update(dt) close() end -- mod render pipelines tween on the same real-frame clock, for the same -- reason: they are presentational, so fast-forward must not speed them up do local close = FrameProfile.section(" pipelines: update") - require("src.render.Pipelines").update(dt) + HotModules.get("src.render.Pipelines").update(dt) close() end do local close = FrameProfile.section(" update: discord") - pcall(function() require("src.core.DiscordPresence").update(dt) end) + if Performance.hotPathHoist then + local ok, Discord = HotModules.optional("src.core.DiscordPresence") + if ok and type(Discord) == "table" then pcall(Discord.update, dt) end + else + pcall(function() require("src.core.DiscordPresence").update(dt) end) + end close() end -- Keep an already-loaded native GC delegation at its update boundary. @@ -913,7 +919,7 @@ Game.centerClassicZones = centerClassicZones function Game:draw() local closeDraw = FrameProfile.section("draw: whole frame") - local screenLayout=require('src.ui.SecondScreen') + local screenLayout=HotModules.get('src.ui.SecondScreen') Renderer.splitViewport=nil if screenLayout.combined(self) then local w,h=love.graphics.getDimensions() @@ -931,25 +937,25 @@ function Game:draw() -- Single-screen generations use the physical Android lower panel as a live -- save companion without changing the game's own screen mode. do - local okC, Companion = pcall(require, "src.ui.SaveCompanion") + local okC, Companion = HotModules.optional("src.ui.SaveCompanion") if okC and Companion and Companion.tick then pcall(Companion.tick, self) end end do - local okSS, SS = pcall(require, "src.ui.SecondScreen") + local okSS, SS = HotModules.optional("src.ui.SecondScreen") if okSS and SS.mode and (SS.mode(self) == "display" or SS.combined(self)) and not self.secondScreenDrawnThisFrame then - local version = require("src.core.GameVersion").info() + local version = HotModules.get("src.core.GameVersion").info() local family = version and version.family -- THE WATCH ONLY ONCE IT IS ON THE WRIST: Platinum's lower screen shows -- the Pokétch after `enablepoketch` (Jubilife) and nothing before; Black -- and White's is the C-Gear, not Platinum's watch (not built yet), so -- theirs stays blank too - local poketchOn = require("src.pokemon.Gen4PoketchState").owned(self.save) + local poketchOn = HotModules.get("src.pokemon.Gen4PoketchState").owned(self.save) local panelModule = family == "hgss" and "src.ui.HgssFieldPanel" or (family ~= "bw" and poketchOn and "src.ui.Gen4Poketch") or nil local okP, Poketch = false, nil - if panelModule then okP, Poketch = pcall(require, panelModule) end + if panelModule then okP, Poketch = HotModules.optional(panelModule) end if not panelModule then pcall(SS.draw, self, function() love.graphics.setColor(0, 0, 0, 1) @@ -988,7 +994,7 @@ function Game:draw() -- has to get out, and because the GPU readback wants the drawing finished. -- A no-op in every other mode and on every machine with one screen. do - local okSS, SS = pcall(require, "src.ui.SecondScreen") + local okSS, SS = HotModules.optional("src.ui.SecondScreen") if okSS and SS.flush then pcall(SS.flush, self) end end FrameProfile.frame() @@ -1001,6 +1007,14 @@ function Game:toggleFrameProfile() return FrameProfile.toggle() end +local function viewportSnapshot(viewport) + return { + width=viewport.width,height=viewport.height,gameX=viewport.gameX,gameY=viewport.gameY, + gameWidth=viewport.gameWidth,gameHeight=viewport.gameHeight,scale=viewport.scale, + dpiX=viewport.dpiX,dpiY=viewport.dpiY, + } +end + function Game:_draw() -- the UI canvas clears transparent when the overworld's world pass -- shows through beneath it; opaque full-screen states get the classic @@ -1165,11 +1179,18 @@ function Game:_draw() if worldDrawn and self.overworld.sgbWorldZones then worldZones = self.overworld:sgbWorldZones() end - local viewport = Renderer:endFrame(zones, worldZones) + local viewport, privateViewport + if Performance.hotPathHoist and Renderer.endFrame == Renderer._nativeEndFrame then + viewport = Renderer:_endFrameForEngine(zones, worldZones) + privateViewport = true + else + viewport = Renderer:endFrame(zones, worldZones) + end -- Persistent tool status is screen-space UI: draw it over the completed -- render pipeline with exact playfield/margin geometry, but below mobile -- controls. It never becomes an updating game state. if ModRuntime.wantsHook("render.hud") then + if privateViewport then viewport = viewportSnapshot(viewport) end ModRuntime.call("render.hud", function() end, self, viewport) end -- on-screen mobile controls: pure screen-space, over the finished frame diff --git a/src/core/HotModules.lua b/src/core/HotModules.lua new file mode 100644 index 000000000..316773c7b --- /dev/null +++ b/src/core/HotModules.lua @@ -0,0 +1,34 @@ +-- Lazy dependency upvalues shared by native update/draw sites. Public require +-- paths stay intact; replacing/clearing package.loaded remains observable. +local Performance = require("src.core.Performance") +local Hot = {} +local _modules = {} + +function Hot.get(name) + if Performance.hotPathHoist then + local module = _modules[name] + if module and package.loaded[name] == module then + return module + end + module = require(name) + _modules[name] = module + return module + end + return require(name) +end + +function Hot.optional(name) + if Performance.hotPathHoist then + local module = _modules[name] + if module and package.loaded[name] == module then + return true, module + end + end + local ok, module = pcall(require, name) + if ok and Performance.hotPathHoist then + _modules[name] = module + end + return ok, module +end + +return Hot diff --git a/src/core/Performance.lua b/src/core/Performance.lua index 359ba0811..a7fcea886 100644 --- a/src/core/Performance.lua +++ b/src/core/Performance.lua @@ -45,6 +45,8 @@ local Performance = {} -- P2 is independently reversible. Tier rows can reduce the idle budget or -- tune the collector without changing the persisted presentation options. Performance.gcBudget = true +-- P3: cache hot-path dependencies and reuse engine-private presentation data. +Performance.hotPathHoist = true Performance.GC_PAUSE = 120 Performance.GC_STEP_MUL = 300 Performance.GC_BUDGET_SECONDS = 0.0005 diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 88984d94b..731d74675 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -20,6 +20,7 @@ -- (data/palettes_yellow.lua / ROM cgbBase). local GameVersion = require("src.core.GameVersion") +local HotModules = require("src.core.HotModules") local PaletteFX = {} @@ -309,7 +310,7 @@ end -- it), so the zone now covers it whatever generation is running. On a Game -- Boy dataset those are 160x144 and this is the same rect it always was. function PaletteFX.whole(colors) - local ok, Renderer = pcall(require, "src.render.Renderer") + local ok, Renderer = HotModules.optional("src.render.Renderer") local w = ok and Renderer and (Renderer.uiWidth or Renderer.WIDTH) or 160 local h = ok and Renderer and (Renderer.uiHeight or Renderer.HEIGHT) or 144 -- zone() takes INCLUSIVE tile bounds, so the last tile index is size/8 - 1; diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 7cff87285..d8aab0c30 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -12,6 +12,8 @@ local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") local PixelCanvas = require("src.render.PixelCanvas") local Runtime = require("src.mods.Runtime") +local Performance = require("src.core.Performance") +local HotModules = require("src.core.HotModules") -- RESOLVED ONCE, NOT EVERY FRAME. These three were `require`d from inside -- beginFrame and endFrame -- three string hashes into package.loaded on every @@ -291,7 +293,7 @@ function Renderer:worldViewSize() -- Gen 1-3 render native border tiles beyond the map. Capping their view -- here and enlarging it during presentation silently changes the user's -- zoom on every building/route transition and makes small-map zoom inert. - if bw and bh and require("src.core.GameVersion").isGen4() then + if bw and bh and HotModules.get("src.core.GameVersion").isGen4() then local uw, uh = self:uiSize() -- Never below the screen the generation was drawn for, so a map at least -- that big comes out exactly the size it did before. @@ -312,7 +314,7 @@ end -- keep the projected ground plane covering the receded corners; filling with -- it would undo the growth it just asked for. function Renderer:worldPresentationScale(sp, pw, ph, vw, vh) - if self.worldBoundsW and require("src.core.GameVersion").isGen4() and not Tilt.active() then + if self.worldBoundsW and HotModules.get("src.core.GameVersion").isGen4() and not Tilt.active() then return math.max(sp, pw / vw, ph / vh) end return sp @@ -416,7 +418,7 @@ function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy, source) -- required here rather than at the top: BattleTransition reaches the -- renderer through game.renderer at draw time, and a module-level require -- would put the two files in a load cycle - local BattleTransition = require("src.render.BattleTransition") + local BattleTransition = HotModules.get("src.render.BattleTransition") local order = BattleTransition.gridOrder(wipe.style, cols, rows) if order then @@ -826,7 +828,34 @@ end -- scissored through the shade-remap shader, later zones on top. -- When GBC FX is active the composite is drawn into presentCanvas and -- presented through the GBC FX shader as a final pass. -function Renderer:endFrame(zones, worldZones) +-- Native blits keep the frame's DPI in explicit arguments, including nested +-- drawing. The public blitCanvas seam retains its original signature/dispatch. +function Renderer:_blitAtDpi(dpiX, dpiY, canvas, sx, sy, zoneList, zoneSx, zoneSy, + bx, by, boxX, boxY, boxW, boxH) + return self:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, + bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY) +end + +local _viewportScratch = {} +local _uiPresentationOwners = setmetatable({}, {__mode="k"}) +local function viewportResult(private, ww, wh, ox, oy, vpw, vph, scale, dpiX, dpiY) + local out = private and _viewportScratch or {} + out.width, out.height, out.gameX, out.gameY = ww, wh, ox, oy + out.gameWidth, out.gameHeight, out.scale = vpw, vph, scale + out.dpiX, out.dpiY = dpiX, dpiY + return out +end +local function uiPresentation(self, x, y, w, h, sx, sy) + local out = _uiPresentationOwners[self] + if not Performance.hotPathHoist or not out or self.uiPresentation ~= out then + out = {} + _uiPresentationOwners[self] = out + end + out.x, out.y, out.w, out.h, out.scaleX, out.scaleY = x, y, w, h, sx, sy + self.uiPresentation = out +end + +local function endFrame(self, zones, worldZones, privateViewport) love.graphics.setCanvas() local ww, wh, pw, ph, dpiX, dpiY = displayMetrics() -- Sp = integer framebuffer pixels per GB pixel; @@ -855,7 +884,7 @@ function Renderer:endFrame(zones, worldZones) local uvpw, uvph = uiw * Ux, uih * Uy local uox = math.floor((pw - uiw * Up) / 2) / dpiX local uoy = math.floor((ph - uih * Up) / 2) / dpiY - self.uiPresentation = {x=uox,y=uoy,w=uvpw,h=uvph,scaleX=Ux,scaleY=Uy} + uiPresentation(self, uox, uoy, uvpw, uvph, Ux, Uy) local GBCFX = gbcFX() -- Forced mono/Classic modes still need a whole-screen zone when a state -- exposes no SGB packets (raw DMG canvas), so sendColors can remap. @@ -895,14 +924,14 @@ function Renderer:endFrame(zones, worldZones) -- took its Gen 1 branch, with Gen 1's tile geometry, over Gen 3 -- data. GameVersion.generation() is the same answer for Gen 1 and -- Gen 2 and the right one for Gen 3. - generation = require("src.core.GameVersion").generation(), + generation = HotModules.get("src.core.GameVersion").generation(), worldOverride = self.worldOverride, worldActive = self.worldActive and true or false, zones = zones, worldZones = worldZones, ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy, vpw = vpw, vph = vph, uiw = uiw, uih = uih, scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY, - secondScreen = require("src.render.SecondScreen"), + secondScreen = HotModules.get("src.render.SecondScreen"), } if Runtime.call("render.compose", function() return false end, self, ctx) == true then self.worldActive = false @@ -910,10 +939,7 @@ function Renderer:endFrame(zones, worldZones) self.uiOverride = nil self.worldOverride = nil PaletteFX.setPass(nil) - return { - width = ww, height = wh, gameX = ox, gameY = oy, - gameWidth = vpw, gameHeight = vph, scale = Sp, dpiX = dpiX, dpiY = dpiY, - } + return viewportResult(privateViewport, ww, wh, ox, oy, vpw, vph, Sp, dpiX, dpiY) end end @@ -972,7 +998,7 @@ function Renderer:endFrame(zones, worldZones) -- a visibly brighter border. local clearR, clearG, clearB = 0, 0, 0 if not self.worldActive then - local ok, Game = pcall(require, "src.core.Game") + local ok, Game = HotModules.optional("src.core.Game") local stack = ok and Game and Game.stack local base = stack and stack.visibleBase and stack:visibleBase() local state = base and stack.states and stack.states[base] @@ -1015,10 +1041,13 @@ function Renderer:endFrame(zones, worldZones) -- see Renderer:blitCanvas; bound here to the frame's dpi so the composite -- call sites below stay unchanged. - local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy, - bx, by, boxX, boxY, boxW, boxH) - return self:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, - bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY) + local blit = self._blitAtDpi + if not Performance.hotPathHoist then + blit = function(_self, _dx, _dy, canvas, sx, sy, zoneList, zoneSx, zoneSy, + bx, by, boxX, boxY, boxW, boxH) + return self:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, + bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY) + end end -- A PUBLISHER MAY HAVE FREED ITS CANVAS SINCE IT HANDED IT OVER. @@ -1078,7 +1107,7 @@ function Renderer:endFrame(zones, worldZones) -- iOS flip a second time turns Platinum upside down while its UI remains -- upright. local loveMajor = love.getVersion() - local gen = require("src.core.GameVersion").generation() + local gen = HotModules.get("src.core.GameVersion").generation() if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 @@ -1114,9 +1143,9 @@ function Renderer:endFrame(zones, worldZones) Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present) if not projected then if worldZones then - blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, 0, 0, ww, wh) + blit(self, dpiX, dpiY, self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, 0, 0, ww, wh) else - blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, 0, 0, ww, wh) + blit(self, dpiX, dpiY, self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, 0, 0, ww, wh) end -- OBP-baked overworld sprites replay on top of the zone pass (GBC -- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass @@ -1233,7 +1262,7 @@ function Renderer:endFrame(zones, worldZones) -- it wants the whole window rather than a fixed box in the middle of one. -- The bag, the party menu, the mart, the Pokenav, the title and the battle -- all say so; the overworld does not, so the map is still never bled. - local okGame, GameMod = pcall(require, "src.core.Game") + local okGame, GameMod = HotModules.optional("src.core.Game") local fills = okGame and GameMod and GameMod.fillScaleInStack(GameMod.stack) or false -- ...AND NOT A BATTLE. @@ -1274,12 +1303,12 @@ function Renderer:endFrame(zones, worldZones) and not GameMod.edgeBleedAllowedInStack(GameMod.stack) then fills = false end - if fills and require("src.core.GameVersion").isGen3() then + if fills and HotModules.get("src.core.GameVersion").isGen3() then self:bleedEdges(self.canvas, Ux, Uy, uox, uoy, ww, wh) end local uiSource=self.uiOverride or self.canvas local uiFactor=uiSource:getWidth()/uiw - blit(uiSource, Ux/uiFactor, Uy/uiFactor, zones, Ux, Uy, uox, uoy, uox, uoy, uvpw, uvph) + blit(self, dpiX, dpiY, uiSource, Ux/uiFactor, Uy/uiFactor, zones, Ux, Uy, uox, uoy, uox, uoy, uvpw, uvph) else local rest = { { uox, uoy, uvpw, uvph } } local placed = {} @@ -1301,7 +1330,7 @@ function Renderer:endFrame(zones, worldZones) -- desktop it IS the window: love.window.getSafeArea is absent or returns -- the full surface, so rect() answers 0, 0, ww, wh and every number below -- is what it has always been. Which is exactly why only mobile saw this. - local sax, say, saw, sah = require("src.core.SafeArea").rect() + local sax, say, saw, sah = HotModules.get("src.core.SafeArea").rect() if self.splitViewport then saw=math.max(0,math.min(saw,ww-sax));sah=math.max(0,math.min(sah,wh-say)) end @@ -1329,13 +1358,13 @@ function Renderer:endFrame(zones, worldZones) rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh) end for _, r in ipairs(rest) do - blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4]) + blit(self, dpiX, dpiY, self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4]) end for _, p in ipairs(placed) do -- shift the draw origin so canvas pixel (a.x, a.y) lands on (dx, dy). -- The zone scissors are computed from the same origin, so an SGB -- region travels with the element instead of staying in the letterbox. - blit(self.canvas, Ux, Uy, zones, Ux, Uy, + blit(self, dpiX, dpiY, self.canvas, Ux, Uy, zones, Ux, Uy, p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh) end end @@ -1345,7 +1374,7 @@ function Renderer:endFrame(zones, worldZones) -- origin and scale it used, so a queued rect covers exactly the pixels it -- replaces -- and UNDER the wipe and veil below, which are screen-wide -- effects that on hardware nothing escapes. - require("src.render.NativeOverlay").flush(uox, uoy, Ux, Uy) + HotModules.get("src.render.NativeOverlay").flush(uox, uoy, Ux, Uy) -- The battle wipe covers the whole surface, letterbox included, so it goes -- over the finished composite rather than under the UI blit. On hardware @@ -1416,13 +1445,18 @@ function Renderer:endFrame(zones, worldZones) self.uiOverride = nil self.worldOverride = nil PaletteFX.setPass(nil) - return { - width = ww, height = wh, - gameX = ox, gameY = oy, - gameWidth = vpw, gameHeight = vph, - scale = Sp, - dpiX = dpiX, dpiY = dpiY, - } + return viewportResult(privateViewport, ww, wh, ox, oy, vpw, vph, Sp, dpiX, dpiY) +end + +-- Public callers (including wrappers installed by mods) always receive a new +-- record. Only the unwrapped native path may borrow the private result. +function Renderer:endFrame(zones, worldZones) + return endFrame(self, zones, worldZones, false) end +Renderer._nativeEndFrame = Renderer.endFrame +function Renderer:_endFrameForEngine(zones, worldZones) + return endFrame(self, zones, worldZones, Performance.hotPathHoist) +end + return Renderer diff --git a/tests/engine/hot_modules.lua b/tests/engine/hot_modules.lua new file mode 100644 index 000000000..a234303cf --- /dev/null +++ b/tests/engine/hot_modules.lua @@ -0,0 +1,52 @@ +local T = require("tests.harness") +local Performance = require("src.core.Performance") +local Hot = require("src.core.HotModules") +local name = "tests.hot_module_probe" +local originalRequire = require +local calls, loads = 0, 0 +package.preload[name] = function() + loads = loads + 1 + return { generation = loads } +end +_G.require = function(path, ...) + calls = calls + 1 + return originalRequire(path, ...) +end +Performance.hotPathHoist = true +local first = Hot.get(name) +T.eq(loads, 1, "cold dependency is loaded once") +T.eq(Hot.get(name), first, "warm module retains identity") +T.eq(calls, 1, "warm path avoids require dispatch") +first.liveField = "changed" +T.eq(Hot.get(name).liveField, "changed", "module field mutations remain live") +local replacement = { generation = 20 } +package.loaded[name] = replacement +T.eq(Hot.get(name), replacement, "explicit package.loaded replacement is observed") +package.loaded[name] = nil +T.eq(Hot.get(name).generation, 2, "clearing package.loaded reloads the dependency") +Performance.hotPathHoist = false +local before = calls +Hot.get(name) +Hot.get(name) +T.eq(calls - before, 2, "P3 disabled restores normal require dispatch") +Performance.hotPathHoist = true +package.loaded[name] = nil +package.preload[name] = function() + error("deliberate optional load failure") +end +local ok = Hot.optional(name) +T.eq(ok, false, "optional dependency errors remain contained") +package.loaded[name] = nil +package.preload[name] = function() + return replacement +end +local value +ok, value = Hot.optional(name) +T.check(ok and value == replacement, "failed loads are not memoized as successful modules") +before = calls +ok, value = Hot.optional(name) +T.check(ok and value == replacement, "optional warm module retains its result") +T.eq(calls, before, "optional warm path avoids pcall/require dispatch") +_G.require = originalRequire +package.loaded[name], package.preload[name] = nil, nil +T.finish() diff --git a/tests/engine/hot_renderer_records.lua b/tests/engine/hot_renderer_records.lua new file mode 100644 index 000000000..4d4fd3eff --- /dev/null +++ b/tests/engine/hot_renderer_records.lua @@ -0,0 +1,121 @@ +local T = require("tests.harness") +love = require("tests.love_stub") +local Performance = require("src.core.Performance") +local Version = require("src.core.GameVersion") +Version.set("red") +local Renderer = require("src.render.Renderer") +local Game = require("src.core.Game") +local Runtime = require("src.mods.Runtime") +local Hooks = require("src.mods.Hooks") +local Stack = require("src.core.StateStack") +local w, h = 640, 480 +love.graphics.getDimensions = function() + return w, h +end +love.graphics.getPixelDimensions = function() + return w, h +end +Renderer:init() +local function present(private) + Renderer:beginFrame() + if private then + return Renderer:_endFrameForEngine() + end + return Renderer:endFrame() +end +for _, enabled in ipairs({ false, true }) do + Performance.hotPathHoist = enabled + w, h = 640, 480 + local first = present() + local oldUI = Renderer.uiPresentation + w, h = 800, 600 + local second = present() + T.check(first ~= second, "public endFrame returns detached records with P3 " .. tostring(enabled)) + T.eq(first.width, 640, "retained public viewport keeps its original width") + T.eq(second.width, 800, "next public viewport has current width") + T.eq(oldUI == Renderer.uiPresentation, enabled, "existing renderer-owned UI view follows the switch") +end +Performance.hotPathHoist = true +local first = present(true) +T.eq(present(true), first, "unobserved native result can reuse private storage") +local foreign = { custom = true } +Renderer.uiPresentation = foreign +present() +T.check(Renderer.uiPresentation ~= foreign, "engine does not reuse a replacement owned by a mod") +T.eq(foreign.x, nil, "replacement supplied by a mod is not mutated") +local realBlit = Renderer.blitCanvas +local forwarded +Renderer.blitCanvas = function(self, ...) + forwarded = { self, ... } + return "forwarded" +end +local canvas, zones = {}, {} +T.eq( + Renderer:_blitAtDpi(1.5, 2, canvas, 3, 4, zones, 5, 6, 7, 8, 9, 10, 11, 12), + "forwarded", + "hoisted blit preserves the public return" +) +T.eq(forwarded[1], Renderer, "hoisted blit preserves the public receiver") +T.eq(forwarded[2], canvas, "hoisted blit preserves canvas identity") +T.eq(forwarded[5], zones, "hoisted blit preserves zone identity") +T.eq(forwarded[14], 1.5, "hoisted blit keeps horizontal DPI in the original position") +T.eq(forwarded[15], 2, "hoisted blit keeps vertical DPI in the original position") +Renderer.blitCanvas = realBlit + +local hooks = Hooks.new() +Runtime.hooks = hooks +local state = { draw = function() end } +local stack = setmetatable({ states = { state } }, { __index = Stack }) +local fake = setmetatable( + { stack = stack, overworld = {}, data = require("tests.modkit.fixtures").fresh(), save = { options = {} } }, + { __index = Game } +) +local received = {} +hooks:wrap("render.hud", function(nextFn, game, viewport) + T.eq(game, fake, "HUD receives the original game") + received[#received + 1] = viewport + return nextFn(game, viewport) +end, 0, "retention") +w, h = 640, 480 +fake:_draw() +w, h = 800, 600 +fake:_draw() +T.check(received[1] ~= received[2], "HUD never receives recycled private results") +T.eq(received[1].width, 640, "retained HUD viewport remains unchanged") +T.eq(received[2].width, 800, "next HUD viewport receives current geometry") +local original = Renderer.endFrame +local wrapped, offered = 0, nil +Renderer.endFrame = function(self, ...) + wrapped = wrapped + 1 + offered = original(self, ...) + offered.modField = "kept" + return offered +end +fake:_draw() +T.eq(wrapped, 1, "existing public endFrame wrapper is not bypassed") +T.eq(received[#received], offered, "wrapper record is forwarded without replacing its identity") +T.eq(received[#received].modField, "kept", "wrapper-added fields remain available") +Renderer.endFrame = original +hooks:removeOwner("retention") +received = {} +local installed = false +hooks:wrap("render.compose", function(nextFn, ...) + if not installed then + installed = true + hooks:wrap("render.hud", function(nextHud, game, viewport) + received[#received + 1] = viewport + return nextHud(game, viewport) + end, 0, "late-hud") + end + return nextFn(...) +end, 0, "late-compose") +w, h = 640, 480 +fake:_draw() +w, h = 800, 600 +fake:_draw() +T.check(received[1] ~= received[2], "a HUD registered during compose also receives detached records") +T.eq(received[1].width, 640, "late registration does not leak mutable native metrics") +hooks:removeOwner("late-hud") +hooks:removeOwner("late-compose") +-- Window UI is absent from this engine base; the fork exercises that path. +T.finish() diff --git a/tools/check_frame_pacing.py b/tools/check_frame_pacing.py index 160706770..2f8a694a6 100644 --- a/tools/check_frame_pacing.py +++ b/tools/check_frame_pacing.py @@ -14,7 +14,9 @@ def main(): parser.add_argument('--love', type=Path, required=True) parser.add_argument('--report', type=Path, required=True) parser.add_argument('--qa-tools', type=Path) - parser.add_argument('--gc', action='store_true', help='compare fixed-step GC with the P2 idle budget') + workload = parser.add_mutually_exclusive_group() + workload.add_argument('--gc', action='store_true', help='compare fixed-step GC with the P2 idle budget') + workload.add_argument('--hot-path', action='store_true', help='compare actual native update/draw dependency dispatch') args = parser.parse_args() root = Path(__file__).resolve().parents[1] sys.path.insert(0, str(args.qa_tools or root / 'tools')) @@ -29,6 +31,9 @@ def main(): if args.gc: files += ('src/core/Performance.lua', 'src/core/GCFrameBudget.lua', 'src/core/Game.lua', 'tools/frame-pacing-check/gc.lua') + if args.hot_path: + files += ('src/core/Game.lua', 'src/core/HotModules.lua', 'src/core/Performance.lua', + 'src/render/Renderer.lua', 'src/render/PaletteFX.lua', 'tools/frame-pacing-check/hot.lua') hashes = {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in files} source = qa_run(['git', 'rev-parse', 'HEAD'], cwd=root, text=True, capture_output=True, check=True).stdout.strip() @@ -39,10 +44,11 @@ def main(): result = qa_run([str(args.love.resolve()), str(root / 'tools/frame-pacing-check')], cwd=root, env=dict(os.environ, POKEPORT_IDENTITY=identity, FRAME_PACING_REPO=str(root), FRAME_PACING_QA_GUARDED='1', - FRAME_PACING_GC='1' if args.gc else '0'), + FRAME_PACING_GC='1' if args.gc else '0', + FRAME_PACING_HOT='1' if args.hot_path else '0'), capture_output=True, text=True, timeout=90) args.report.with_suffix('.log').write_text(result.stdout + result.stderr) - prefix = 'P2_NATIVE_RESULT ' if args.gc else 'P1_NATIVE_RESULT ' + prefix = 'P3_NATIVE_RESULT ' if args.hot_path else 'P2_NATIVE_RESULT ' if args.gc else 'P1_NATIVE_RESULT ' records = [line.removeprefix(prefix) for line in result.stdout.splitlines() if line.startswith(prefix)] if result.returncode or len(records) != 1: raise RuntimeError('native pacing probe failed; see ' + str(args.report.with_suffix('.log'))) diff --git a/tools/frame-pacing-check/hot.lua b/tools/frame-pacing-check/hot.lua new file mode 100644 index 000000000..8c8fdec9a --- /dev/null +++ b/tools/frame-pacing-check/hot.lua @@ -0,0 +1,114 @@ +local Version = require("src.core.GameVersion") +Version.set("red") +local Game = require("src.core.Game") +local Renderer = require("src.render.Renderer") +local Stack = require("src.core.StateStack") +local Performance = require("src.core.Performance") +local Cap = require("src.core.FrameCap") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local graphics = love.graphics +Game.data = require("tests.modkit.fixtures").fresh() +Game.save = { version = "red", options = {}, player = {}, modData = {} } +Game.overworld = {} +Game.stack = setmetatable( + { + states = { + { + draw = function() + graphics.setColor(0.2, 0.4, 0.6, 1) + graphics.rectangle("fill", 16, 16, 96, 80) + graphics.setColor(1, 1, 1, 1) + end, + }, + }, + }, + { __index = Stack } +) +Renderer:init() +local phase, frame, deadline, started, cpuStarted = 1, 0, 0, 0, 0 +local WINDOW, WARMUP = 120, 120 +local drawTotal, drawMax, requireCalls, requires = 0, 0, 0, {} +local results = {} +local _, _, mode = love.window.getMode() +Cap.current = tonumber(mode.refreshrate) or 60 +if Cap.current <= 0 then + Cap.current = 60 +end +Cap.vsyncPacing = true + +local function countRequires() + local original = require + requireCalls, requires = 0, {} + _G.require = function(name, ...) + requireCalls = requireCalls + 1 + requires[name] = (requires[name] or 0) + 1 + return original(name, ...) + end + local ok, err = pcall(Game.draw, Game) + _G.require = original + assert(ok, err) +end +local function startPhase() + Profile.setEnabled(false) + Performance.hotPathHoist = phase == 2 + frame, drawTotal, drawMax = 0, 0, 0 + deadline = love.timer.getTime() +end +local function finishPhase() + results[phase] = { + enabled = Performance.hotPathHoist, + frames = WINDOW, + seconds = love.timer.getTime() - started, + drawMeanMs = drawTotal / WINDOW * 1000, + drawMaxMs = drawMax * 1000, + processCpuMsPerFrame = (os.clock() - cpuStarted) / WINDOW * 1000, + requireCalls = requireCalls, + requires = requires, + } + if phase == 2 then + print("P3_NATIVE_RESULT " .. Json.encode({ + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + renderer = graphics.getRendererInfo(), + refresh = mode.refreshrate, + vsync = love.window.getVSync(), + cap = Cap.current, + phases = results, + scope = "Native Game:draw and real canvas composition over owned synthetic content, no world/battle/mod scene", + })) + return true + end + phase = 2 + startPhase() +end +function love.run() + startPhase() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + countRequires() + Profile.setEnabled(true) + started, cpuStarted = love.timer.getTime(), os.clock() + end + local start = love.timer.getTime() + Game:draw() + if frame >= WARMUP then + local elapsed = love.timer.getTime() - start + drawTotal, drawMax = drawTotal + elapsed, math.max(drawMax, elapsed) + end + local close = Profile.section("present (GPU wait / vsync)") + graphics.present() + close() + deadline = Cap.pace(love.timer, love.window, true, deadline) + frame = frame + 1 + if frame == WARMUP + WINDOW and finishPhase() then + return 0 + end + end +end diff --git a/tools/frame-pacing-check/main.lua b/tools/frame-pacing-check/main.lua index 5bb9948d3..282369558 100644 --- a/tools/frame-pacing-check/main.lua +++ b/tools/frame-pacing-check/main.lua @@ -1,9 +1,17 @@ local root = assert(os.getenv("FRAME_PACING_REPO")) package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +function love.errorhandler(message) + print(debug.traceback(tostring(message),2)) + return function()return 1 end +end if os.getenv("FRAME_PACING_GC") == "1" then require("tools.frame-pacing-check.gc") return end +if os.getenv("FRAME_PACING_HOT") == "1" then + require("tools.frame-pacing-check.hot") + return +end local Cap = require("src.core.FrameCap") local Profile = require("src.core.FrameProfile") local Json = require("src.link.Json") From 56a69480fe17068b4d625a024519d2c9d8419ae4 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:34:03 +0100 Subject: [PATCH 072/138] Cover current native draw imports and refresh upstream qualification --- docs/hot-path-dispatch.md | 4 ++-- src/core/Game.lua | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/hot-path-dispatch.md b/docs/hot-path-dispatch.md index 2905c39dd..d1632f35b 100644 --- a/docs/hot-path-dispatch.md +++ b/docs/hot-path-dispatch.md @@ -37,8 +37,8 @@ Measured on macOS arm64 (milliseconds/frame, switch off → on): | --- | ---: | ---: | ---: | ---: | | Fork / LÖVE 11.5 OpenGL | 0.16 → 0.21 | 0.166 → 0.218 | 0.787 → 0.919 | 15 → 6 | | Fork / LÖVE 12 Metal | 16.36 → 16.38 | 16.373 → 16.383 | 1.134 → 1.026 | 15 → 6 | -| Public adaptation / LÖVE 11.5 OpenGL | 0.30 → 0.22 | 0.315 → 0.222 | 1.227 → 0.992 | 12 → 5 | -| Public adaptation / LÖVE 12 Metal | 16.43 → 16.40 | 16.440 → 16.410 | 0.849 → 1.003 | 12 → 5 | +| Public adaptation on ed5189c2 / LÖVE 11.5 OpenGL | 0.21 → 0.21 | 0.223 → 0.215 | 0.952 → 0.936 | 14 → 5 | +| Public adaptation on ed5189c2 / LÖVE 12 Metal | 16.44 → 16.44 | 16.455 → 16.449 | 0.844 → 0.831 | 14 → 5 | Queue submission can absorb GPU/VSync waiting in Metal; these are wall-time sections, not isolated GPU durations. The short runs show reduced dependency diff --git a/src/core/Game.lua b/src/core/Game.lua index 769362451..33cfccb16 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1127,8 +1127,8 @@ function Game:_draw() end end local baseState = self.stack.states[drawFrom] - local SuperSample = require("src.render.SuperSample") - if not worldDrawn and baseState and require("src.core.GameVersion").isNds() + local SuperSample = HotModules.get("src.render.SuperSample") + if not worldDrawn and baseState and HotModules.get("src.core.GameVersion").isNds() and SuperSample.singleScreen(self) and not SuperSample.active then SuperSample.draw(self, self.stack:top(), drawStates) else From af26258273695acf80b5ca64017d345954638d0c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:39:00 +0100 Subject: [PATCH 073/138] Share ordered native phone slots and permanent reservations with Pokegear --- docs/native-gen2-phone-book.md | 35 +++++++ src/script/Gen2Commands.lua | 37 +++----- src/ui/PokegearMenu.lua | 44 ++++++--- src/world/Gen2PhoneBook.lua | 116 +++++++++++++++++++++++ tests/engine/gen2_phone_book.lua | 140 ++++++++++++++++++++++++++++ tests/engine/gen2_phone_content.lua | 4 +- 6 files changed, 337 insertions(+), 39 deletions(-) create mode 100644 docs/native-gen2-phone-book.md create mode 100644 src/world/Gen2PhoneBook.lua create mode 100644 tests/engine/gen2_phone_book.lua diff --git a/docs/native-gen2-phone-book.md b/docs/native-gen2-phone-book.md new file mode 100644 index 000000000..1a34bacaa --- /dev/null +++ b/docs/native-gen2-phone-book.md @@ -0,0 +1,35 @@ +# Native ordered phone book + +The native script verbs and Pokegear now share ten ordered phone slots. +The existing `save.g2Phone` registration set stays available; the additive +`g2PhoneSlots` array retains insertion order and script-deletion holes. +Older sets receive deterministic initial order. External set edits are +adopted without resurrecting deletions or renumbering contact IDs. Reads do +not discard overflow entries from historical oversized sets. + +Missing permanent numbers reserve slots, so eight ordinary contacts cannot +lock out Mom and Elm. Their imported non-trainer identities are authoritative; +explicit metadata and relocated rows take precedence over legacy retail +positions. The number being added never reserves against itself. Duplicate, +zero, unknown imported and full-book additions are refused. Native +checkcellnum retains the cartridge's empty-byte membership behavior. + +Script deletion leaves a hole. Pokegear DELETE compacts the actual slots, +while the incoming selector walks registration order. The native card shows +ten slots, uses explicit blank rows instead of invented Mom/Elm entries, +refuses calls on blanks and scrolls slots nine and ten into view. + +Validation:277 focused synthetic checks use actual Gen2ScriptVM/ScriptRunner +add/check/delete/ask opcodes, Pokegear input and drawing with only prompt, +audio and graphics IO substituted. Gold/Silver/Crystal exercise reservations, +ordering, external edits, slot deletion, empty rows, native ask result codes, +relocated permanent identities and visible slot-ten dispatch. This establishes +model/VM/UI behavior, not a hardware phone call or original story acceptance. +The existing content-bridge tests also check ten-slot native consumption. + +The public legacy Phone facade, phone.contact_list display hook, full special +call/rematch contracts, new authored phone scripts/forward references, and +fresh-import/GPU/story acceptance remain open. Full Gen1 parity is unproven. + +Original implementation by Roxas2712; existing upstream/third-party rights and +credits remain applicable. See NOTICE.md and LICENSE-ROXAS2712.md. diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..8a1f08387 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2128,8 +2128,8 @@ function Gen2Commands.availableCallers(data, save, mapDef, tod) local out = {} local group = mapDef and tonumber(mapDef.group) local number = mapDef and tonumber(mapDef.number) - for id, registered in pairs(phoneList(save)) do - if registered and type(id) == "number" then + for _, id in ipairs(require("src.world.Gen2PhoneBook").contacts(save)) do + if id ~= 0 then local entry = phoneContact(data, id) local mask = entry and tonumber(entry.receiveTime) or 0 -- `and [hl]` on the mask, then the map compare @@ -2139,7 +2139,6 @@ function Gen2Commands.availableCallers(data, save, mapDef, tod) end end end - table.sort(out) return out end @@ -2720,39 +2719,25 @@ end -- Phone_FindOpenSlot (36:$402D) fails when none of them is free. That cap is -- the whole point of the ".PhoneFull" branch every phone trainer carries -- an -- uncapped list can never reach it. -local GEN2_PHONE_SLOTS = 10 - -local function phoneListCount(save) - local n = 0 - for _, registered in pairs(phoneList(save)) do - if registered then n = n + 1 end - end - return n -end - -- AddPhoneNumber (36:$4000): carry means "not added" -- either the id is --- already in the list or there is no free slot. -local function addPhoneNumber(save, id) - local list = phoneList(save) - if list[id] then return false end - if phoneListCount(save) >= GEN2_PHONE_SLOTS then return false end - list[id] = true - return true +-- already in the list or there is no unreserved slot. Keep the same ordered +-- native book for the script verbs and Pokegear; the set remains compatible. +local function addPhoneNumber(ctx, id) + return require("src.world.Gen2PhoneBook").add( + ctx.data or (ctx.game and ctx.game.data), ctx.save, id) end function Commands.g2_cellnum(ctx, id, add) if type(id) ~= "number" then return end if add then - ctx.lastCheck = addPhoneNumber(ctx.save, id) + ctx.lastCheck = addPhoneNumber(ctx, id) else - local list = phoneList(ctx.save) - ctx.lastCheck = list[id] == true - list[id] = nil + ctx.lastCheck = require("src.world.Gen2PhoneBook").remove(ctx.save, id) end end function Commands.g2_check_cellnum(ctx, id) - ctx.lastCheck = phoneList(ctx.save)[id] == true + ctx.lastCheck = require("src.world.Gen2PhoneBook").has(ctx.save, id) ctx.g2Var = ctx.lastCheck and 1 or 0 end @@ -2783,7 +2768,7 @@ function Commands.g2_ask_cellnum(ctx, id, textId) ctx.g2Var = 2 return end - if addPhoneNumber(ctx.save, id) then + if addPhoneNumber(ctx, id) then ctx.g2Var = 0 else ctx.g2Var = 1 diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index f0da924a2..490c87acb 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -389,15 +389,30 @@ end -- `db PHONE_MOM, PHONE_ELM, -1`): those two can never be deleted. local GEN2_PERMANENT_NUMBERS = { [1] = true, [4] = true } -function PokegearMenu.canDeleteContact(entry) +function PokegearMenu.canDeleteContact(entry, data) local id = tonumber(entry and entry.id) - return id ~= nil and not GEN2_PERMANENT_NUMBERS[id] + if not id or id < 1 then return false end + if entry.permanent ~= nil then return not entry.permanent end + if data then return require("src.world.Gen2PhoneBook").canDelete(data, id) end + return not GEN2_PERMANENT_NUMBERS[id] end function PokegearMenu:contacts() local data = self.game.data local save = self.game.save + if require("src.core.GameVersion").isGen2() then + local Book = require("src.world.Gen2PhoneBook") + local Calls = require("src.script.Gen2Commands") + local permanent, out = Book.permanent(data), {} + for slot, id in ipairs(Book.contacts(save)) do + local name = Calls.phoneName(data,id) + out[slot] = { id=name and id or 0, slot=slot, name=name or "----------", + permanent=permanent[id] == true } + end + return out + end + local list = {} local registered = save and save.g2Phone if type(registered) == "table" then @@ -528,7 +543,7 @@ function PokegearMenu:update(dt) -- stay filled -- and the ROM's DELETE refuses only for the two -- PermanentNumbers, MOM and PROF.ELM. local entry = list[self.contact] - local canDelete = entry and PokegearMenu.canDeleteContact(entry) + local canDelete = entry and PokegearMenu.canDeleteContact(entry, self.game.data) if canDelete and (input:wasPressed("down") or input:wasPressed("up")) then self.phoneAction = self.phoneAction == "delete" and "call" or "delete" Sound.play(self.game.data, "Tink") @@ -539,9 +554,13 @@ function PokegearMenu:update(dt) self.phoneAction = nil self.callText = nil if entry and deleting then - require("src.script.Gen2Commands").phoneList(self.game.save)[entry.id] = nil + if require("src.core.GameVersion").isGen2() then + require("src.world.Gen2PhoneBook").deleteAt(self.game.save, entry.slot) + else + require("src.script.Gen2Commands").phoneList(self.game.save)[entry.id] = nil + end if self.contact > 1 then self.contact = self.contact - 1 end - elseif entry then + elseif entry and entry.id ~= 0 then self:startPhoneCall(entry) end elseif input:wasPressed("b") then @@ -558,7 +577,8 @@ function PokegearMenu:update(dt) self.contact = self.contact > 1 and self.contact - 1 or #list Sound.play(self.game.data, "Tink") elseif input:wasPressed("a") then - if #list > 0 then + local entry = list[self.contact] + if entry and entry.id ~= 0 then self.phoneSubmenu = true self.phoneAction = "call" Sound.play(self.game.data, "Press_AB") @@ -767,22 +787,22 @@ function PokegearMenu:drawPhone() self:drawStrip() local list = self:contacts() - if type(list) ~= "table" or #list == 0 then + if (type(list) ~= "table" or #list == 0) and not require("src.core.GameVersion").isGen2() then list = { { name = "MOM" }, { name = "PROF.ELM" } } end if self.contact < 1 then self.contact = 1 end if self.contact > #list then self.contact = #list end local selectedName = nil - -- One row per contact, starting at tile row 4 (inside the cream plate) + -- Keep the selected native slot visible, including slots nine and ten. + local top = math.max(0, self.contact - 8) for i, entry in ipairs(list) do local name = tostring( (type(entry) == "table" and (entry.name or entry.label)) or entry or "?" ):upper() - local ty = 3 + i -- MOM at row 4, next at 5, ... + local ty = 3 + i - top if ty > 11 then break end - - if i == self.contact then + if ty >= 4 and i == self.contact then selectedName = name -- THE CURSOR IS A GLYPH, NOT THE LETTER ">". -- @@ -806,7 +826,7 @@ function PokegearMenu:drawPhone() Font.draw(Strings(name), 18, ty * 8) if Font.endTint then Font.endTint() end love.graphics.setColor(1, 1, 1, 1) - else + elseif ty >= 4 then self:text(name, 3, ty) end end diff --git a/src/world/Gen2PhoneBook.lua b/src/world/Gen2PhoneBook.lua new file mode 100644 index 000000000..7f7949d65 --- /dev/null +++ b/src/world/Gen2PhoneBook.lua @@ -0,0 +1,116 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native ten-byte order beside the existing numeric registration set. Reads +-- retain legacy overflow entries; no save or contact ID is renumbered. +local M = { SIZE=10 } +local function valid(id) + return type(id)=='number' and id%1==0 and id>=1 and id<=255 +end +local function registered(save) + if type(save.g2Phone)~='table' then save.g2Phone={} end + return save.g2Phone +end +function M.slots(save) + local set=registered(save) + local slots=type(save.g2PhoneSlots)=='table' and save.g2PhoneSlots or {} + save.g2PhoneSlots=slots + local seen={} + for i=1,M.SIZE do + local id=slots[i] + if valid(id) and set[id] and not seen[id] then seen[id]=true + else slots[i]=0 end + end + local pending={} + for id,on in pairs(set) do + if on and valid(id) and not seen[id] then pending[#pending+1]=id end + end + table.sort(pending) + local cursor=1 + for i=1,M.SIZE do + if slots[i]==0 and pending[cursor] then slots[i]=pending[cursor];cursor=cursor+1 end + end + return slots +end +function M.contacts(save) + local slots,out=M.slots(save),{} + for i=1,M.SIZE do out[i]=slots[i] end + return out +end +function M.has(save,id) + if id==0 then + for _,value in ipairs(M.slots(save)) do if value==0 then return true end end + return false + end + return valid(id) and registered(save)[id] and true or false +end +function M.permanent(data) + local out={} + local pool=data and data.map_scripts and data.map_scripts.phone + local constants=data and data.constants or {} + local order=constants.phoneContactOrder or {} + local version=require('src.core.GameVersion').get() + local retail=version=='gold' or version=='silver' or version=='crystal' + for id,row in pairs(pool or {}) do + if valid(id) and type(row)=='table' and not (row.class and row.class~=0) then + local number=row.contactNumber + if number==1 or number==4 or number==nil and + (order[id+1]=='PHONE_MOM' or order[id+1]=='PHONE_ELM' + or retail and constants.phoneContactOrder==nil and (id==1 or id==4)) then out[id]=true end + end + end + -- Before import, the retail constants still reserve their two slots. A + -- custom cartridge receives no guessed retail identities. + if pool==nil and retail and constants.phoneContactOrder==nil then out[1],out[4]=true,true end + return out +end +function M.remainingSlots(data,save,id) + local missing=0 + for permanent in pairs(M.permanent(data)) do + if permanent~=id and not M.has(save,permanent) then missing=missing+1 end + end + return math.max(0,M.SIZE-missing) +end +function M.openSlot(data,save,id) + local slots=M.slots(save) + for i=1,M.remainingSlots(data,save,id) do if slots[i]==0 then return i end end +end +function M.add(data,save,id) + if not valid(id) then return false,'unknown' end + local pool=data and data.map_scripts and data.map_scripts.phone + if pool and type(pool[id])~='table' then return false,'unknown' end + if M.has(save,id) then return false,'already' end + local slot=M.openSlot(data,save,id) + if not slot then return false,'full' end + registered(save)[id]=true;save.g2PhoneSlots[slot]=id + return true +end +function M.remove(save,id) + local slots=M.slots(save) + for i=1,M.SIZE do + if slots[i]==id then + slots[i]=0 + if id~=0 then registered(save)[id]=nil end + return true + end + end + -- An old oversized flat set remains recoverable, including deletion of an + -- entry that could not fit into the ten hardware slots yet. + if valid(id) and registered(save)[id] then registered(save)[id]=nil;return true end + return false +end +function M.deleteAt(save,slot) + if type(slot)~='number' or slot%1~=0 or slot<1 or slot>M.SIZE then return false end + local slots=M.slots(save);local id=slots[slot] + if id==0 then return false end + registered(save)[id]=nil;slots[slot]=0 + local write=1 + for read=1,M.SIZE do + if slots[read]~=0 then slots[write]=slots[read];write=write+1 end + end + for i=write,M.SIZE do slots[i]=0 end + return true +end +function M.canDelete(data,id) + local pool=data and data.map_scripts and data.map_scripts.phone + return valid(id) and pool and type(pool[id])=='table' and not M.permanent(data)[id] or false +end +return M diff --git a/tests/engine/gen2_phone_book.lua b/tests/engine/gen2_phone_book.lua new file mode 100644 index 000000000..f030ebce0 --- /dev/null +++ b/tests/engine/gen2_phone_book.lua @@ -0,0 +1,140 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +local Book=require('src.world.Gen2PhoneBook') +local Calls=require('src.script.Gen2Commands') +local Gear=require('src.ui.PokegearMenu') +local function data() + local d=T.fixtures.fresh();d.map_scripts={phone={},scripts={STUB={{'end'}}}} + for id=1,24 do d.map_scripts.phone[id]={name='CONTACT'..id,class=1,trainer=1, + receive='STUB',receiveTime=7,call='STUB',callTime=7,group=1,number=1} end + d.map_scripts.phone[1]={name='MOM',contactNumber=1} + d.map_scripts.phone[4]={name='ELM',contactNumber=4} + return d +end +local function same(actual,expected,label) + T.eq(#actual,#expected,label..' length') + for i,id in ipairs(expected) do T.eq(actual[i],id,label..' slot'..i) end +end +for _,version in ipairs({'gold','silver','crystal'}) do + V.set(version);local d,save=data(),{} + local label=version..': ' + T.eq(Book.remainingSlots(d,save,2),8,label..' absent permanent numbers reserve two slots') + T.eq(Book.remainingSlots(d,save,1),9,label..' a permanent number does not reserve against itself') + T.eq(Book.has(save,0),true,label..' native empty-byte membership semantics retained') + local trainerIds={2,3,5,6,7,8,9,10} + for _,id in ipairs(trainerIds) do T.eq(Book.add(d,save,id),true,label..' ordinary number admitted within unreserved run') end + T.eq(Book.add(d,save,11),false,label..' ninth trainer cannot consume missing permanent slots') + T.eq(Book.add(d,save,1),true,label..' Mom can occupy her reserved slot') + T.eq(Book.add(d,save,4),true,label..' Elm can occupy his reserved slot') + same(Book.contacts(save),{2,3,5,6,7,8,9,10,1,4},label..' insertion order reaches all ten slots') + T.eq(Book.has(save,0),false,label..' full native array contains no empty byte') + local ok,why=Book.add(d,save,2);T.eq(ok,false,label..' duplicate refused');T.eq(why,'already',label..' duplicate reason retained') + T.eq(Book.add(d,save,0),false,label..' zero never becomes a registered contact') + T.eq(Book.add(d,save,255),false,label..' absent contact cannot become callable') + T.eq(Book.canDelete(d,1),false,label..' Mom is permanent in the native UI') + T.eq(Book.canDelete(d,4),false,label..' Elm is permanent in the native UI') + T.eq(Book.canDelete(d,2),true,label..' trainer number is deletable') + T.eq(Book.remove(save,5),true,label..' script deletion succeeds') + T.eq(Book.contacts(save)[3],0,label..' script deletion leaves a physical hole') + T.eq(Book.contacts(save)[4],6,label..' script deletion does not reorder later slots') + T.eq(Book.add(d,save,11),true,label..' next addition occupies the first hole') + T.eq(Book.contacts(save)[3],11,label..' reusing a hole keeps remaining order') + T.eq(Book.deleteAt(save,2),true,label..' Pokegear deletion succeeds') + same(Book.contacts(save),{2,11,6,7,8,9,10,1,4,0},label..' only Pokegear deletion compacts') + T.eq(save.g2Phone[3],nil,label..' deletion updates the existing numeric set') + local detached=Book.contacts(save);detached[1]=24 + T.eq(Book.contacts(save)[1],2,label..' returned slot arrays are detached') + save.g2Phone[23]=true + T.eq(Book.contacts(save)[10],23,label..' old/native set writes are adopted without reordering') + save.g2Phone[11]=nil + T.eq(Book.contacts(save)[2],0,label..' external native deletion is not resurrected') + local selected=Calls.availableCallers(d,save,{group=2,number=2},'DAY') + same(selected,{2,6,7,8,9,10,23},label..' actual incoming selector walks physical registration order') + -- Actual imported-opcode compiler and native ScriptRunner, including the + -- askforphonenumber result branches. Only the yes/no prompt is an IO seam. + local fresh={};local game={data=d,save=fresh,overworld={}} + local serial=0 + local function run(rows) + serial=serial+1;local key='BOOK'..serial + d.map_scripts.scripts[key]=rows + local lowered=assert(require('src.script.Gen2ScriptVM').compile(d,key)) + local runner=require('src.script.ScriptRunner').new(game,game.overworld);runner:run(lowered) + T.eq(runner:isRunning(),false,label..' native book script returns') + return runner.ctx + end + local ctx=run({{'addcellnum',7},{'checkcellnum',7},{'end'}}) + T.eq(ctx.g2Var,1,label..' actual native add/check writes the book') + T.eq(Book.contacts(fresh)[1],7,label..' VM and UI share native slot ownership') + ctx=run({{'delcellnum',7},{'checkcellnum',7},{'end'}}) + T.eq(ctx.g2Var,0,label..' actual native delete clears membership') + ctx=run({{'checkcellnum',0},{'end'}}) + T.eq(ctx.g2Var,1,label..' actual native checkcellnum sees an empty slot') + local Commands=require('src.script.Commands');local ask=Commands.ask + local accepted=true;Commands.ask=function(c)c.lastCheck=accepted end + ctx=run({{'askforphonenumber',8},{'end'}}) + T.eq(ctx.g2Var,0,label..' actual accepted ask returns CONTACT_GOT') + ctx=run({{'askforphonenumber',8},{'end'}}) + T.eq(ctx.g2Var,1,label..' actual duplicate ask returns CONTACTS_FULL') + accepted=false;ctx=run({{'askforphonenumber',9},{'end'}}) + T.eq(ctx.g2Var,2,label..' actual refused ask returns CONTACT_REFUSED') + T.eq(fresh.g2Phone[9],nil,label..' refusal does not mutate native membership') + Commands.ask=ask +end +V.set('crystal') +do + local d=data();d.map_scripts.phone[1]={class=1,name='TRAINER1'} + d.map_scripts.phone[4]={class=1,name='TRAINER4'} + d.map_scripts.phone[12]={name='AUTHORED MOM',contactNumber=1} + d.map_scripts.phone[14]={name='AUTHORED ELM',contactNumber=4} + T.eq(Book.canDelete(d,1),true,'native trainer identity wins over the old Mom slot') + T.eq(Book.canDelete(d,4),true,'native trainer identity wins over the old Elm slot') + T.eq(Book.canDelete(d,12),false,'explicit relocated Mom identity is permanent') + T.eq(Book.canDelete(d,14),false,'explicit relocated Elm identity is permanent') + d.constants.phoneContactOrder={};d.map_scripts.phone[1]={name='CUSTOM'} + T.eq(Book.canDelete(d,1),true,'authored empty identity metadata does not receive retail guesses') +end +do + local d=data();local save={g2Phone={[7]=true,[2]=true,[5]=true}} + same(Book.contacts(save),{2,5,7,0,0,0,0,0,0,0},'legacy set receives deterministic initial slot order') + save.g2PhoneSlots={7,2,2,0,0,0,0,0,0,0} + same(Book.contacts(save),{7,2,5,0,0,0,0,0,0,0},'stored order survives and duplicate slots are repaired') + local gear=setmetatable({game={data=d,save=save},contact=1},{__index=Gear}) + local rows=gear:contacts();T.eq(#rows,10,'actual native card exposes ten physical slots') + T.eq(rows[1].id,7,'actual native card does not sort away registration order') + T.eq(rows[4].id,0,'empty native row is not callable') + T.eq(rows[4].name,'----------','empty native row is displayed explicitly') + local empty=setmetatable({game={data=d,save={}}},{__index=Gear}):contacts() + T.eq(empty[1].id,0,'empty book cannot invent a Mom registration') + T.eq(empty[4].id,0,'empty book cannot invent an Elm registration') + local key;gear.cards={{id='PHONE'}};gear.index=1;gear.blink=0 + gear.game.input={wasPressed=function(_,value)return key==value end} + gear.game.stack={pop=function()end} + local Sound=require('src.core.Sound');local sound=Sound.play;Sound.play=function()end + gear.contact=4;key='a';gear:update(0) + T.eq(gear.phoneSubmenu,nil,'real native A input cannot call an empty slot') + gear.contact=2;key='a';gear:update(0);key='down';gear:update(0);key='a';gear:update(0) + T.eq(save.g2Phone[2],nil,'real native DELETE input removes the selected physical slot') + same(Book.contacts(save),{7,5,0,0,0,0,0,0,0,0},'real Pokegear delete compacts the shared book') + Sound.play=sound +end +do + local d=data();local save={g2Phone={},g2PhoneSlots={2,3,5,6,7,8,9,10,1,4}} + for _,id in ipairs(save.g2PhoneSlots)do save.g2Phone[id]=true end + local gear=setmetatable({game={data=d,save=save},contact=10,gfx={}},{__index=Gear}) + local drawn={};gear.drawStrip=function()end;gear.textbox=function()end + gear.printBoxText=function()end;gear.drawModeArrow=function()end + gear.text=function(_,name,_,y)drawn[name]=y end + local Font=require('src.render.Font');local old={Font.draw,Font.drawCode,Font.beginTint,Font.endTint} + local graphics=love.graphics;love.graphics={setColor=function()end,rectangle=function()end} + Font.draw=function(name,_,y)drawn[name]=y/8 end + Font.drawCode=function()end;Font.beginTint=function()end;Font.endTint=function()end + gear:drawPhone() + T.eq(drawn.ELM,11,'actual native drawing keeps selected slot ten visible') + T.eq(drawn.MOM,10,'actual native drawing retains slot nine beside it') + T.eq(drawn.CONTACT2,nil,'scrolling does not draw hidden slot one') + Font.draw,Font.drawCode,Font.beginTint,Font.endTint=unpack(old);love.graphics=graphics +end +V.set(original) +T.finish('native ordered phone slots, permanent reservations, actual VM and Pokegear') diff --git a/tests/engine/gen2_phone_content.lua b/tests/engine/gen2_phone_content.lua index 344faeb6c..044b6896d 100644 --- a/tests/engine/gen2_phone_content.lua +++ b/tests/engine/gen2_phone_content.lua @@ -62,7 +62,9 @@ for _,version in ipairs({'gold','silver','crystal'}) do if index then local game={data=d,save={g2Phone={[index]=true}},overworld={}} local gear=setmetatable({game=game},{__index=Gear}) - T.eq(#gear:contacts(),1,label..' actual native saved phone book sees addition') + T.eq(#gear:contacts(),10,label..' actual native phone book preserves ten physical slots') + T.eq(gear:contacts()[1].id,index,label..' registered addition occupies the first native slot') + T.eq(gear:contacts()[2].id,0,label..' unused native slot cannot invent a callable contact') for _,direction in ipairs({'call','receive'}) do local rows=direction=='call' and C.phoneCallScript(d,index) or C.phoneReceiveScript(d,index) T.check(rows~=nil,label..' '..direction..' compiles through native VM') From 97ea982bc1c06282dcc863103daaa5b0a204c7d3 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:44:42 +0100 Subject: [PATCH 074/138] Document offered contribution permission for the native phone book --- docs/native-gen2-phone-book.md | 2 +- src/world/Gen2PhoneBook.lua | 2 +- tests/engine/gen2_phone_book.lua | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/native-gen2-phone-book.md b/docs/native-gen2-phone-book.md index 1a34bacaa..825fb775b 100644 --- a/docs/native-gen2-phone-book.md +++ b/docs/native-gen2-phone-book.md @@ -32,4 +32,4 @@ call/rematch contracts, new authored phone scripts/forward references, and fresh-import/GPU/story acceptance remain open. Full Gen1 parity is unproven. Original implementation by Roxas2712; existing upstream/third-party rights and -credits remain applicable. See NOTICE.md and LICENSE-ROXAS2712.md. +credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/src/world/Gen2PhoneBook.lua b/src/world/Gen2PhoneBook.lua index 7f7949d65..db0ba6d25 100644 --- a/src/world/Gen2PhoneBook.lua +++ b/src/world/Gen2PhoneBook.lua @@ -1,4 +1,4 @@ --- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Copyright (c) 2026 Roxas2712. See the offered PR contribution permission in docs/native-gen2-phone-book.md. -- Native ten-byte order beside the existing numeric registration set. Reads -- retain legacy overflow entries; no save or contact ID is renumbered. local M = { SIZE=10 } diff --git a/tests/engine/gen2_phone_book.lua b/tests/engine/gen2_phone_book.lua index f030ebce0..06f472afd 100644 --- a/tests/engine/gen2_phone_book.lua +++ b/tests/engine/gen2_phone_book.lua @@ -1,4 +1,4 @@ --- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Copyright (c) 2026 Roxas2712. See the offered PR contribution permission in docs/native-gen2-phone-book.md. package.path='./?.lua;./?/init.lua;'..package.path local T=require('tests.modkit') local V=require('src.core.GameVersion');local original=V.get() From 391dc765020b14678aca4f1302e82d8af8b6b359 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:51:23 +0100 Subject: [PATCH 075/138] perf(P4): hoist world draw bodies and reuse private cover lists --- docs/world-draw-hot-paths.md | 52 + src/core/Performance.lua | 2 + src/world/OverworldController.lua | 1665 +++++++++++++++------------- tests/engine/world_draw_hot.lua | 135 +++ tests/world_draw/fixture.lua | 118 ++ tools/check_frame_pacing.py | 11 +- tools/frame-pacing-check/main.lua | 4 + tools/frame-pacing-check/world.lua | 138 +++ 8 files changed, 1353 insertions(+), 772 deletions(-) create mode 100644 docs/world-draw-hot-paths.md create mode 100644 tests/engine/world_draw_hot.lua create mode 100644 tests/world_draw/fixture.lua create mode 100644 tools/frame-pacing-check/world.lua diff --git a/docs/world-draw-hot-paths.md b/docs/world-draw-hot-paths.md new file mode 100644 index 000000000..30a70b297 --- /dev/null +++ b/docs/world-draw-hot-paths.md @@ -0,0 +1,52 @@ +# P4: native world draw dispatch and private worklists + +`Performance.worldDrawHoist` defaults to `true`. Set it to `false` to disable +private list reuse and whole-loop protection and restore per-frame bound FX, +height/entity/focus adapters and individual protected entity draws. The switch +is independent of P1–P3. + +Native FX bodies, entity drawing, ground rise and canopy focus are methods with +explicit frame arguments. Ordinary flat draws use these methods directly. +Fresh no-argument bindings remain at public pipeline and billboard callback +seams: a retained FX callback must keep its own frame/camera. No record, method +signature, hook order or render pipeline field changes. + +Reflectors use a private counted instance list; removed entities are released. +Reflection cover cells use two private numeric columns and a reusable seen set. +Nested draws borrow separate work storage while the outer lease is busy. +An overridden public `rippleCells` still receives fresh pair tables in the old +shape. None of the new pools is included in a public callback record. + +The ordinary entity loop has one protected call. Its original sprite-depth +cleanup still runs before the original error is rethrown. The deferred above- +terrain pass retains its original ordering after the top layer. + +The guarded native host runs 64 synthetic rectangle actors through actual +`drawWorld`, reflection coverage, FX and native canvas composition. There is no +ROM, save/profile, extracted asset or mod scene. Each phase has 120 warm-up and +120 measured frames; VSync 1, 60 Hz, P1/P3 enabled. GC is stopped in each measured +window to expose allocation growth, then restarted. Heap growth includes all +host/profiler allocations and is not retained memory or a leak measurement. + +Observed macOS arm64, switch off → on: + +| Tree / backend | F11 world entities, ms/frame | F11 FX, ms/frame | Raw draw mean, ms | CPU, ms/frame | Heap growth / 120 frames, KiB | +| --- | ---: | ---: | ---: | ---: | ---: | +| Fork / LÖVE 11.5 OpenGL | 0.04 → 0.05 | 0.01 → 0.01 | 0.395 → 0.468 | 1.101 → 1.243 | 2758 → 1059 | +| Fork / LÖVE 12 Metal | 0.02 → 0.02 | 0.01 → 0.01 | 16.405 → 16.408 | 0.728 → 0.703 | 2748 → 1043 | +| Public adaptation / LÖVE 11.5 OpenGL | 0.03 → 0.02 | 0.01 → 0.01 | 0.270 → 0.241 | 0.911 → 0.801 | 2758 → 1059 | +| Public adaptation / LÖVE 12 Metal | 0.02 → 0.02 | 0.01 → 0.01 | 16.417 → 16.407 | 0.612 → 0.597 | 2749 → 1043 | + +Untimed protected calls fall from 55 to 1 in this scene (nine actors are deferred +to the above-terrain pass). Heap growth is about 61% lower. Short timing results +are mixed, and Metal can place GPU/VSync waiting in submission. No general FPS +or CPU improvement is established. Full game, Android, dedicated ARM handheld +and long session: not measured. + +Reproduce with `tools/check_frame_pacing.py --world-draw --love /path/to/love +--report /owned/qa/world.json`. The guarded runner retains raw F11 logs and +measured-source fingerprints. `tests/engine/world_draw_hot.lua` exercises four +editions, operation order/coordinates, batch-call count, private list reuse, +shrinking lists, public ripple records, reentrant covers, retained FX callbacks +and sprite-depth cleanup. A separate local comparison with the unchanged +pre-P4 controller also matched all draw traces in those four editions. diff --git a/src/core/Performance.lua b/src/core/Performance.lua index a7fcea886..c7bd61b1a 100644 --- a/src/core/Performance.lua +++ b/src/core/Performance.lua @@ -47,6 +47,8 @@ local Performance = {} Performance.gcBudget = true -- P3: cache hot-path dependencies and reuse engine-private presentation data. Performance.hotPathHoist = true +-- P4: independent world-draw closure, list and protection optimization. +Performance.worldDrawHoist = true Performance.GC_PAUSE = 120 Performance.GC_STEP_MUL = 300 Performance.GC_BUDGET_SECONDS = 0.0005 diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 7e052d703..2f2b0787c 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -10,6 +10,7 @@ -- map scripts (data/scripts/), marts, nurses or extracted text. local FrameProfile = require("src.core.FrameProfile") +local Performance = require("src.core.Performance") local Assets = require("src.render.Assets") local Camera = require("src.render.Camera") local Collision = require("src.world.Collision") @@ -16855,6 +16856,820 @@ function OverworldState:poseBerryTrees() end end +-- Only public callback seams bind a frame; ordinary draws pass context explicitly. +local function bindWorldFx(fn, state, cam) + return function() return fn(state, cam) end +end + +-- the Pokémon Center heal machine (PokeCenterOAMData): the monitor +-- tile over the machine's screen and one ball per healed mon in two +-- mirrored columns, all blinking during the jingle flash. The GB +-- draws it at fixed screen coords with the player's cell BG-aligned +-- at (64,64); anchoring those coords to where the player stood keeps +-- the overlay on the machine at any zoom. +function OverworldState:_worldFxHeal(cam) + if not self.healAnim then return end + local ha = self.healAnim + -- HOENN'S MACHINE IS NOT AN OAM TABLE. + -- + -- Everything below this block is the Game Boy's: two 8x8 tiles out of + -- field.overworldFx.healMachine at coordinates a table in the ROM gives. + -- Emerald has no such table -- its machine is a field effect whose two + -- sprites are created by machine code, and where they go is four + -- immediates INSIDE that code. extractHealMachine reads them, and the + -- record it writes carries the positions already measured from the corner + -- of the player's own cell, so nothing here has to know where the + -- cartridge's camera puts anybody. + if GameVersion.isGen4() then return end + if self:drawGen3Heal(ha, cam) then return end + local fxDef = Game.data.field.overworldFx + if self.healMachineImg == nil and fxDef and fxDef.healMachine then + local ok, img = pcall(love.graphics.newImage, fxDef.healMachine.path) + self.healMachineImg = ok and img or false + end + local img = self.healMachineImg + if img then + if not self.healMachineQuads then + local w, h = img:getWidth(), img:getHeight() + self.healMachineQuads = { + love.graphics.newQuad(0, 0, 8, 8, w, h), -- monitor ($7c) + love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d) + } + end + -- The machine carries its OWN four colours. HealMachineAnim.LoadPalettes + -- (04:$6434) copies them over OBJ palette 6 before the animation runs -- + -- which is why every row of its OAM table names palette 6 -- and they are + -- white / light orange / red / black: a Poke Ball. Nothing was applying + -- any palette at all, so the sheet drew in the DMG greys it is decoded + -- in and a Pokemon Center healed with grey balls. + local def = fxDef and fxDef.healMachine + local objPal = def and def.gen2ObjPal + local basePal = (objPal and objPal[1] and objPal) or PaletteFX.GRAYS + -- the jingle flash recolors the machine sprites in place + -- (FlashSprite8Times XORs rOBP1; the sprites never disappear): + -- ha.visible == false is the flashed half of each beat, drawn with + -- the light/dark shades swapped instead of skipped + local shader + if (not ha.visible) or basePal ~= PaletteFX.GRAYS then + shader = PaletteFX.shader() + if shader then + PaletteFX.sendColors(shader, ha.visible and basePal + or PaletteFX.permute(basePal, HEAL_FLASH_MAP)) + love.graphics.setShader(shader) + end + end + -- TileRenderer windows with -floor(cam), so the overlay must use the + -- same snap or a fractional camera (odd fill/tilt view sizes) parks + -- the balls a pixel off the machine tiles + local ox = ha.px - 64 - math.floor(cam.x) + local oy = ha.py - 64 - math.floor(cam.y) + -- Gen2 puts these sprites somewhere else entirely. Its + -- HealMachineAnim.PC_ElmsLab_OAM (04:$63BC) has the monitor at x 26/30 + -- and the balls at x 24/32; Gen1's PokeCenterOAMData has the balls at + -- 40/48. The extractor reads that OAM table straight off the cartridge + -- into field.overworldFx.healMachine, so use it where it exists and keep + -- the Gen1 constants as the fallback -- drawn at Gen1's offsets the + -- whole overlay sat a full 16px right of the Gen2 machine. + local monitor = (def and def.monitor) or { { 44, 20 } } + local balls = (def and def.balls) or HEAL_BALL_XY + love.graphics.setColor(1, 1, 1, 1) + for _, m in ipairs(monitor) do + love.graphics.draw(img, self.healMachineQuads[1], ox + m[1], oy + m[2]) + end + for i = 1, math.min(ha.lit, #balls) do + local b = balls[i] + if b[3] then -- right column: OAM_XFLIP + love.graphics.draw(img, self.healMachineQuads[2], + ox + b[1] + 8, oy + b[2], 0, -1, 1) + else + love.graphics.draw(img, self.healMachineQuads[2], + ox + b[1], oy + b[2]) + end + end + if shader then love.graphics.setShader() end + end +end + +-- the Cut/boulder dust puff: the smoke tile drawn 2x2 over the cell, +-- flickering (AnimateBoulderDust XORs the OBJ palette every step) +function OverworldState:_worldFxDust(cam) + if not self.dustAnim then return end + local fxDef = Game.data.field.overworldFx + local smoke = fxDef and fxDef.smoke + if smoke then + if self.smokeImg == nil then + local ok, img = pcall(love.graphics.newImage, smoke.path) + self.smokeImg = ok and img or false + end + if self.smokeImg then + local da = self.dustAnim + local dx = da.x * 16 - cam.x + local dy = da.y * 16 - cam.y + local flicker = math.floor(da.frames / 4) % 2 == 0 + love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55) + for i = 0, 1 do + for j = 0, 1 do + love.graphics.draw(self.smokeImg, dx + i * 8, dy + j * 8) + end + end + love.graphics.setColor(1, 1, 1, 1) + end + end +end + +-- FireRed's S.S. Anne departure uses two ordinary OBJ sprites that are not +-- object events: a looping 16x32 wake behind the ship and 16x16 smoke puffs +-- from its funnel. Special 401 owns their exact lifetime/positions; this +-- pass only turns that state into the extracted ROM frames. +function OverworldState:_worldSsAnneArt(cam) + local def = Game.data.constants and Game.data.constants.gen3SSAnneFx + if not def then return nil end + if self.ssAnneWakeImg == nil then + local ok, img = pcall(Assets.image, def.wake) + self.ssAnneWakeImg = ok and img or false + end + if self.ssAnneSmokeImg == nil then + local ok, img = pcall(Assets.image, def.smoke) + self.ssAnneSmokeImg = ok and img or false + end + if self.ssAnneWakeImg and not self.ssAnneWakeQuads then + local w, h = self.ssAnneWakeImg:getDimensions() + self.ssAnneWakeQuads = { + love.graphics.newQuad(0, 0, 16, 32, w, h), + love.graphics.newQuad(16, 0, 16, 32, w, h), + } + end + if self.ssAnneSmokeImg and not self.ssAnneSmokeQuads then + local w, h = self.ssAnneSmokeImg:getDimensions() + self.ssAnneSmokeQuads = {} + for i = 0, 3 do + self.ssAnneSmokeQuads[i + 1] = love.graphics.newQuad(i * 16, 0, 16, 16, w, h) + end + end + return self.ssAnneWakeImg, self.ssAnneSmokeImg +end + +function OverworldState:_worldFxSSAnneWake(cam) + local fx = self.ssAnneDepartureFx + if not (fx and fx.boat) then return end + local wake = self:_worldSsAnneArt(cam) + if not (wake and self.ssAnneWakeQuads) then return end + local age = math.min(fx.wakeAge or 0, 132) + local cx = fx.boat.px + (fx.boat.shiftPx or 0) + 8 - cam.x + + 80 + math.floor(age / 6) + if cx < -18 then return end + local frame = math.floor(age / 12) % 2 + 1 + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(wake, self.ssAnneWakeQuads[frame], + math.floor(cx - 8), 109 - 16) +end + +function OverworldState:_worldFxSSAnneSmoke(cam) + local fx = self.ssAnneDepartureFx + if not (fx and fx.smoke and #fx.smoke > 0) then return end + local _, smoke = self:_worldSsAnneArt(cam) + if not (smoke and self.ssAnneSmokeQuads) then return end + love.graphics.setColor(1, 1, 1, 1) + for _, puff in ipairs(fx.smoke) do + local age = puff.age or 0 + local frame + if age < 10 then frame = 1 + elseif age < 30 then frame = 2 + elseif age < 50 then frame = 3 + else frame = 4 end + local cx = puff.x + math.floor(age / 4) + love.graphics.draw(smoke, self.ssAnneSmokeQuads[frame], + math.floor(cx - 8), 78 - 8) + end +end + +-- the watering can's water: droplets arcing down onto the soil, and a +-- sparkle on the last few frames as it soaks in +function OverworldState:_worldFxWater(cam) + if not self.waterAnim then return end + local wa = self.waterAnim + local total = wa.total or 40 + local age = total - wa.frames + local dx = wa.x * 16 - cam.x + local dy = wa.y * 16 - cam.y + love.graphics.setColor(0.55, 0.80, 0.98, 0.95) + for i = 0, 5 do + -- each droplet falls on its own phase, so they read as a stream + local t = ((age * 3 + i * 7) % 24) / 24 + local px = dx + 2 + i * 2 + t * 10 + local py = dy - 6 + t * 20 + if py < dy + 16 then + love.graphics.rectangle("fill", px, py, 2, 3) + end + end + -- the soak: a brief bright ring once the water has been falling a while + if age > total * 0.55 then + local k = (age - total * 0.55) / (total * 0.45) + love.graphics.setColor(1, 1, 1, 0.8 * (1 - k)) + love.graphics.rectangle("fill", dx + 2, dy + 12, 12, 2) + end + love.graphics.setColor(1, 1, 1, 1) +end + +-- the cut tree splitting apart (AnimCut): top half slides right, +-- bottom half slides left, 1px per frame, flickering as they go +function OverworldState:_worldFxCutTree(cam) + if not self.cutAnim then return end + local fxDef = Game.data.field.overworldFx + local tree = fxDef and fxDef.cutTree + if not tree then return end + if self.cutTreeImg == nil then + local ok, img = pcall(love.graphics.newImage, tree.path) + self.cutTreeImg = ok and img or false + end + local img = self.cutTreeImg + if not img then return end + if not self.cutTreeQuads then + local w, h = img:getWidth(), img:getHeight() + self.cutTreeQuads = { + love.graphics.newQuad(0, 0, 16, 8, w, h), -- top half + love.graphics.newQuad(0, 8, 16, 8, w, h), -- bottom half + } + end + local ca = self.cutAnim + local off = (ca.total or 8) - ca.frames + local dx = ca.x * 16 - cam.x + local dy = ca.y * 16 - cam.y + local flicker = ca.frames % 2 == 0 + love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55) + love.graphics.draw(img, self.cutTreeQuads[1], dx + off, dy) + love.graphics.draw(img, self.cutTreeQuads[2], dx - off, dy + 8) + love.graphics.setColor(1, 1, 1, 1) +end + +-- the "!" bubble above a trainer who spotted the player +-- the tile-anchored sparkle: an OAM sprite in the cartridge, so it rides +-- the camera the way a body does rather than the background's shake +function OverworldState:_worldFxSparkle(cam) + love.graphics.setColor(1, 1, 1, 1) + self:drawSparkles(cam.x, cam.y) +end + +-- ...and ONE of them, for the two paths that cannot draw the set in screen +-- space: each sparkle sits on its own cell, so a projected scene has to +-- anchor each one separately. The upvalue is set immediately before each +-- call and cleared after, which is the same shape the emote path uses for +-- the one NPC it belongs to. +function OverworldState:_worldFxSparkleOne(cam, item) + local s = item + if not s then return end + local sprite = self:sparkleSprite() + if not sprite then return end + local frame = self:sparkleFrame(s.clock) + if not frame then return end + love.graphics.setColor(1, 1, 1, 1) + sprite:drawFixedFrame(s.px, s.py + 4, cam.x, cam.y, frame) +end + +-- THE POKE RADAR'S PATCHES, one at a time like the sparkles +function OverworldState:_worldFxRadarOne(cam, item) + local pt = item + if not pt then return end + require("src.world.Gen4Radar").drawPatch(pt, pt.x * 16 - cam.x, pt.y * 16 - cam.y, + love.timer and love.timer.getTime() * 60 or 0) +end + +function OverworldState:_worldFxRadar(cam) + for _, pt in ipairs((self.gen4Radar and self.gen4Radar.active and self.gen4Radar.patches) or {}) do + if pt.active then self:_worldFxRadarOne(cam, pt) end + end + radarOne = nil +end + +function OverworldState:_worldFxEmote(cam) + if not (self.emote and self.emote.npc) then return end + -- bubble = false is a silent hold (a Pikachu emotion that plays a + -- cry with no bubble still pauses the world for its beat) + if self.emote.bubble == false then return end + local npc = self.emote.npc + -- SINNOH'S OWN BUBBLE, ON TOP OF THE HEAD, IN THE GROUND'S PROJECTION. + -- + -- Reported from play: *"were missing the proper exclamation point above + -- trainers when they see you for battle and its not above their heads"*. + -- Platinum had neither art source below, so it fell to the hand-drawn box, + -- placed with flat 2D arithmetic that knows nothing of terrain height or + -- the tilt -- so it landed across the face. + -- + -- The cartridge (src/overlay005/ov5_021F5A10.c, see Gen4Emotes): a 16x16 + -- billboard from fldeff.narc whose plane rises 0..16 from an anchor 32 + -- units above the owner's drawn position -- the top of a 32-unit + -- character -- bouncing 6, 10, 12, 12, 10, 6, 0 and then holding. So the + -- bubble's bottom edge sits on the sprite's top edge, centred on it, and + -- it is drawn through exactly the transform the sprite is: the same + -- terrain rise, and `freeEntity` when a 3D camera owns the frame. + local g4emotes = Game.data and Game.data.gen4_emotes + local g4ground = self.map and self.map.renderer and self.map.renderer.gen4Ground + if g4emotes and g4ground and npc.sprite then + local Gen4Emotes = require("src.import.Gen4Emotes") + local kind = (self.emote.bubble == "double" or self.emote.bubble == 2) + and g4emotes.double and "double" or "exclamation" + -- BW's "?", note and "..." by name (Gen5MoveExec), when the cache has them + local named = self.emote.bubble + if type(named) == "string" and named ~= "double" and type(g4emotes[named]) == "table" then + kind = named + end + local record = g4emotes[kind] + self.gen4EmoteImages = self.gen4EmoteImages or {} + local img = self.gen4EmoteImages[kind] + if img == nil and record and record.rgba then + local okI, made = pcall(function() + local data = love.image.newImageData(record.width, record.height, + "rgba8", record.rgba) + local image = love.graphics.newImage(data) + image:setFilter("nearest", "nearest") + return image + end) + img = okI and made or false + self.gen4EmoteImages[kind] = img + end + if img then + local age = math.max(0, (self.emote.totalFrames or Gen4Emotes.FRAMES) + - (self.emote.frames or 0)) + -- HEARTGOLD / SOULSILVER: the cartridge's own anchor, 32 world units + -- over the owner through the camera now drawing, the player + -- included (src/world/Gen4EmoteDraw.lua); Platinum below, unchanged + local EmoteDraw = require("src.world.Gen4EmoteDraw") + if EmoteDraw.applies(self.map) then + EmoteDraw.draw(self, g4ground, npc, cam, record, img, age) + return + end + local bx, by, rise = OverworldState.gen4EmoteSpot(g4ground, npc, cam, + record, age) + local function draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, bx, by) + end + local free = g4ground.freeMode and g4ground:freeMode() + if free then + -- Depth-tested where the cartridge's billboard actually is -- the + -- middle of a bubble 32 units up -- not at its trainer's feet. + g4ground:freeEntity(npc.px or 0, npc.py or 0, cam.x, cam.y, rise, draw, + Gen4Emotes.LIFT + 8) + else + draw() + end + return + end + end + -- WHERE IT HANGS, which the cartridge states outright. + -- + -- Reported from play: the bubble "should appear above the trainers head". + -- It was four pixels right of them and six low, which on a 16x32 walker + -- put it across the face rather than over it. + -- + -- The icon's own sprite callback (0B4724) copies the owner's position + -- every frame and offsets it by exactly one thing: + -- + -- icon->x = owner->x + -- icon->y = owner->y - 16 + -- + -- and a GBA sprite's x/y is its CENTRE (the corner comes from + -- centerToCornerVec), so this is "the icon's centre sits sixteen pixels + -- above the owner's" -- which is a different top-left offset for a 16x16 + -- object than for a 16x32 one, and is why one number could never be right + -- for both. Stated as centres it falls out the same for either: the x + -- offsets cancel whatever the sprite's width, and the y is the owner's + -- half-height less the icon's eight plus the sixteen. + local half = math.floor(((npc.sprite and npc.sprite.tileH) or 16) / 2) + local ex = npc.px - cam.x + local ey = npc.py - cam.y - 12 - half + local bubble = Game.data.field.emotionBubbles + local drawn = false + + -- HOENN'S OWN BUBBLE, which is not on the Game Boy's sheet. + -- + -- The "!" over a trainer's head is three 16x16 icons hanging off a sprite + -- template that only a field-effect script reaches (see extractEmotes) -- + -- which is why every sweep of the field-effect tables missed it. A Gen 3 + -- dataset that has them draws them; one that does not falls through to + -- the Game Boy sheet and then to the box below, exactly as before. + local icons = (Game.data.constants or {}).gen3Emotes + if icons then + local role = GEN3_EMOTE_ROLES[self.emote.bubble or 1] or "exclamation" + local record = icons[role] + local path = record and record.image + if path then + self.gen3EmoteImages = self.gen3EmoteImages or {} + local held = self.gen3EmoteImages[path] + if held == nil then + local okImg, img = pcall(require("src.render.Assets").image, path) + held = (okImg and img) or false + self.gen3EmoteImages[path] = held + end + if held then + local age = math.max(0, (self.emote.totalFrames or 60) + - self.emote.frames) + local frame = 0 + local left = age + for _, step in ipairs(record.timeline or {}) do + local duration = tonumber(step[2]) or 1 + if left < duration then + frame = tonumber(step[1]) or 0 + break + end + left = left - duration + frame = tonumber(step[1]) or frame + end + -- SpriteCB_TrainerIcons starts data[3] at -5, accumulates it into + -- data[4], and increments that velocity until the offset returns 0. + local bounce, velocity = 0, -5 + for _ = 1, math.min(age + 1, 11) do + bounce = bounce + velocity + if bounce ~= 0 then velocity = velocity + 1 else velocity = 0 end + end + local fw, fh = tonumber(record.width) or 16, tonumber(record.height) or 16 + local iw, ih = held:getDimensions() + local q = nil + if (tonumber(record.frames) or 1) > 1 then + self.gen3EmoteQuads = self.gen3EmoteQuads or {} + local key = path .. ":" .. tostring(frame) + q = self.gen3EmoteQuads[key] + if not q then + q = love.graphics.newQuad(frame * fw, 0, fw, fh, iw, ih) + self.gen3EmoteQuads[key] = q + end + end + love.graphics.setColor(1, 1, 1, 1) + if q then love.graphics.draw(held, q, ex, ey + bounce) + else love.graphics.draw(held, ex, ey + bounce) end + drawn = true + end + end + end + if not drawn and bubble and bubble.path then + local ok, img = pcall(function() + self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path) + return self.emoteImg + end) + -- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command + -- picks question/happy crops instead + local bi = self.emote.bubble or 1 + local rect = bubble.bubbles and bubble.bubbles[bi] + if ok and img and rect then + love.graphics.setColor(1, 1, 1, 1) + -- one Quad per bubble crop, cached: this draws every frame the "!" + -- (or the emote-command crops) is up, so a fresh Quad here churned + -- the GC. The bubble set is small and fixed, so the cache is bounded. + self.emoteQuads = self.emoteQuads or {} + local q = self.emoteQuads[bi] + if not q then + q = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h, + img:getDimensions()) + self.emoteQuads[bi] = q + end + love.graphics.draw(img, q, ex, ey) + drawn = true + end + end + if not drawn then + local Font = require("src.render.Font") + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", ex, ey, 10, 12) + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("line", ex + 0.5, ey + 0.5, 10, 12) + Font.draw("!", ex + 1, ey + 2) + love.graphics.setColor(1, 1, 1, 1) + end +end + +-- the FLY bird sweeping off with the player +function OverworldState:_worldFxBird(cam) + if not self.flyAnim then return end + -- FireRed has a dedicated five-frame 64x64 field-effect bird. Frames 1 + -- and 3 already contain Red/Leaf riding it during fly-out, so use the + -- cartridge sheet directly instead of substituting the selected mon's + -- battle front sprite. + local frlg = Game.data.constants and Game.data.constants.gen3FRLGFlyBird + if frlg and frlg.path then + if self.flyBirdImg == nil then + local ok, img = pcall(love.graphics.newImage, frlg.path) + self.flyBirdImg = ok and img or false + end + if self.flyBirdImg then + local gender = ((Game.save or {}).player or {}).gender == "girl" + and "girl" or "boy" + local frame = ((frlg.flyOut or {})[gender]) or (gender == "girl" and 3 or 1) + local fw = frlg.frameWidth or 64 + local fh = frlg.frameHeight or 64 + self.flyBirdQuads = self.flyBirdQuads or {} + if not self.flyBirdQuads[frame] then + local iw, ih = self.flyBirdImg:getDimensions() + self.flyBirdQuads[frame] = love.graphics.newQuad( + 0, frame * fh, fw, fh, iw, ih) + end + local t = math.max(0, math.min(48, 48 - self.flyAnim.frames)) + local reach = 16 + local dx, dy + if t <= reach then + local k = 1 - t / reach + dx, dy = -96 * k, -72 * k + else + local k = (t - reach) / (48 - reach) + dx, dy = -120 * k * k, -96 * k * k + end + local px = math.floor(self.player.px - cam.x + 8 - fw / 2 + dx) + local py = math.floor(self.player.py - cam.y + 8 - fh / 2 + dy) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(self.flyBirdImg, self.flyBirdQuads[frame], px, py) + require("src.render.PaletteFX").markTrueColor(px, py, fw, fh) + return + end + end + local birdId = FieldDefaults.fieldValue(Game.data, "playerSprites", "fly") + if not self.birdSprite and birdId and Game.data.sprites[birdId] then + local SR = require("src.render.SpriteRenderer") + self.birdSprite = SR.new(Game.data.sprites[birdId]) + end + if self.birdSprite then + local t = 48 - self.flyAnim.frames + local bx = self.player.px - t * 4 + local by = self.player.py - math.floor(t * 1.5) + love.graphics.setColor(1, 1, 1, 1) + self.birdSprite:draw(bx, by, cam.x, cam.y, "left", + math.floor(t / 4) % 2, false) + return + end + + -- Legacy fallback for datasets that predate the ROM-extracted FireRed + -- bird above. Keep it for old caches/other games, but current FireRed + -- imports should never reach this branch. + -- + -- Reported from play, twice: "ensure the animation of the flying type + -- pokemon swooping up my player plays after the fly hm transition" and + -- then "Theres still no fly swoop". The machinery above has been here the + -- whole time and has never had a sprite to draw: it wants + -- playerSprites.fly, and no Gen 3 cache carries one. + -- + -- IT WAS LOOKED FOR PROPERLY THIS TIME, and it is not where a sheet like + -- that would be. Emerald's fly bird is not an object-event graphics row + -- -- the three 64x64 rows in that table are Rayquaza twice and the cable + -- car, and every 32x32 row is a person or a legendary -- and it is not + -- hanging off any field-effect script either: not one of the sixty-seven + -- scripts loads tiles, and no native any of them calls reaches a sprite + -- template with frames that size. It is loaded by C, from a sheet only + -- the fly task names, and nothing the import can currently follow gets to + -- it. + -- + -- So the departure is drawn with something this port certainly does have + -- and the cartridge's own bird is not: the FRONT SPRITE OF THE POKEMON + -- THAT IS ACTUALLY CARRYING YOU -- the same one the field-move sweep just + -- showed. It stoops in from the upper left, meets the player, and lifts + -- away with them. Reconstructed, and said so; the moment the bird IS + -- found, playerSprites.fly makes the branch above win again and this is + -- never reached. + local mon = self.flyAnim.mon + if not mon then return end + if self.flyMonImg == nil then + local okPath, path = pcall(function() + return require("src.pokemon.Sprites").path( + Game.data, mon.species, "front", { mon = mon }) + end) + local img + if okPath and type(path) == "string" then + local okImg, loaded = pcall(love.graphics.newImage, path) + img = okImg and loaded or nil + end + self.flyMonImg = img or false + end + if not self.flyMonImg then return end + local total = 48 + local t = math.max(0, math.min(total, total - self.flyAnim.frames)) + -- the stoop: in over sixteen frames, away over the rest, and the two + -- meet on the player rather than anywhere else + local reach = 16 + local dx, dy + if t <= reach then + local k = 1 - t / reach + dx, dy = -96 * k, -72 * k + else + local k = (t - reach) / (total - reach) + dx, dy = -120 * k * k, -96 * k * k + end + local iw, ih = self.flyMonImg:getDimensions() + local px = math.floor(self.player.px - cam.x + 8 - iw / 2 + dx) + local py = math.floor(self.player.py - cam.y + 8 - ih / 2 + dy) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(self.flyMonImg, px, py) + require("src.render.PaletteFX").markTrueColor(px, py, iw, ih) +end + +-- fishing pose: the rod tile over the faced water (gfx/fishing.asm) +function OverworldState:_worldFxRod(cam) + if not self.fishing then return end + local fx = Game.data.field.overworldFx + local rod = fx and fx.fishingRod + if rod then + if self.rodImg == nil then + local ok, img = pcall(love.graphics.newImage, rod.path) + self.rodImg = ok and img or false + end + if self.rodImg then + local p = self.player + local oam = ROD_OAM[self.fishing.facing] or ROD_OAM.down + if not self.rodQuads then + -- one quad per 8x8 tile of the stacked sheet (ROD_OAM.tile) + local iw, ih = self.rodImg:getDimensions() + self.rodQuads = {} + for i = 0, math.floor(ih / 8) - 1 do + self.rodQuads[i] = love.graphics.newQuad(0, i * 8, 8, 8, iw, ih) + end + end + local quad = self.rodQuads[oam.tile] + -- the sprite's top-left is 4px above its cell (SpriteRenderer:draw) + local rx = p.px - cam.x + oam.dx + local ry = p.py - cam.y - 4 + oam.dy + love.graphics.setColor(1, 1, 1, 1) + if quad and oam.flip then + love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1) + elseif quad then + love.graphics.draw(self.rodImg, quad, rx, ry) + end + end + end +end + +function OverworldState:_worldRise(e, groundForRise, groundSin, cam) + if not groundForRise then return 0 end + local rise = groundForRise:rise(e.px + (e.shiftPx or 0) + 8, e.py + 8) + -- A SPRITE OFFSET (MapObject_SetSpritePosOffset: the surf mount's ride, + -- the Distortion World cast's flights) in the field view: up by the + -- camera's height lean, toward it by the ground's + local so = e.spriteOffset + if so and (so.lift or so.oz) then + rise = rise + (so.lift or 0) * (groundForRise.heightScale or 0) + - (so.oz or 0) * (groundForRise.groundScale or 1) + end + if groundSin >= 1 then return rise end + return (e.py - cam.y) * (1 - groundSin) + rise +end + +function OverworldState:_worldDrawEntity(e, cam, bgY, freeGround, groundForRise, groundSin, grassColors, spriteDepthGround, shadows, riseOf) + if not (self.flyAnim and self:hasFlyBird() and e == self.player) + and not e.hidden and not e.berryVisualEmpty and not plotIsEmpty(e) then + -- A FREE CAMERA PLACES ITS OWN CHARACTERS. + -- + -- Reported from play: *"isnt keeping the player the right size"*, with a + -- screenshot of a third-person Twinleaf in correct perspective and two + -- characters standing in it at flat 2D scale. They were drawn by the + -- line below, which places every sprite at `py - camY` -- the field + -- camera's 1:1 mapping, and simply not this camera's. + -- + -- `freeEntity` runs THIS SAME DRAW inside a transform, so nothing here + -- learns about projection and the entity keeps owning its own sprite, + -- frame and palette. + -- + -- In FIRST person the player is the eye, so drawing them puts their own + -- back across the middle of the view. + if freeGround then + if not (freeGround:freeMode() == "first" and e == self.player) then + local rise = riseOf(self, e, groundForRise, groundSin, cam) + -- ...AND WHICH WAY THEY FACE ON SCREEN. + -- + -- Reported from play: *"when i orbit the camera 180 degrees if i + -- walk left it looks like hes walking right"*. `screenToWorld` + -- turns the PRESS into a world direction, so the step is right; + -- the sprite then showed that world facing under a camera that is + -- no longer looking north, so the artwork was the one thing still + -- in the old frame. `worldToScreen` is its inverse, and the pair + -- are inverses because the camera sits between them. + -- + -- Set and restored around the draw rather than threaded through, + -- because `facing` is what every pose, animation and follower path + -- in the entity reads, and none of them should learn about cameras. + local view, saved = freeGround.view3d, e.facing + if view and view.worldToScreen then + e.facing = view:worldToScreen(saved) + end + freeGround:freeEntity((e.px or 0), (e.py or 0), cam.x, cam.y, rise, + function() e:draw(cam.x, cam.y + rise) end, + nil, require("src.world.Gen4SurfMount").poseFor(e, e == self.player and self.dwPose or nil)) + e.facing = saved + end + -- Deliberately without the flat shadow and the grass overdraw below: + -- both are drawn in screen space against a ground plane this camera + -- does not have, and a shadow in the wrong place is worse than none. + return + end + -- IN THE WORLD'S DEPTH on Sinnoh's cartridge camera + -- (Gen4Ground:beginSprites): the billboard origin's depth, so a prop in + -- front of a character hides them; nil leaves the draw as it was + local depth = spriteDepthGround and spriteDepthGround:billboardDepth( + (e.px or 0) + (e.shiftPx or 0), e.py or 0, + e.spriteOffset and e.spriteOffset.lift, e.spriteOffset and e.spriteOffset.oz) + -- UNDER the sprite, and lifted by the same terrain rise it is, so a + -- character on a slope keeps their feet on it. + if shadows then + if depth then + spriteDepthGround:withSpriteDepth(depth, function() + shadows:draw(self.map, e, cam.x, cam.y, e == self.player, riseOf(self, e, groundForRise, groundSin, cam)) + end, false) + else + shadows:draw(self.map, e, cam.x, cam.y, e == self.player, riseOf(self, e, groundForRise, groundSin, cam)) + end + end + if depth then + spriteDepthGround:withSpriteDepth(depth, function() e:draw(cam.x, cam.y + riseOf(self, e, groundForRise, groundSin, cam)) end) + else + e:draw(cam.x, cam.y + riseOf(self, e, groundForRise, groundSin, cam)) + end + -- tall grass overdraws the sprite's feet (GB sprite priority); + -- the overdraw is BG tiles, so it rides the shake offset too + love.graphics.setColor(1, 1, 1, 1) + if self.map:isGrassCell(e.cellX, e.cellY) then + if not self:drawGen3Grass(e.cellX, e.cellY, cam.x, bgY, + self:gen3GrassFrame(e, false)) then + self.map.renderer:drawCellBottom(e.cellX, e.cellY, cam.x, bgY) + if grassColors then + self.map.renderer:markCellBottomRedraw(e.cellX, e.cellY, + cam.x, bgY, grassColors) + end + end + end + if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then + if not self:drawGen3Grass(e.targetX, e.targetY, cam.x, bgY, + self:gen3GrassFrame(e, true)) then + self.map.renderer:drawCellBottom(e.targetX, e.targetY, cam.x, bgY) + if grassColors then + self.map.renderer:markCellBottomRedraw(e.targetX, e.targetY, + cam.x, bgY, grassColors) + end + end + end + end +end + +function OverworldState:_worldAim(renderer, ox, oy, feetX, feetZ) + local g4 = renderer and renderer.gen4Ground + if g4 then + if ox then g4.focusX, g4.focusZ = feetX - ox, feetZ - oy + else g4.focusX, g4.focusZ = nil, nil end + end +end + +-- Private cell columns never cross the public rippleCells seam. +local nativeRippleCells = OverworldState.rippleCells +function OverworldState:_worldAddCoverCell(pool, cx, cy) + local key = cx * 4096 + cy + if pool.seen[key] then return end + pool.seen[key] = true + local n = pool.n + 1 + pool.n, pool.x[n], pool.y[n] = n, cx, cy +end + +function OverworldState:_worldReflectionCover(reflectors, count, renderer, cam, bgY) + local pool = self._worldCover + if not pool then + pool = {x={}, y={}, seen={}, n=0} + self._worldCover = pool + elseif pool.busy then + pool = {x={}, y={}, seen={}, n=0} + end + pool.busy = true + local oldN = pool.n + pool.n = 0 + for key in pairs(pool.seen) do pool.seen[key] = nil end + for i = 1, count do + local cx0, cy0, cx1, cy1 = self:reflectionRect(reflectors[i]) + if cx0 then + for cy = cy0, cy1 do + for cx = cx0, cx1 do self:_worldAddCoverCell(pool, cx, cy) end + end + end + end + for _, r in ipairs(self.ripples or {}) do + for cy = math.floor(r.py / 16), math.floor((r.py + 15) / 16) do + for cx = math.floor(r.px / 16), math.floor((r.px + 15) / 16) do + self:_worldAddCoverCell(pool, cx, cy) + end + end + end + for i = pool.n + 1, oldN do pool.x[i], pool.y[i] = nil, nil end + for i = 1, pool.n do + renderer:drawReflectionCover(pool.x[i], pool.y[i], cam.x, bgY) + end + pool.busy = nil +end + +function OverworldState:_worldEntityLoop(drawEntity, cam, bgY, freeGround, groundForRise, + groundSin, grassColors, spriteDepthGround, shadows, riseOf) + local onTop + for _, e in ipairs(self.entities) do + if self:gen3AboveTopLayer(e) then + onTop = onTop or {} + onTop[#onTop + 1] = e + else + drawEntity(self, e, cam, bgY, freeGround, groundForRise, groundSin, + grassColors, spriteDepthGround, shadows, riseOf) + end + end + return onTop +end + function OverworldState:drawWorld() -- Dark-map BG shade shift, armed for the whole frame before anything draws. -- home/fade.asm's LoadGBPal writes ONE rBGP for the screen, so terrain, the @@ -16946,638 +17761,23 @@ function OverworldState:drawWorld() -- in their historical order. (Bodies are byte-identical to the pre-tilt -- inline code, so the flat draw sequence is unchanged.) - -- the Pokémon Center heal machine (PokeCenterOAMData): the monitor - -- tile over the machine's screen and one ball per healed mon in two - -- mirrored columns, all blinking during the jingle flash. The GB - -- draws it at fixed screen coords with the player's cell BG-aligned - -- at (64,64); anchoring those coords to where the player stood keeps - -- the overlay on the machine at any zoom. - local function fxHeal() - if not self.healAnim then return end - local ha = self.healAnim - -- HOENN'S MACHINE IS NOT AN OAM TABLE. - -- - -- Everything below this block is the Game Boy's: two 8x8 tiles out of - -- field.overworldFx.healMachine at coordinates a table in the ROM gives. - -- Emerald has no such table -- its machine is a field effect whose two - -- sprites are created by machine code, and where they go is four - -- immediates INSIDE that code. extractHealMachine reads them, and the - -- record it writes carries the positions already measured from the corner - -- of the player's own cell, so nothing here has to know where the - -- cartridge's camera puts anybody. - if GameVersion.isGen4() then return end - if self:drawGen3Heal(ha, cam) then return end - local fxDef = Game.data.field.overworldFx - if self.healMachineImg == nil and fxDef and fxDef.healMachine then - local ok, img = pcall(love.graphics.newImage, fxDef.healMachine.path) - self.healMachineImg = ok and img or false - end - local img = self.healMachineImg - if img then - if not self.healMachineQuads then - local w, h = img:getWidth(), img:getHeight() - self.healMachineQuads = { - love.graphics.newQuad(0, 0, 8, 8, w, h), -- monitor ($7c) - love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d) - } - end - -- The machine carries its OWN four colours. HealMachineAnim.LoadPalettes - -- (04:$6434) copies them over OBJ palette 6 before the animation runs -- - -- which is why every row of its OAM table names palette 6 -- and they are - -- white / light orange / red / black: a Poke Ball. Nothing was applying - -- any palette at all, so the sheet drew in the DMG greys it is decoded - -- in and a Pokemon Center healed with grey balls. - local def = fxDef and fxDef.healMachine - local objPal = def and def.gen2ObjPal - local basePal = (objPal and objPal[1] and objPal) or PaletteFX.GRAYS - -- the jingle flash recolors the machine sprites in place - -- (FlashSprite8Times XORs rOBP1; the sprites never disappear): - -- ha.visible == false is the flashed half of each beat, drawn with - -- the light/dark shades swapped instead of skipped - local shader - if (not ha.visible) or basePal ~= PaletteFX.GRAYS then - shader = PaletteFX.shader() - if shader then - PaletteFX.sendColors(shader, ha.visible and basePal - or PaletteFX.permute(basePal, HEAL_FLASH_MAP)) - love.graphics.setShader(shader) - end - end - -- TileRenderer windows with -floor(cam), so the overlay must use the - -- same snap or a fractional camera (odd fill/tilt view sizes) parks - -- the balls a pixel off the machine tiles - local ox = ha.px - 64 - math.floor(cam.x) - local oy = ha.py - 64 - math.floor(cam.y) - -- Gen2 puts these sprites somewhere else entirely. Its - -- HealMachineAnim.PC_ElmsLab_OAM (04:$63BC) has the monitor at x 26/30 - -- and the balls at x 24/32; Gen1's PokeCenterOAMData has the balls at - -- 40/48. The extractor reads that OAM table straight off the cartridge - -- into field.overworldFx.healMachine, so use it where it exists and keep - -- the Gen1 constants as the fallback -- drawn at Gen1's offsets the - -- whole overlay sat a full 16px right of the Gen2 machine. - local monitor = (def and def.monitor) or { { 44, 20 } } - local balls = (def and def.balls) or HEAL_BALL_XY - love.graphics.setColor(1, 1, 1, 1) - for _, m in ipairs(monitor) do - love.graphics.draw(img, self.healMachineQuads[1], ox + m[1], oy + m[2]) - end - for i = 1, math.min(ha.lit, #balls) do - local b = balls[i] - if b[3] then -- right column: OAM_XFLIP - love.graphics.draw(img, self.healMachineQuads[2], - ox + b[1] + 8, oy + b[2], 0, -1, 1) - else - love.graphics.draw(img, self.healMachineQuads[2], - ox + b[1], oy + b[2]) - end - end - if shader then love.graphics.setShader() end - end - end - - -- the Cut/boulder dust puff: the smoke tile drawn 2x2 over the cell, - -- flickering (AnimateBoulderDust XORs the OBJ palette every step) - local function fxDust() - if not self.dustAnim then return end - local fxDef = Game.data.field.overworldFx - local smoke = fxDef and fxDef.smoke - if smoke then - if self.smokeImg == nil then - local ok, img = pcall(love.graphics.newImage, smoke.path) - self.smokeImg = ok and img or false - end - if self.smokeImg then - local da = self.dustAnim - local dx = da.x * 16 - cam.x - local dy = da.y * 16 - cam.y - local flicker = math.floor(da.frames / 4) % 2 == 0 - love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55) - for i = 0, 1 do - for j = 0, 1 do - love.graphics.draw(self.smokeImg, dx + i * 8, dy + j * 8) - end - end - love.graphics.setColor(1, 1, 1, 1) - end - end - end - - -- FireRed's S.S. Anne departure uses two ordinary OBJ sprites that are not - -- object events: a looping 16x32 wake behind the ship and 16x16 smoke puffs - -- from its funnel. Special 401 owns their exact lifetime/positions; this - -- pass only turns that state into the extracted ROM frames. - local function ssAnneArt() - local def = Game.data.constants and Game.data.constants.gen3SSAnneFx - if not def then return nil end - if self.ssAnneWakeImg == nil then - local ok, img = pcall(Assets.image, def.wake) - self.ssAnneWakeImg = ok and img or false - end - if self.ssAnneSmokeImg == nil then - local ok, img = pcall(Assets.image, def.smoke) - self.ssAnneSmokeImg = ok and img or false - end - if self.ssAnneWakeImg and not self.ssAnneWakeQuads then - local w, h = self.ssAnneWakeImg:getDimensions() - self.ssAnneWakeQuads = { - love.graphics.newQuad(0, 0, 16, 32, w, h), - love.graphics.newQuad(16, 0, 16, 32, w, h), - } - end - if self.ssAnneSmokeImg and not self.ssAnneSmokeQuads then - local w, h = self.ssAnneSmokeImg:getDimensions() - self.ssAnneSmokeQuads = {} - for i = 0, 3 do - self.ssAnneSmokeQuads[i + 1] = love.graphics.newQuad(i * 16, 0, 16, 16, w, h) - end - end - return self.ssAnneWakeImg, self.ssAnneSmokeImg - end - - local function fxSSAnneWake() - local fx = self.ssAnneDepartureFx - if not (fx and fx.boat) then return end - local wake = ssAnneArt() - if not (wake and self.ssAnneWakeQuads) then return end - local age = math.min(fx.wakeAge or 0, 132) - local cx = fx.boat.px + (fx.boat.shiftPx or 0) + 8 - cam.x - + 80 + math.floor(age / 6) - if cx < -18 then return end - local frame = math.floor(age / 12) % 2 + 1 - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(wake, self.ssAnneWakeQuads[frame], - math.floor(cx - 8), 109 - 16) - end - - local function fxSSAnneSmoke() - local fx = self.ssAnneDepartureFx - if not (fx and fx.smoke and #fx.smoke > 0) then return end - local _, smoke = ssAnneArt() - if not (smoke and self.ssAnneSmokeQuads) then return end - love.graphics.setColor(1, 1, 1, 1) - for _, puff in ipairs(fx.smoke) do - local age = puff.age or 0 - local frame - if age < 10 then frame = 1 - elseif age < 30 then frame = 2 - elseif age < 50 then frame = 3 - else frame = 4 end - local cx = puff.x + math.floor(age / 4) - love.graphics.draw(smoke, self.ssAnneSmokeQuads[frame], - math.floor(cx - 8), 78 - 8) - end - end - - -- the watering can's water: droplets arcing down onto the soil, and a - -- sparkle on the last few frames as it soaks in - local function fxWater() - if not self.waterAnim then return end - local wa = self.waterAnim - local total = wa.total or 40 - local age = total - wa.frames - local dx = wa.x * 16 - cam.x - local dy = wa.y * 16 - cam.y - love.graphics.setColor(0.55, 0.80, 0.98, 0.95) - for i = 0, 5 do - -- each droplet falls on its own phase, so they read as a stream - local t = ((age * 3 + i * 7) % 24) / 24 - local px = dx + 2 + i * 2 + t * 10 - local py = dy - 6 + t * 20 - if py < dy + 16 then - love.graphics.rectangle("fill", px, py, 2, 3) - end - end - -- the soak: a brief bright ring once the water has been falling a while - if age > total * 0.55 then - local k = (age - total * 0.55) / (total * 0.45) - love.graphics.setColor(1, 1, 1, 0.8 * (1 - k)) - love.graphics.rectangle("fill", dx + 2, dy + 12, 12, 2) - end - love.graphics.setColor(1, 1, 1, 1) - end - - -- the cut tree splitting apart (AnimCut): top half slides right, - -- bottom half slides left, 1px per frame, flickering as they go - local function fxCutTree() - if not self.cutAnim then return end - local fxDef = Game.data.field.overworldFx - local tree = fxDef and fxDef.cutTree - if not tree then return end - if self.cutTreeImg == nil then - local ok, img = pcall(love.graphics.newImage, tree.path) - self.cutTreeImg = ok and img or false - end - local img = self.cutTreeImg - if not img then return end - if not self.cutTreeQuads then - local w, h = img:getWidth(), img:getHeight() - self.cutTreeQuads = { - love.graphics.newQuad(0, 0, 16, 8, w, h), -- top half - love.graphics.newQuad(0, 8, 16, 8, w, h), -- bottom half - } - end - local ca = self.cutAnim - local off = (ca.total or 8) - ca.frames - local dx = ca.x * 16 - cam.x - local dy = ca.y * 16 - cam.y - local flicker = ca.frames % 2 == 0 - love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55) - love.graphics.draw(img, self.cutTreeQuads[1], dx + off, dy) - love.graphics.draw(img, self.cutTreeQuads[2], dx - off, dy + 8) - love.graphics.setColor(1, 1, 1, 1) - end - - -- the "!" bubble above a trainer who spotted the player - -- the tile-anchored sparkle: an OAM sprite in the cartridge, so it rides - -- the camera the way a body does rather than the background's shake - local function fxSparkle() - love.graphics.setColor(1, 1, 1, 1) - self:drawSparkles(cam.x, cam.y) - end - - -- ...and ONE of them, for the two paths that cannot draw the set in screen - -- space: each sparkle sits on its own cell, so a projected scene has to - -- anchor each one separately. The upvalue is set immediately before each - -- call and cleared after, which is the same shape the emote path uses for - -- the one NPC it belongs to. - local sparkleOne = nil - local function fxSparkleOne() - local s = sparkleOne - if not s then return end - local sprite = self:sparkleSprite() - if not sprite then return end - local frame = self:sparkleFrame(s.clock) - if not frame then return end - love.graphics.setColor(1, 1, 1, 1) - sprite:drawFixedFrame(s.px, s.py + 4, cam.x, cam.y, frame) - end - - -- THE POKE RADAR'S PATCHES, one at a time like the sparkles - local radarOne = nil - local function fxRadarOne() - local pt = radarOne - if not pt then return end - require("src.world.Gen4Radar").drawPatch(pt, pt.x * 16 - cam.x, pt.y * 16 - cam.y, - love.timer and love.timer.getTime() * 60 or 0) - end - local function fxRadar() - for _, pt in ipairs((self.gen4Radar and self.gen4Radar.active and self.gen4Radar.patches) or {}) do - if pt.active then radarOne = pt; fxRadarOne() end - end - radarOne = nil - end - - local function fxEmote() - if not (self.emote and self.emote.npc) then return end - -- bubble = false is a silent hold (a Pikachu emotion that plays a - -- cry with no bubble still pauses the world for its beat) - if self.emote.bubble == false then return end - local npc = self.emote.npc - -- SINNOH'S OWN BUBBLE, ON TOP OF THE HEAD, IN THE GROUND'S PROJECTION. - -- - -- Reported from play: *"were missing the proper exclamation point above - -- trainers when they see you for battle and its not above their heads"*. - -- Platinum had neither art source below, so it fell to the hand-drawn box, - -- placed with flat 2D arithmetic that knows nothing of terrain height or - -- the tilt -- so it landed across the face. - -- - -- The cartridge (src/overlay005/ov5_021F5A10.c, see Gen4Emotes): a 16x16 - -- billboard from fldeff.narc whose plane rises 0..16 from an anchor 32 - -- units above the owner's drawn position -- the top of a 32-unit - -- character -- bouncing 6, 10, 12, 12, 10, 6, 0 and then holding. So the - -- bubble's bottom edge sits on the sprite's top edge, centred on it, and - -- it is drawn through exactly the transform the sprite is: the same - -- terrain rise, and `freeEntity` when a 3D camera owns the frame. - local g4emotes = Game.data and Game.data.gen4_emotes - local g4ground = self.map and self.map.renderer and self.map.renderer.gen4Ground - if g4emotes and g4ground and npc.sprite then - local Gen4Emotes = require("src.import.Gen4Emotes") - local kind = (self.emote.bubble == "double" or self.emote.bubble == 2) - and g4emotes.double and "double" or "exclamation" - -- BW's "?", note and "..." by name (Gen5MoveExec), when the cache has them - local named = self.emote.bubble - if type(named) == "string" and named ~= "double" and type(g4emotes[named]) == "table" then - kind = named - end - local record = g4emotes[kind] - self.gen4EmoteImages = self.gen4EmoteImages or {} - local img = self.gen4EmoteImages[kind] - if img == nil and record and record.rgba then - local okI, made = pcall(function() - local data = love.image.newImageData(record.width, record.height, - "rgba8", record.rgba) - local image = love.graphics.newImage(data) - image:setFilter("nearest", "nearest") - return image - end) - img = okI and made or false - self.gen4EmoteImages[kind] = img - end - if img then - local age = math.max(0, (self.emote.totalFrames or Gen4Emotes.FRAMES) - - (self.emote.frames or 0)) - -- HEARTGOLD / SOULSILVER: the cartridge's own anchor, 32 world units - -- over the owner through the camera now drawing, the player - -- included (src/world/Gen4EmoteDraw.lua); Platinum below, unchanged - local EmoteDraw = require("src.world.Gen4EmoteDraw") - if EmoteDraw.applies(self.map) then - EmoteDraw.draw(self, g4ground, npc, cam, record, img, age) - return - end - local bx, by, rise = OverworldState.gen4EmoteSpot(g4ground, npc, cam, - record, age) - local function draw() - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(img, bx, by) - end - local free = g4ground.freeMode and g4ground:freeMode() - if free then - -- Depth-tested where the cartridge's billboard actually is -- the - -- middle of a bubble 32 units up -- not at its trainer's feet. - g4ground:freeEntity(npc.px or 0, npc.py or 0, cam.x, cam.y, rise, draw, - Gen4Emotes.LIFT + 8) - else - draw() - end - return - end - end - -- WHERE IT HANGS, which the cartridge states outright. - -- - -- Reported from play: the bubble "should appear above the trainers head". - -- It was four pixels right of them and six low, which on a 16x32 walker - -- put it across the face rather than over it. - -- - -- The icon's own sprite callback (0B4724) copies the owner's position - -- every frame and offsets it by exactly one thing: - -- - -- icon->x = owner->x - -- icon->y = owner->y - 16 - -- - -- and a GBA sprite's x/y is its CENTRE (the corner comes from - -- centerToCornerVec), so this is "the icon's centre sits sixteen pixels - -- above the owner's" -- which is a different top-left offset for a 16x16 - -- object than for a 16x32 one, and is why one number could never be right - -- for both. Stated as centres it falls out the same for either: the x - -- offsets cancel whatever the sprite's width, and the y is the owner's - -- half-height less the icon's eight plus the sixteen. - local half = math.floor(((npc.sprite and npc.sprite.tileH) or 16) / 2) - local ex = npc.px - cam.x - local ey = npc.py - cam.y - 12 - half - local bubble = Game.data.field.emotionBubbles - local drawn = false - - -- HOENN'S OWN BUBBLE, which is not on the Game Boy's sheet. - -- - -- The "!" over a trainer's head is three 16x16 icons hanging off a sprite - -- template that only a field-effect script reaches (see extractEmotes) -- - -- which is why every sweep of the field-effect tables missed it. A Gen 3 - -- dataset that has them draws them; one that does not falls through to - -- the Game Boy sheet and then to the box below, exactly as before. - local icons = (Game.data.constants or {}).gen3Emotes - if icons then - local role = GEN3_EMOTE_ROLES[self.emote.bubble or 1] or "exclamation" - local record = icons[role] - local path = record and record.image - if path then - self.gen3EmoteImages = self.gen3EmoteImages or {} - local held = self.gen3EmoteImages[path] - if held == nil then - local okImg, img = pcall(require("src.render.Assets").image, path) - held = (okImg and img) or false - self.gen3EmoteImages[path] = held - end - if held then - local age = math.max(0, (self.emote.totalFrames or 60) - - self.emote.frames) - local frame = 0 - local left = age - for _, step in ipairs(record.timeline or {}) do - local duration = tonumber(step[2]) or 1 - if left < duration then - frame = tonumber(step[1]) or 0 - break - end - left = left - duration - frame = tonumber(step[1]) or frame - end - -- SpriteCB_TrainerIcons starts data[3] at -5, accumulates it into - -- data[4], and increments that velocity until the offset returns 0. - local bounce, velocity = 0, -5 - for _ = 1, math.min(age + 1, 11) do - bounce = bounce + velocity - if bounce ~= 0 then velocity = velocity + 1 else velocity = 0 end - end - local fw, fh = tonumber(record.width) or 16, tonumber(record.height) or 16 - local iw, ih = held:getDimensions() - local q = nil - if (tonumber(record.frames) or 1) > 1 then - self.gen3EmoteQuads = self.gen3EmoteQuads or {} - local key = path .. ":" .. tostring(frame) - q = self.gen3EmoteQuads[key] - if not q then - q = love.graphics.newQuad(frame * fw, 0, fw, fh, iw, ih) - self.gen3EmoteQuads[key] = q - end - end - love.graphics.setColor(1, 1, 1, 1) - if q then love.graphics.draw(held, q, ex, ey + bounce) - else love.graphics.draw(held, ex, ey + bounce) end - drawn = true - end - end - end - if not drawn and bubble and bubble.path then - local ok, img = pcall(function() - self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path) - return self.emoteImg - end) - -- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command - -- picks question/happy crops instead - local bi = self.emote.bubble or 1 - local rect = bubble.bubbles and bubble.bubbles[bi] - if ok and img and rect then - love.graphics.setColor(1, 1, 1, 1) - -- one Quad per bubble crop, cached: this draws every frame the "!" - -- (or the emote-command crops) is up, so a fresh Quad here churned - -- the GC. The bubble set is small and fixed, so the cache is bounded. - self.emoteQuads = self.emoteQuads or {} - local q = self.emoteQuads[bi] - if not q then - q = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h, - img:getDimensions()) - self.emoteQuads[bi] = q - end - love.graphics.draw(img, q, ex, ey) - drawn = true - end - end - if not drawn then - local Font = require("src.render.Font") - love.graphics.setColor(1, 1, 1, 1) - love.graphics.rectangle("fill", ex, ey, 10, 12) - love.graphics.setColor(0, 0, 0, 1) - love.graphics.rectangle("line", ex + 0.5, ey + 0.5, 10, 12) - Font.draw("!", ex + 1, ey + 2) - love.graphics.setColor(1, 1, 1, 1) - end - end - - -- the FLY bird sweeping off with the player - local function fxBird() - if not self.flyAnim then return end - -- FireRed has a dedicated five-frame 64x64 field-effect bird. Frames 1 - -- and 3 already contain Red/Leaf riding it during fly-out, so use the - -- cartridge sheet directly instead of substituting the selected mon's - -- battle front sprite. - local frlg = Game.data.constants and Game.data.constants.gen3FRLGFlyBird - if frlg and frlg.path then - if self.flyBirdImg == nil then - local ok, img = pcall(love.graphics.newImage, frlg.path) - self.flyBirdImg = ok and img or false - end - if self.flyBirdImg then - local gender = ((Game.save or {}).player or {}).gender == "girl" - and "girl" or "boy" - local frame = ((frlg.flyOut or {})[gender]) or (gender == "girl" and 3 or 1) - local fw = frlg.frameWidth or 64 - local fh = frlg.frameHeight or 64 - self.flyBirdQuads = self.flyBirdQuads or {} - if not self.flyBirdQuads[frame] then - local iw, ih = self.flyBirdImg:getDimensions() - self.flyBirdQuads[frame] = love.graphics.newQuad( - 0, frame * fh, fw, fh, iw, ih) - end - local t = math.max(0, math.min(48, 48 - self.flyAnim.frames)) - local reach = 16 - local dx, dy - if t <= reach then - local k = 1 - t / reach - dx, dy = -96 * k, -72 * k - else - local k = (t - reach) / (48 - reach) - dx, dy = -120 * k * k, -96 * k * k - end - local px = math.floor(self.player.px - cam.x + 8 - fw / 2 + dx) - local py = math.floor(self.player.py - cam.y + 8 - fh / 2 + dy) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(self.flyBirdImg, self.flyBirdQuads[frame], px, py) - require("src.render.PaletteFX").markTrueColor(px, py, fw, fh) - return - end - end - local birdId = FieldDefaults.fieldValue(Game.data, "playerSprites", "fly") - if not self.birdSprite and birdId and Game.data.sprites[birdId] then - local SR = require("src.render.SpriteRenderer") - self.birdSprite = SR.new(Game.data.sprites[birdId]) - end - if self.birdSprite then - local t = 48 - self.flyAnim.frames - local bx = self.player.px - t * 4 - local by = self.player.py - math.floor(t * 1.5) - love.graphics.setColor(1, 1, 1, 1) - self.birdSprite:draw(bx, by, cam.x, cam.y, "left", - math.floor(t / 4) % 2, false) - return - end - - -- Legacy fallback for datasets that predate the ROM-extracted FireRed - -- bird above. Keep it for old caches/other games, but current FireRed - -- imports should never reach this branch. - -- - -- Reported from play, twice: "ensure the animation of the flying type - -- pokemon swooping up my player plays after the fly hm transition" and - -- then "Theres still no fly swoop". The machinery above has been here the - -- whole time and has never had a sprite to draw: it wants - -- playerSprites.fly, and no Gen 3 cache carries one. - -- - -- IT WAS LOOKED FOR PROPERLY THIS TIME, and it is not where a sheet like - -- that would be. Emerald's fly bird is not an object-event graphics row - -- -- the three 64x64 rows in that table are Rayquaza twice and the cable - -- car, and every 32x32 row is a person or a legendary -- and it is not - -- hanging off any field-effect script either: not one of the sixty-seven - -- scripts loads tiles, and no native any of them calls reaches a sprite - -- template with frames that size. It is loaded by C, from a sheet only - -- the fly task names, and nothing the import can currently follow gets to - -- it. - -- - -- So the departure is drawn with something this port certainly does have - -- and the cartridge's own bird is not: the FRONT SPRITE OF THE POKEMON - -- THAT IS ACTUALLY CARRYING YOU -- the same one the field-move sweep just - -- showed. It stoops in from the upper left, meets the player, and lifts - -- away with them. Reconstructed, and said so; the moment the bird IS - -- found, playerSprites.fly makes the branch above win again and this is - -- never reached. - local mon = self.flyAnim.mon - if not mon then return end - if self.flyMonImg == nil then - local okPath, path = pcall(function() - return require("src.pokemon.Sprites").path( - Game.data, mon.species, "front", { mon = mon }) - end) - local img - if okPath and type(path) == "string" then - local okImg, loaded = pcall(love.graphics.newImage, path) - img = okImg and loaded or nil - end - self.flyMonImg = img or false - end - if not self.flyMonImg then return end - local total = 48 - local t = math.max(0, math.min(total, total - self.flyAnim.frames)) - -- the stoop: in over sixteen frames, away over the rest, and the two - -- meet on the player rather than anywhere else - local reach = 16 - local dx, dy - if t <= reach then - local k = 1 - t / reach - dx, dy = -96 * k, -72 * k - else - local k = (t - reach) / (total - reach) - dx, dy = -120 * k * k, -96 * k * k - end - local iw, ih = self.flyMonImg:getDimensions() - local px = math.floor(self.player.px - cam.x + 8 - iw / 2 + dx) - local py = math.floor(self.player.py - cam.y + 8 - ih / 2 + dy) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(self.flyMonImg, px, py) - require("src.render.PaletteFX").markTrueColor(px, py, iw, ih) - end - - -- fishing pose: the rod tile over the faced water (gfx/fishing.asm) - local function fxRod() - if not self.fishing then return end - local fx = Game.data.field.overworldFx - local rod = fx and fx.fishingRod - if rod then - if self.rodImg == nil then - local ok, img = pcall(love.graphics.newImage, rod.path) - self.rodImg = ok and img or false - end - if self.rodImg then - local p = self.player - local oam = ROD_OAM[self.fishing.facing] or ROD_OAM.down - if not self.rodQuads then - -- one quad per 8x8 tile of the stacked sheet (ROD_OAM.tile) - local iw, ih = self.rodImg:getDimensions() - self.rodQuads = {} - for i = 0, math.floor(ih / 8) - 1 do - self.rodQuads[i] = love.graphics.newQuad(0, i * 8, 8, 8, iw, ih) - end - end - local quad = self.rodQuads[oam.tile] - -- the sprite's top-left is 4px above its cell (SpriteRenderer:draw) - local rx = p.px - cam.x + oam.dx - local ry = p.py - cam.y - 4 + oam.dy - love.graphics.setColor(1, 1, 1, 1) - if quad and oam.flip then - love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1) - elseif quad then - love.graphics.draw(self.rodImg, quad, rx, ry) - end - end - end - end + local sparkleOne, radarOne + local boundFx = not Performance.worldDrawHoist or pipelineId or tilt + local fxHeal = boundFx and bindWorldFx(self._worldFxHeal, self, cam) or self._worldFxHeal + local fxDust = boundFx and bindWorldFx(self._worldFxDust, self, cam) or self._worldFxDust + local fxSSAnneWake = boundFx and bindWorldFx(self._worldFxSSAnneWake, self, cam) or self._worldFxSSAnneWake + local fxSSAnneSmoke = boundFx and bindWorldFx(self._worldFxSSAnneSmoke, self, cam) or self._worldFxSSAnneSmoke + local fxWater = boundFx and bindWorldFx(self._worldFxWater, self, cam) or self._worldFxWater + local fxCutTree = boundFx and bindWorldFx(self._worldFxCutTree, self, cam) or self._worldFxCutTree + local fxSparkle = boundFx and bindWorldFx(self._worldFxSparkle, self, cam) or self._worldFxSparkle + local fxSparkleOne + if boundFx then fxSparkleOne = function() return self:_worldFxSparkleOne(cam, sparkleOne) end end + local fxRadarOne + if boundFx then fxRadarOne = function() return self:_worldFxRadarOne(cam, radarOne) end end + local fxRadar = boundFx and bindWorldFx(self._worldFxRadar, self, cam) or self._worldFxRadar + local fxEmote = bindWorldFx(self._worldFxEmote, self, cam) + local fxBird = boundFx and bindWorldFx(self._worldFxBird, self, cam) or self._worldFxBird + local fxRod = boundFx and bindWorldFx(self._worldFxRod, self, cam) or self._worldFxRod if pipelineId then -- === PIPELINE PATH: a mod owns the world pass. ====================== @@ -17703,8 +17903,8 @@ function OverworldState:drawWorld() -- The S.S. Anne effects are authored in screen OAM coordinates for this -- fixed-camera cutscene. Keep them as an upright overlay for a custom -- world pipeline; the flat/tilt paths place the wake behind the ship. - fxSSAnneWake() - fxSSAnneSmoke() + fxSSAnneWake(self, cam) + fxSSAnneSmoke(self, cam) for _, sp in ipairs(self.sparkles or {}) do sparkleOne = sp at(fxSparkleOne, sp.px + 8, sp.py + 16) @@ -17761,19 +17961,9 @@ function OverworldState:drawWorld() and groundForRise:freeMode()) and groundForRise or nil local groundSin = groundForRise and groundForRise.scale and select(1, groundForRise:scale()) or 1 - local function riseOf(e) - if not groundForRise then return 0 end - local rise = groundForRise:rise(e.px + (e.shiftPx or 0) + 8, e.py + 8) - -- A SPRITE OFFSET (MapObject_SetSpritePosOffset: the surf mount's ride, - -- the Distortion World cast's flights) in the field view: up by the - -- camera's height lean, toward it by the ground's - local so = e.spriteOffset - if so and (so.lift or so.oz) then - rise = rise + (so.lift or 0) * (groundForRise.heightScale or 0) - - (so.oz or 0) * (groundForRise.groundScale or 1) - end - if groundSin >= 1 then return rise end - return (e.py - cam.y) * (1 - groundSin) + rise + local riseOf = self._worldRise + if not Performance.worldDrawHoist then + riseOf = function(_self, e) return self:_worldRise(e, groundForRise, groundSin, cam) end end if override then @@ -17865,7 +18055,6 @@ function OverworldState:drawWorld() -- ...AND A PLOT'S ANSWER CHANGES WHILE YOU ARE STANDING THERE. A tree -- is planted, grows and is picked without the map reloading, so its -- emptiness is asked at draw time rather than remembered at spawn. - local plotEmpty = plotIsEmpty -- the ground whose depth the entity pass is drawing into, while it is local spriteDepthGround = nil -- SINNOH'S CHARACTERS STAND ON SOMETHING. Built once per frame rather @@ -17877,98 +18066,11 @@ function OverworldState:drawWorld() shadows = require("src.render.Gen4Shadows").forMap(Game.data) or false self.gen4Shadows = shadows end - local function drawEntity(e) - if not (self.flyAnim and self:hasFlyBird() and e == self.player) - and not e.hidden and not e.berryVisualEmpty and not plotEmpty(e) then - -- A FREE CAMERA PLACES ITS OWN CHARACTERS. - -- - -- Reported from play: *"isnt keeping the player the right size"*, with a - -- screenshot of a third-person Twinleaf in correct perspective and two - -- characters standing in it at flat 2D scale. They were drawn by the - -- line below, which places every sprite at `py - camY` -- the field - -- camera's 1:1 mapping, and simply not this camera's. - -- - -- `freeEntity` runs THIS SAME DRAW inside a transform, so nothing here - -- learns about projection and the entity keeps owning its own sprite, - -- frame and palette. - -- - -- In FIRST person the player is the eye, so drawing them puts their own - -- back across the middle of the view. - if freeGround then - if not (freeGround:freeMode() == "first" and e == self.player) then - local rise = riseOf(e) - -- ...AND WHICH WAY THEY FACE ON SCREEN. - -- - -- Reported from play: *"when i orbit the camera 180 degrees if i - -- walk left it looks like hes walking right"*. `screenToWorld` - -- turns the PRESS into a world direction, so the step is right; - -- the sprite then showed that world facing under a camera that is - -- no longer looking north, so the artwork was the one thing still - -- in the old frame. `worldToScreen` is its inverse, and the pair - -- are inverses because the camera sits between them. - -- - -- Set and restored around the draw rather than threaded through, - -- because `facing` is what every pose, animation and follower path - -- in the entity reads, and none of them should learn about cameras. - local view, saved = freeGround.view3d, e.facing - if view and view.worldToScreen then - e.facing = view:worldToScreen(saved) - end - freeGround:freeEntity((e.px or 0), (e.py or 0), cam.x, cam.y, rise, - function() e:draw(cam.x, cam.y + rise) end, - nil, require("src.world.Gen4SurfMount").poseFor(e, e == self.player and self.dwPose or nil)) - e.facing = saved - end - -- Deliberately without the flat shadow and the grass overdraw below: - -- both are drawn in screen space against a ground plane this camera - -- does not have, and a shadow in the wrong place is worse than none. - return - end - -- IN THE WORLD'S DEPTH on Sinnoh's cartridge camera - -- (Gen4Ground:beginSprites): the billboard origin's depth, so a prop in - -- front of a character hides them; nil leaves the draw as it was - local depth = spriteDepthGround and spriteDepthGround:billboardDepth( - (e.px or 0) + (e.shiftPx or 0), e.py or 0, - e.spriteOffset and e.spriteOffset.lift, e.spriteOffset and e.spriteOffset.oz) - -- UNDER the sprite, and lifted by the same terrain rise it is, so a - -- character on a slope keeps their feet on it. - if shadows then - if depth then - spriteDepthGround:withSpriteDepth(depth, function() - shadows:draw(self.map, e, cam.x, cam.y, e == self.player, riseOf(e)) - end, false) - else - shadows:draw(self.map, e, cam.x, cam.y, e == self.player, riseOf(e)) - end - end - if depth then - spriteDepthGround:withSpriteDepth(depth, function() e:draw(cam.x, cam.y + riseOf(e)) end) - else - e:draw(cam.x, cam.y + riseOf(e)) - end - -- tall grass overdraws the sprite's feet (GB sprite priority); - -- the overdraw is BG tiles, so it rides the shake offset too - love.graphics.setColor(1, 1, 1, 1) - if self.map:isGrassCell(e.cellX, e.cellY) then - if not self:drawGen3Grass(e.cellX, e.cellY, cam.x, bgY, - self:gen3GrassFrame(e, false)) then - self.map.renderer:drawCellBottom(e.cellX, e.cellY, cam.x, bgY) - if grassColors then - self.map.renderer:markCellBottomRedraw(e.cellX, e.cellY, - cam.x, bgY, grassColors) - end - end - end - if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then - if not self:drawGen3Grass(e.targetX, e.targetY, cam.x, bgY, - self:gen3GrassFrame(e, true)) then - self.map.renderer:drawCellBottom(e.targetX, e.targetY, cam.x, bgY) - if grassColors then - self.map.renderer:markCellBottomRedraw(e.targetX, e.targetY, - cam.x, bgY, grassColors) - end - end - end + local drawEntity = self._worldDrawEntity + if not Performance.worldDrawHoist then + drawEntity = function(_self, e) + return self:_worldDrawEntity(e, cam, bgY, freeGround, groundForRise, groundSin, + grassColors, spriteDepthGround, shadows, riseOf) end end @@ -18023,16 +18125,25 @@ function OverworldState:drawWorld() -- rectangle each, which is precisely what a scissor is. It is set per -- cell and intersected with whatever clip the caller already had, so a -- reflection is painted once per water cell it falls across. - local reflectors = {} + local reflectors = Performance.worldDrawHoist and self._worldReflectors or nil + if not reflectors or reflectors.busy then + reflectors = {n=0} + if Performance.worldDrawHoist and not self._worldReflectors then self._worldReflectors = reflectors end + end + reflectors.busy = true + local oldReflectors, reflectorCount = reflectors.n, 0 for _, e in ipairs(self.entities) do if not e.hidden and e.drawReflection then e.reflects = self:reflectionSearch(e) if e.reflects then e.reflectStill = self:stillReflection(e.reflects) - reflectors[#reflectors + 1] = e + reflectorCount = reflectorCount + 1 + reflectors[reflectorCount] = e end end end + for i = reflectorCount + 1, oldReflectors do reflectors[i] = nil end + reflectors.n = reflectorCount self.reflectProbe = (self.reflectProbe or 0) + 1 if self.reflectProbe % 90 == 1 then local p, set = self.player, Game and Game.data and Game.data.constants @@ -18122,6 +18233,9 @@ function OverworldState:drawWorld() love.graphics.setColor(1, 1, 1, 1) local renderer = self.map.renderer if renderer.drawReflectionCover then + if Performance.worldDrawHoist and self.rippleCells == nativeRippleCells then + self:_worldReflectionCover(reflectors, reflectorCount, renderer, cam, bgY) + else local seen, cells = {}, {} for _, e in ipairs(reflectors) do local cx0, cy0, cx1, cy1 = self:reflectionRect(e) @@ -18141,15 +18255,17 @@ function OverworldState:drawWorld() for _, cell in ipairs(cells) do renderer:drawReflectionCover(cell[1], cell[2], cam.x, bgY) end + end end end + reflectors.busy = nil -- The rotating gates sit under the entity pass; the S.S. Anne wake has -- priority below the ship and therefore belongs immediately before it. self:drawGen3Gates(cam) -- OBJ priority 2 and a later sprite id put the wake behind the ship at the -- same priority on hardware. Draw it immediately before the entity pass. - fxSSAnneWake() + fxSSAnneWake(self, cam) local onTop = nil -- SINNOH'S CARTRIDGE CAMERA: the characters go into the world's own depth -- buffer (Gen4Ground:beginSprites) so props in front of them hide them @@ -18158,18 +18274,28 @@ function OverworldState:drawWorld() spriteDepthGround = groundForRise end local closeEntities = FrameProfile.section(" world: entities") + if Performance.worldDrawHoist then + local okE, result = pcall(self._worldEntityLoop, self, drawEntity, cam, bgY, freeGround, + groundForRise, groundSin, grassColors, spriteDepthGround, shadows, riseOf) + if not okE then + if spriteDepthGround then spriteDepthGround:endSprites(); spriteDepthGround = nil end + error(result, 0) + end + onTop = result + else for _, e in ipairs(self.entities) do if self:gen3AboveTopLayer(e) then onTop = onTop or {} onTop[#onTop + 1] = e else - local okE, errE = pcall(drawEntity, e) + local okE, errE = pcall(drawEntity, self, e, cam, bgY, freeGround, groundForRise, groundSin, grassColors, spriteDepthGround, shadows, riseOf) if not okE then if spriteDepthGround then spriteDepthGround:endSprites(); spriteDepthGround = nil end error(errE, 0) end end end + end if spriteDepthGround then spriteDepthGround:endSprites() spriteDepthGround = nil @@ -18186,11 +18312,10 @@ function OverworldState:drawWorld() -- not the player at every zoom. Cleared after, so a stale focus can never -- outlive the frame it was true for. local feetX, feetZ = (self.player.px or 0) + 8, (self.player.py or 0) + 8 - local function aim(renderer, ox, oy) - local g4 = renderer and renderer.gen4Ground - if g4 then - if ox then g4.focusX, g4.focusZ = feetX - ox, feetZ - oy - else g4.focusX, g4.focusZ = nil, nil end + local aim = self._worldAim + if not Performance.worldDrawHoist then + aim = function(_self, renderer, ox, oy) + return self:_worldAim(renderer, ox, oy, feetX, feetZ) end end -- THE "!" GOES IN WHILE A FREE CAMERA'S 3D PASS IS STILL OPEN. @@ -18207,28 +18332,28 @@ function OverworldState:drawWorld() emoteEarly = true end local closeTiles = FrameProfile.section(" world: tiles") - aim(self.map.renderer, 0, 0) + aim(self, self.map.renderer, 0, 0, feetX, feetZ) self.map.renderer:drawAbove(cam.x, bgY, vw, vh) - aim(self.map.renderer) + aim(self, self.map.renderer, nil, nil, feetX, feetZ) for _, nb in ipairs(self.neighbors) do if nb.map.renderer.drawAbove then - aim(nb.map.renderer, nb.ox, nb.oy) + aim(self, nb.map.renderer, nb.ox, nb.oy, feetX, feetZ) nb.map.renderer:drawAbove(cam.x - nb.ox, bgY - nb.oy, vw, vh) - aim(nb.map.renderer) + aim(self, nb.map.renderer, nil, nil, feetX, feetZ) end end closeTiles() closeEntities = FrameProfile.section(" world: entities") - for _, e in ipairs(onTop or {}) do drawEntity(e) end + for _, e in ipairs(onTop or {}) do drawEntity(self, e, cam, bgY, freeGround, groundForRise, groundSin, grassColors, spriteDepthGround, shadows, riseOf) end closeEntities() local closeFx = FrameProfile.section(" world: FX") - fxHeal() - fxDust() - fxCutTree() - fxWater() + fxHeal(self, cam) + fxDust(self, cam) + fxCutTree(self, cam) + fxWater(self, cam) if not emoteEarly then self:eachEmote(fxEmote) end - fxRadar() + fxRadar(self, cam) -- BLACK / WHITE: the phenomenon spot and the rod's lure (Gen5Phenomena) if GameVersion.isGen5() then local g5ground = self.map and self.map.renderer and self.map.renderer.gen4Ground @@ -18238,11 +18363,11 @@ function OverworldState:drawWorld() end -- HGSS: the Dowsing MCHN's reading (src/world/HgssDowsing.lua) if self.hgssDowsing then require("src.world.HgssDowsing").draw(self, vw, vh) end - fxSparkle() - fxBird() - fxRod() + fxSparkle(self, cam) + fxBird(self, cam) + fxRod(self, cam) -- Smoke keeps the template's priority 0, above the ship and map layers. - fxSSAnneSmoke() + fxSSAnneSmoke(self, cam) closeFx() else -- === TILT PATH: ground-hugging FX stay on the projected ground, all @@ -18254,10 +18379,10 @@ function OverworldState:drawWorld() -- layers are separate and composited ground-under-upright, so drawing -- them now into the still-active ground canvas is order-equivalent. local closeFx = FrameProfile.section(" world: FX") - fxHeal() - fxDust() - fxCutTree() - fxWater() + fxHeal(self, cam) + fxDust(self, cam) + fxCutTree(self, cam) + fxWater(self, cam) -- ...and the gates with them: they are behind every upright sprite (see -- drawGen3Gates), so the ground canvas is exactly where they belong self:drawGen3Gates(cam) @@ -18265,7 +18390,7 @@ function OverworldState:drawWorld() Game.renderer:beginUprightPass() - fxSSAnneWake() + fxSSAnneWake(self, cam) -- One y-sorted list of ALL upright billboards -- sprites (player, NPCs, -- ghosts) -- keyed on baseline world y (the foot / base row). Farther @@ -18298,7 +18423,7 @@ function OverworldState:drawWorld() local fy = e.py - cam.y + 16 local colors = zoneColorsAt(zones, fx, fy) self:billboard(fx, fy, vw, vh, colors, false, - function() e:draw(cam.x, cam.y + riseOf(e)) end) + function() e:draw(cam.x, cam.y + riseOf(self, e, groundForRise, groundSin, cam)) end) -- tall-grass feet overdraw glued to the sprite: same anchor + depth -- so it keeps hiding the feet, color-0-keyed palette so its white -- gaps still show the sprite through (drawCellBottomRaw lets the @@ -18341,7 +18466,7 @@ function OverworldState:drawWorld() local fy = self.player.py - cam.y + 16 self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod) end - fxSSAnneSmoke() + fxSSAnneSmoke(self, cam) for _, sp in ipairs(self.sparkles or {}) do sparkleOne = sp local fx = sp.px - cam.x + 8 diff --git a/tests/engine/world_draw_hot.lua b/tests/engine/world_draw_hot.lua new file mode 100644 index 000000000..a80c4a2dc --- /dev/null +++ b/tests/engine/world_draw_hot.lua @@ -0,0 +1,135 @@ +local T = require("tests.harness") +love = require("tests.love_stub") +local Version = require("src.core.GameVersion") +local Performance = require("src.core.Performance") +local World = require("src.world.OverworldController") +local Fixture = require("tests.world_draw.fixture") +local originalPcall = pcall +for _, version in ipairs({ "red", "gold", "emerald", "platinum" }) do + Version.set(version) + local f = Fixture.new(32) + local world = f.world + Performance.worldDrawHoist = false + world:drawWorld() + f.reset() + local beforeCalls = 0 + _G.pcall = function(...) + beforeCalls = beforeCalls + 1 + return originalPcall(...) + end + world:drawWorld() + _G.pcall = originalPcall + local before = f.trace() + f.reset() + Performance.worldDrawHoist = true + local afterCalls = 0 + _G.pcall = function(...) + afterCalls = afterCalls + 1 + return originalPcall(...) + end + world:drawWorld() + _G.pcall = originalPcall + T.same(f.trace(), before, version .. " keeps tile/reflection/entity/top-layer/FX draw order and coordinates") + T.check(beforeCalls > afterCalls, version .. " reduces protected calls for the entity pass") + T.eq(afterCalls, 1, version .. " has one protected ordinary entity loop") + local list, cover = world._worldReflectors, world._worldCover + world:drawWorld() + T.eq(world._worldReflectors, list, "reflector list is private and reused") + T.eq(world._worldCover, cover, "parallel cover columns are private and reused") + world.entities = { world.player } + world.ripples = {} + world:drawWorld() + T.eq(list.n, 0, "shrinking frame clears reflector count") + T.eq(list[1], nil, "shrinking frame releases stale entity references") +end +Version.set("red") +local f = Fixture.new(3) +local world = f.world +local retained = {} +world.rippleCells = function(self, out, seen) + retained[#retained + 1] = out + T.check(out ~= self._worldCover, "public replacement never receives a private pool") + return World.rippleCells(self, out, seen) +end +world:drawWorld() +world:drawWorld() +T.check(retained[1] ~= retained[2], "public ripple wrapper keeps fresh cell records") +T.check(retained[1][1] ~= retained[2][1], "public cell pairs remain detached") +local ended = 0 +world.map.renderer.gen4Ground = { + rise = function() + return 0 + end, + billboardDepth = function() + return nil + end, + beginSprites = function() + return true + end, + endSprites = function() + ended = ended + 1 + end, +} +world.player.draw = function() + error("synthetic entity failure") +end +local ok, err = pcall(World.drawWorld, world) +T.eq(ok, false, "entity errors are propagated") +T.check(tostring(err):find("synthetic entity failure", 1, true) ~= nil, "original entity error remains visible") +T.eq(ended, 1, "sprite depth pass closes exactly once on failure") +-- Nested cover drawing must not overwrite the outer borrowed columns. +local nested = false +local visited = {} +world.rippleCells = World.rippleCells +world.map.renderer.drawReflectionCover = function(_, cx, cy) + visited[#visited + 1] = cx .. ":" .. cy + if not nested then + nested = true + world:_worldReflectionCover({}, 0, { drawReflectionCover = function() end }, world.camera, 0) + end +end +world:_worldReflectionCover({ world.entities[2] }, 1, world.map.renderer, world.camera, 0) +T.same( + visited, + { "2:3", "3:3", "2:4", "3:4", "0:1", "1:1", "0:2", "1:2" }, + "nested cover work cannot clobber the outer columns" +) +-- A pipeline may retain its no-argument FX callbacks after a later frame. +local P = require("src.render.Pipelines") +local saved = + { worldPipeline = P.worldPipeline, drawWorld = P.drawWorld, worldPresent = P.worldPresent, level = P.level } +local frames = {} +P.worldPipeline = function() + return "synthetic", {} +end +P.drawWorld = function(_, ctx) + frames[#frames + 1] = ctx + return {} +end +P.worldPresent = function(canvas) + return canvas +end +P.level = function() + return 1 +end +local scene = Fixture.new(2) +scene.world:drawWorld() +scene.world.camera = { x = 67, y = 5 } +scene.world:drawWorld() +T.check(frames[1] ~= frames[2] and frames[1].fx ~= frames[2].fx, "public pipeline frames and FX tables remain detached") +T.check(frames[1].fx.water ~= frames[2].fx.water, "public FX callbacks keep their own frame binding") +local positions = {} +local rectangle = love.graphics.rectangle +love.graphics.rectangle = function(_, x) + positions[#positions + 1] = x +end +frames[1].fx.water() +local oldX = positions[1] +positions = {} +frames[2].fx.water() +T.eq(oldX - positions[1], 64, "retained zero-argument callback keeps the original camera") +love.graphics.rectangle = rectangle +for name, value in pairs(saved) do + P[name] = value +end +T.finish("world draw hot paths") diff --git a/tests/world_draw/fixture.lua b/tests/world_draw/fixture.lua new file mode 100644 index 000000000..d7f8f773c --- /dev/null +++ b/tests/world_draw/fixture.lua @@ -0,0 +1,118 @@ +-- Owned synthetic draw scene: no ROM, profile, asset extraction or game progress. +local F = {} +function F.new(count, options) + local World = options and options.worldModule or require("src.world.OverworldController") + local Game = require("src.core.Game") + -- The normal enter lifecycle supplies this shared engine reference. The + -- isolated rendering fixture has no map loader or save lifecycle to enter. + for index = 1, 100 do + local name = debug.getupvalue(World.drawWorld, index) + if not name then + break + end + if name == "Game" then + debug.setupvalue(World.drawWorld, index, Game) + break + end + end + Game.data = { field = { overworldFx = {} }, constants = {}, sprites = {} } + Game.save = { options = {} } + local trace = {} + local function mark(name, ...) + if options and options.quiet then + return + end + local row = { name, ... } + for i = 1, #row do + row[i] = tostring(row[i]) + end + trace[#trace + 1] = table.concat(row, ":") + end + local renderer = {} + for _, name in ipairs({ "drawBorderFill", "draw", "drawMapOnly", "drawAbove", "drawReflectionCover" }) do + renderer[name] = function(_, ...) + mark(name, ...) + end + end + local world = setmetatable({ + camera = { x = 3, y = 5 }, + map = { + id = "SYNTHETIC", + renderer = renderer, + isGrassCell = function() + return false + end, + }, + neighbors = {}, + ghosts = {}, + entities = {}, + gen4Shadows = false, + waterAnim = { x = 1, y = 1, frames = 12, total = 40 }, + ripples = { { px = 15, py = 31, clock = 0 } }, + }, { __index = World }) + for i = 1, count or 3 do + local e = { + px = i * 16, + py = i * 16, + cellX = i, + cellY = i, + facing = "down", + above = i % 7 == 0, + draw = function(self, cx, cy) + mark("entity", self.cellX, cx, cy) + end, + drawReflection = function(self, cx, cy) + mark("reflection", self.cellX, cx, cy) + end, + } + world.entities[i] = e + end + world.player = world.entities[1] + world.reflectionSearch = function(_, e) + return e.cellX % 2 == 0 and { { e.cellX, e.cellY + 1 } } or nil + end + world.stillReflection = function() + return false + end + world.reflectionRect = function(_, e) + return e.cellX, e.cellY + 1, e.cellX + 1, e.cellY + 2 + end + world.gen3AboveTopLayer = function(_, e) + return e.above + end + world.drawRipples = function(_, cx, cy) + mark("ripples", cx, cy) + end + world.drawGen3Gates = function() + mark("gates") + end + world.drawGen3Flash = function() + mark("flash") + end + world.drawGen3Decorate = function() + mark("decoration") + end + world.placeFreeCamera = function() end + world.reflectProbe, world.reflectProbe2 = 1, 1 + Game.renderer = { + worldViewSize = function() + return 160, 144 + end, + fitScale = function() + return 1 + end, + setWorldOverride = function() end, + } + return { + world = world, + game = Game, + trace = function() + return trace + end, + reset = function() + trace = {} + end, + mark = mark, + } +end +return F diff --git a/tools/check_frame_pacing.py b/tools/check_frame_pacing.py index 2f8a694a6..a46da9de5 100644 --- a/tools/check_frame_pacing.py +++ b/tools/check_frame_pacing.py @@ -17,6 +17,7 @@ def main(): workload = parser.add_mutually_exclusive_group() workload.add_argument('--gc', action='store_true', help='compare fixed-step GC with the P2 idle budget') workload.add_argument('--hot-path', action='store_true', help='compare actual native update/draw dependency dispatch') + workload.add_argument('--world-draw', action='store_true', help='compare native world entity/FX dispatch over synthetic content') args = parser.parse_args() root = Path(__file__).resolve().parents[1] sys.path.insert(0, str(args.qa_tools or root / 'tools')) @@ -34,6 +35,11 @@ def main(): if args.hot_path: files += ('src/core/Game.lua', 'src/core/HotModules.lua', 'src/core/Performance.lua', 'src/render/Renderer.lua', 'src/render/PaletteFX.lua', 'tools/frame-pacing-check/hot.lua') + if args.world_draw: + files += ('src/core/Game.lua', 'src/core/Performance.lua', 'src/core/HotModules.lua', + 'src/world/OverworldController.lua', 'src/render/Renderer.lua', + 'src/render/PaletteFX.lua', 'src/render/Pipelines.lua', + 'tests/world_draw/fixture.lua', 'tools/frame-pacing-check/world.lua') hashes = {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in files} source = qa_run(['git', 'rev-parse', 'HEAD'], cwd=root, text=True, capture_output=True, check=True).stdout.strip() @@ -45,10 +51,11 @@ def main(): cwd=root, env=dict(os.environ, POKEPORT_IDENTITY=identity, FRAME_PACING_REPO=str(root), FRAME_PACING_QA_GUARDED='1', FRAME_PACING_GC='1' if args.gc else '0', - FRAME_PACING_HOT='1' if args.hot_path else '0'), + FRAME_PACING_HOT='1' if args.hot_path else '0', + FRAME_PACING_WORLD='1' if args.world_draw else '0'), capture_output=True, text=True, timeout=90) args.report.with_suffix('.log').write_text(result.stdout + result.stderr) - prefix = 'P3_NATIVE_RESULT ' if args.hot_path else 'P2_NATIVE_RESULT ' if args.gc else 'P1_NATIVE_RESULT ' + prefix = 'P4_NATIVE_RESULT ' if args.world_draw else 'P3_NATIVE_RESULT ' if args.hot_path else 'P2_NATIVE_RESULT ' if args.gc else 'P1_NATIVE_RESULT ' records = [line.removeprefix(prefix) for line in result.stdout.splitlines() if line.startswith(prefix)] if result.returncode or len(records) != 1: raise RuntimeError('native pacing probe failed; see ' + str(args.report.with_suffix('.log'))) diff --git a/tools/frame-pacing-check/main.lua b/tools/frame-pacing-check/main.lua index 282369558..8f689eaf0 100644 --- a/tools/frame-pacing-check/main.lua +++ b/tools/frame-pacing-check/main.lua @@ -12,6 +12,10 @@ if os.getenv("FRAME_PACING_HOT") == "1" then require("tools.frame-pacing-check.hot") return end +if os.getenv("FRAME_PACING_WORLD") == "1" then + require("tools.frame-pacing-check.world") + return +end local Cap = require("src.core.FrameCap") local Profile = require("src.core.FrameProfile") local Json = require("src.link.Json") diff --git a/tools/frame-pacing-check/world.lua b/tools/frame-pacing-check/world.lua new file mode 100644 index 000000000..6b4bd3ff5 --- /dev/null +++ b/tools/frame-pacing-check/world.lua @@ -0,0 +1,138 @@ +local Version = require("src.core.GameVersion") +Version.set("red") +local Renderer = require("src.render.Renderer") +local Performance = require("src.core.Performance") +local Cap = require("src.core.FrameCap") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local scene = require("tests.world_draw.fixture").new(64, { quiet = true }) +local world, graphics = scene.world, love.graphics +scene.game.renderer = Renderer +Renderer:init() +world.map.renderer.drawBorderFill = function(_, cx, cy, w, h) + graphics.setColor(0.2, 0.4, 0.5, 1) + graphics.rectangle("fill", 0, 0, w, h) +end +world.map.renderer.draw = function() + graphics.setColor(0.3, 0.5, 0.4, 1) + for y = 0, 8 do + for x = 0, 9 do + graphics.rectangle("fill", x * 16, y * 16, 14, 14) + end + end +end +world.map.renderer.drawReflectionCover = function(_, cx, cy, camX, camY) + graphics.setColor(0.2, 0.4, 0.5, 0.3) + graphics.rectangle("fill", cx * 16 - camX, cy * 16 - camY, 16, 16) +end +for i, e in ipairs(world.entities) do + e.px, e.py = (i % 10) * 16, math.floor(i / 10) * 16 + e.cellX, e.cellY = math.floor(e.px / 16), math.floor(e.py / 16) + e.draw = function(actor, cx, cy) + graphics.setColor(0.8, 0.3, 0.2, 1) + graphics.rectangle("fill", actor.px - cx, actor.py - cy, 8, 12) + end + e.drawReflection = function(actor, cx, cy) + graphics.setColor(0.8, 0.3, 0.2, 0.5) + graphics.rectangle("fill", actor.px - cx, actor.py - cy + 16, 8, 12) + end +end +local phase, frame, deadline, started, cpuStarted = 1, 0, 0, 0, 0 +local WINDOW, WARMUP = 120, 120 +local drawTotal, drawMax, protectedCalls, heapStart = 0, 0, 0, 0 +local results = {} +local _, _, mode = love.window.getMode() +Cap.current = tonumber(mode.refreshrate) or 60 +if Cap.current <= 0 then + Cap.current = 60 +end +Cap.vsyncPacing = true +local function draw() + local close = Profile.section("draw: whole frame") + Renderer:beginFrame(true) + Renderer:beginWorldPass() + world:drawWorld() + Renderer:endWorldPass() + Renderer:endFrame() + close() + Profile.frame() +end +local function startPhase() + Profile.setEnabled(false) + Performance.worldDrawHoist = phase == 2 + collectgarbage("restart") + frame, drawTotal, drawMax = 0, 0, 0 + deadline = love.timer.getTime() +end +local function finishPhase() + results[phase] = { + enabled = Performance.worldDrawHoist, + frames = WINDOW, + seconds = love.timer.getTime() - started, + drawMeanMs = drawTotal / WINDOW * 1000, + drawMaxMs = drawMax * 1000, + processCpuMsPerFrame = (os.clock() - cpuStarted) / WINDOW * 1000, + protectedCalls = protectedCalls, + heapGrowthKiB = collectgarbage("count") - heapStart, + } + collectgarbage("restart") + if phase == 2 then + print( + "P4_NATIVE_RESULT " + .. Json.encode({ + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + renderer = graphics.getRendererInfo(), + refresh = mode.refreshrate, + vsync = love.window.getVSync(), + cap = Cap.current, + entities = #world.entities, + phases = results, + scope = "Native drawWorld/entity/reflection/FX loops with synthetic 64-actor rectangles; no ROM assets or mod scene; GC stopped during each measured window", + }) + ) + return true + end + phase = 2 + startPhase() +end +function love.run() + startPhase() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + local original = pcall + protectedCalls = 0 + _G.pcall = function(...) + protectedCalls = protectedCalls + 1 + return original(...) + end + draw() + _G.pcall = original + collectgarbage("collect") + collectgarbage("stop") + Profile.setEnabled(true) + heapStart = collectgarbage("count") + started, cpuStarted = love.timer.getTime(), os.clock() + end + local before = love.timer.getTime() + draw() + if frame >= WARMUP then + local elapsed = love.timer.getTime() - before + drawTotal, drawMax = drawTotal + elapsed, math.max(drawMax, elapsed) + end + local close = Profile.section("present (GPU wait / vsync)") + graphics.present() + close() + deadline = Cap.pace(love.timer, love.window, true, deadline) + frame = frame + 1 + if frame == WARMUP + WINDOW and finishPhase() then + return 0 + end + end +end From 8fea1351c63bb248963603157169e92b2f39245c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:57:49 +0100 Subject: [PATCH 076/138] Expose native phone list display hook and preserve deletion identity --- docs/native-gen2-phone-book.md | 4 +- docs/native-gen2-phone-list-hook.md | 33 ++++++++ src/ui/PokegearMenu.lua | 30 ++++++- tests/engine/gen2_phone_list_hook.lua | 117 ++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 docs/native-gen2-phone-list-hook.md create mode 100644 tests/engine/gen2_phone_list_hook.lua diff --git a/docs/native-gen2-phone-book.md b/docs/native-gen2-phone-book.md index 825fb775b..e5d1868ca 100644 --- a/docs/native-gen2-phone-book.md +++ b/docs/native-gen2-phone-book.md @@ -27,8 +27,8 @@ relocated permanent identities and visible slot-ten dispatch. This establishes model/VM/UI behavior, not a hardware phone call or original story acceptance. The existing content-bridge tests also check ten-slot native consumption. -The public legacy Phone facade, phone.contact_list display hook, full special -call/rematch contracts, new authored phone scripts/forward references, and +The display hook is covered separately in [native-gen2-phone-list-hook.md](native-gen2-phone-list-hook.md). +The public legacy Phone facade, full special call/rematch contracts, new authored phone scripts/forward references, and fresh-import/GPU/story acceptance remain open. Full Gen1 parity is unproven. Original implementation by Roxas2712; existing upstream/third-party rights and diff --git a/docs/native-gen2-phone-list-hook.md b/docs/native-gen2-phone-list-hook.md new file mode 100644 index 000000000..da8fbf286 --- /dev/null +++ b/docs/native-gen2-phone-list-hook.md @@ -0,0 +1,33 @@ +# Native phone list display hook + +Gold, Silver and Crystal now dispatch `phone.contact_list(save, list)` when +the native Pokegear reads its contacts. The signature follows the read-only +Gen1 reference at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, +`src/core/gen2/Phone.lua`: the save remains an opaque native handle and the +list is a detached array of ten numeric contact IDs, with zero for blanks. +Native API 1/2 share this signature. Other generations do not dispatch this +native Pokegear hook. + +Mods can reorder or hide display rows and show contacts registered through +`phone_contacts`. A return value of the wrong type or length retains the +native display. Invalid, fractional, non-finite and unknown contact IDs +become blanks without shifting slots. Mutating the display list does not +register contacts, reorder the saved phone book or filter incoming callers. + +Each displayed contact retains its actual physical slot. The native DELETE +action therefore removes the selected contact even after display reordering; +an injected non-member cannot delete an unrelated saved slot. Unsubscribing, +failed-entry rollback and loader teardown restore the native presentation. + +Validation:focused checks use the actual Loader, content registry, +Pokegear contacts/input and incoming selector across all three editions and +two native API levels. They cover invalid returns, detached arrays, +saved-book isolation, reordered deletion, injected non-members, rollback and +other-generation dispatch isolation. This is synthetic behavioral +acceptance; no new hardware phone-call or original-story claim is made. + +The complete legacy Phone facade, special-call/rematch contracts, authored +phone scripts/forward references and full story acceptance remain open. + +Original contributions by Roxas2712; existing reference, upstream and +third-party rights and credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index 490c87acb..011f6f2af 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -5,6 +5,7 @@ local Theme = require("src.ui.Theme") local Screens = require("src.ui.Screens") local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") +local Runtime = require("src.mods.Runtime") local PokegearMenu = {} PokegearMenu.__index = PokegearMenu @@ -404,10 +405,31 @@ function PokegearMenu:contacts() if require("src.core.GameVersion").isGen2() then local Book = require("src.world.Gen2PhoneBook") local Calls = require("src.script.Gen2Commands") + local slots = Book.contacts(save) + if Runtime.wantsHook("phone.contact_list") then + -- Save is an opaque native handle; the display list is detached. The + -- incoming selector continues to use the unhooked physical book. + local shown = Runtime.call("phone.contact_list", function(_, list) return list end, + save, Book.contacts(save)) + if type(shown) == "table" and #shown == Book.SIZE then + slots = {} + for i = 1, Book.SIZE do + local value = shown[i] + local id = (type(value) == "number" or type(value) == "string") and tonumber(value) + slots[i] = id and id % 1 == 0 and id >= 1 and id <= 255 + and Calls.phoneContact(data, id) and id or 0 + end + end + end + local physical = Book.contacts(save) local permanent, out = Book.permanent(data), {} - for slot, id in ipairs(Book.contacts(save)) do + for displaySlot, id in ipairs(slots) do local name = Calls.phoneName(data,id) - out[slot] = { id=name and id or 0, slot=slot, name=name or "----------", + local slot = id == 0 and displaySlot or nil + if id ~= 0 then + for i, nativeId in ipairs(physical) do if nativeId == id then slot = i; break end end + end + out[displaySlot] = { id=name and id or 0, slot=slot, name=name or "----------", permanent=permanent[id] == true } end return out @@ -555,7 +577,9 @@ function PokegearMenu:update(dt) self.callText = nil if entry and deleting then if require("src.core.GameVersion").isGen2() then - require("src.world.Gen2PhoneBook").deleteAt(self.game.save, entry.slot) + local Book = require("src.world.Gen2PhoneBook") + if entry.slot then Book.deleteAt(self.game.save, entry.slot) + else Book.remove(self.game.save, entry.id) end else require("src.script.Gen2Commands").phoneList(self.game.save)[entry.id] = nil end diff --git a/tests/engine/gen2_phone_list_hook.lua b/tests/engine/gen2_phone_list_hook.lua new file mode 100644 index 000000000..b1f0c959b --- /dev/null +++ b/tests/engine/gen2_phone_list_hook.lua @@ -0,0 +1,117 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-phone-list-hook.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local Gear=require('src.ui.PokegearMenu');local Book=require('src.world.Gen2PhoneBook') +local Calls=require('src.script.Gen2Commands') +local function fixture() + local d=T.fixtures.fresh();d.constants.phoneContactOrder={'PHONE_00','PHONE_MOM','U2','U3','PHONE_ELM','U5','U6','PHONE_A','U8','PHONE_B'} + d.map_scripts={phoneInfo={size=10},scripts={STUB={{'end'}}},phone={ + [1]={name='MOM',contactNumber=1},[4]={name='ELM',contactNumber=4}, + [7]={name='A',receive='STUB',receiveTime=7,group=1,number=1}, + [9]={name='B',receive='STUB',receiveTime=7,group=1,number=1}}} + return d +end +local function load(d,source,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots()) do d[root]=nil end + local files={} + if source then + files['mods/phone_hook/manifest.json']=Json.encode({id='phone_hook',name='Phone list',version='1.0.0', + api=mode and mode[1] or 2,content_format=mode and mode[2] or 'native',entry='main.lua',affects_link=false}) + files['mods/phone_hook/main.lua']='local mod=...;mod.exports.api=mod;'..source + end + return T.sdk.loadMods(source and {'mods/phone_hook'} or {},{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +for _,version in ipairs({'gold','silver','crystal'}) do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}}) do + local d=fixture();local r=load(d,[[ + local added=mod.content.phone_contacts:register('PHONE_NEW',{name='AUTHORED NEW'}) + mod.exports.index=added.index + mod.exports.stop=mod.hooks:wrap("phone.contact_list",function(next,save,list) + mod.exports.calls=(mod.exports.calls or 0)+1;mod.exports.save=save;mod.exports.received=list + local rows=next(save,list);rows[1]=9;rows[2]=0;rows[3]='7';rows[10]=added.index + return rows + end) + ]],mode) + local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' real public hook registration succeeds') + local save={g2Phone={[7]=true,[9]=true},g2PhoneSlots={7,9,0,0,0,0,0,0,0,0}} + local gear=setmetatable({game={data=d,save=save}},{__index=Gear}) + local rows=gear:contacts();local e=r.loader.exports.phone_hook + T.eq(e.calls,1,label..' native card dispatches the hook exactly once') + T.eq(e.save,save,label..' opaque native save handle retains reference signature') + T.eq(#rows,10,label..' presentation retains ten slots') + T.eq(rows[1].id,9,label..' display hook can reorder native contacts') + T.eq(rows[1].slot,2,label..' reordered row retains its actual physical slot') + T.eq(rows[2].id,0,label..' display hook can hide a row') + T.eq(rows[3].id,7,label..' numeric contact spellings resolve through native records') + T.eq(rows[3].slot,1,label..' reordered selected ID does not acquire a different native slot') + T.eq(rows[10].name,'AUTHORED NEW',label..' hook sees actual native content registration') + T.eq(rows[10].slot,nil,label..' an injected display row cannot borrow another physical slot') + T.eq(save.g2Phone[e.index],nil,label..' showing a contact does not register it') + T.eq(Book.contacts(save)[1],7,label..' saved registration order remains native') + local incoming=Calls.availableCallers(d,save,{group=2,number=1},'DAY') + T.eq(incoming[1],7,label..' incoming caller selection ignores display reorder') + T.eq(incoming[2],9,label..' hidden display contact remains eligible for native incoming calls') + T.eq(e.calls,1,label..' incoming call selection does not dispatch the display hook') + e.received[1]=255;rows[1].name='READ MUTATION' + T.eq(Book.contacts(save)[1],7,label..' received display arrays cannot mutate the native book') + T.eq(d.map_scripts.phone[9].name,'B',label..' returned UI rows cannot mutate native contacts') + local key;gear.cards={{id='PHONE'}};gear.index=1;gear.blink=0;gear.contact=1 + gear.game.input={wasPressed=function(_,value)return key==value end};gear.game.stack={pop=function()end} + local Sound=require('src.core.Sound');local old=Sound.play;Sound.play=function()end + key='a';gear:update(0);key='down';gear:update(0);key='a';gear:update(0) + T.eq(save.g2Phone[9],nil,label..' actual DELETE targets the reordered contact ID') + T.eq(save.g2Phone[7],true,label..' display position cannot delete the unrelated physical contact') + gear.contact=1;key='a';gear:update(0);key='down';gear:update(0);key='a';gear:update(0) + T.eq(save.g2Phone[7],true,label..' deleting an injected non-member never consumes another native slot') + Sound.play=old + e.stop();rows=gear:contacts() + T.eq(rows[1].id,7,label..' unsubscribe restores the actual native presentation') + T.eq(rows[2].id,0,label..' unsubscribe does not recreate the deleted contact') + finish(r) + end +end +V.set('crystal') +for _,expression in ipairs({'nil','false','"bad"','{9}','{9,7,0,0,0,0,0,0,0,0,0}'}) do + local d=fixture();local r=load(d,'mod.hooks:wrap("phone.contact_list",function()return '..expression..' end)') + T.eq(#r.errors,0,'invalid display return does not prevent entry loading') + local save={g2Phone={[7]=true}};local rows=setmetatable({game={data=d,save=save}},{__index=Gear}):contacts() + T.eq(rows[1].id,7,'wrong return type/length keeps the real native display: '..expression) + T.eq(#rows,10,'wrong return type/length keeps native slot count') + finish(r) +end +do + local d=fixture();local r=load(d,[[mod.hooks:wrap("phone.contact_list",function() + return {999,-1,1.5,0/0,math.huge,{},true,'7','bad',9} + end)]]) + local save={g2Phone={[7]=true,[9]=true}} + local rows=setmetatable({game={data=d,save=save}},{__index=Gear}):contacts() + for i=1,7 do T.eq(rows[i].id,0,'invalid callback contact becomes a blank at slot'..i) end + T.eq(rows[8].id,7,'valid numeric string still resolves') + T.eq(rows[9].id,0,'invalid text becomes a blank') + T.eq(rows[10].id,9,'valid last native contact is retained') + T.eq(save.g2Phone[7],true,'sanitization preserves native registration') + finish(r) +end +do + local d=fixture();local r=load(d,[[mod.hooks:wrap("phone.contact_list",function()return {9,0,0,0,0,0,0,0,0,0}end);error('entry failure')]]) + T.check(#r.errors>0,'failed entry is reported') + local rows=setmetatable({game={data=d,save={g2Phone={[7]=true}}}},{__index=Gear}):contacts() + T.eq(rows[1].id,7,'failed-entry rollback removes its phone hook') + finish(r) +end +for _,version in ipairs({'red','firered','emerald','heartgold'}) do + V.set(version);local d=fixture();d.field.phone={contacts={{id=99,name='LEGACY'}}} + local r=load(d,[[mod.exports.calls=0 + mod.hooks:wrap("phone.contact_list",function(next,...)mod.exports.calls=mod.exports.calls+1;return next(...)end)]]) + T.eq(#r.errors,0,version..' hook registration remains available without a native call site') + local rows=setmetatable({game={data=d,save={}}},{__index=Gear}):contacts() + T.eq(rows[1].name,'LEGACY',version..' native-looking phone records do not replace authored presentation') + T.eq(r.loader.exports.phone_hook.calls,0,version..' cannot dispatch a Gen2-only display hook') + finish(r) +end +V.set(original) +T.finish('native phone.contact_list hook, actual card/delete, saved-book isolation and rollback') From a2e3034f88dee3c1169e12a194d8729d85b0535f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:00:38 +0100 Subject: [PATCH 077/138] Preserve native phone caller byte selection and RNG consumption --- docs/native-gen2-phone-call-selection.md | 24 +++++++ src/script/Gen2Commands.lua | 14 +++- tests/engine/gen2_phone_call_selection.lua | 78 ++++++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 docs/native-gen2-phone-call-selection.md create mode 100644 tests/engine/gen2_phone_call_selection.lua diff --git a/docs/native-gen2-phone-call-selection.md b/docs/native-gen2-phone-call-selection.md new file mode 100644 index 000000000..18b995104 --- /dev/null +++ b/docs/native-gen2-phone-call-selection.md @@ -0,0 +1,24 @@ +# Native incoming caller selection + +The native incoming-call path now consumes one unsigned random byte for +ChooseRandomCaller, swaps its nibbles, masks to five bits and reduces it by +the eligible caller count. Previously it drew a uniform Lua list index, +which changed the native byte-to-contact mapping. Candidates retain saved +phone-slot order, independently of the display hook. + +The timer expiry remains consumed before the coin flip; service and empty +book gates consume no caller-choice byte. A successful choice still compiles +and executes the selected contact's imported receive program and resets the +receive delay. The entrance gate leaves both the timer and RNG untouched. + +The algorithm and gate order were compared with the read-only reference +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, +`src/core/gen2/Phone.lua` (ChooseRandomCaller and CheckPhoneCall). +Synthetic tests exhaust all256 byte values for each book size1..10 against +an independent bitwise oracle and exercise actual native call selection, +Gen2ScriptVM and ScriptRunner in Gold/Silver/Crystal. This does not establish +complete radio/phone parity, a hardware call or original-story acceptance. + +Original contributions by Roxas2712; existing reference, upstream and +third-party rights and credits remain applicable. See NOTICE.md and +LICENSE-ROXAS2712.md. diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index 8a1f08387..60e494371 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2122,7 +2122,7 @@ function Gen2Commands.phoneCallDelay(save) return GEN2_CALL_DELAYS[math.min(cycles, #GEN2_CALL_DELAYS - 1) + 1] end --- The sample GetAvailableCallers builds, in PHONE_* order. +-- The sample GetAvailableCallers builds, in saved phone-slot order. function Gen2Commands.availableCallers(data, save, mapDef, tod) local bit = GEN2_TOD_BIT[tod] or 2 local out = {} @@ -2142,6 +2142,16 @@ function Gen2Commands.availableCallers(data, save, mapDef, tod) return out end +-- ChooseRandomCaller swaps one Random byte's nibbles, masks to five bits, +-- then takes the remainder against the eligible count. Preserve that exact +-- mapping and byte consumption rather than drawing a uniform list index. +function Gen2Commands.chooseRandomCaller(callers, rng) + if not callers or #callers == 0 then return nil end + local byte = (rng and rng() or math.random(0, 255)) % 256 + local swapped = (byte % 16) * 16 + math.floor(byte / 16) + return callers[(swapped % 32) % #callers + 1] +end + -- Returns the caller id and its compiled receive script, or nil. `minutes` is -- the play clock in minutes, which stands in for the ROM's day/hour/minute -- countdown; `entrance` is CheckStandingOnEntrance. @@ -2189,7 +2199,7 @@ function Gen2Commands.rollIncomingCall(data, save, mapDef, tod, minutes, entranc if math.floor(palette / 16) % 16 ~= 0 then return nil end local callers = Gen2Commands.availableCallers(data, save, mapDef, tod) if #callers == 0 then return nil end - local id = callers[math.random(#callers)] + local id = Gen2Commands.chooseRandomCaller(callers) local script = Gen2Commands.phoneReceiveScript(data, id) if not script then return nil end -- InitCallReceiveDelay at the tail of Script_ReceivePhoneCall (36:$42B7): diff --git a/tests/engine/gen2_phone_call_selection.lua b/tests/engine/gen2_phone_call_selection.lua new file mode 100644 index 000000000..e89503baa --- /dev/null +++ b/tests/engine/gen2_phone_call_selection.lua @@ -0,0 +1,78 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Calls=require('src.script.Gen2Commands') +local V=require('src.core.GameVersion');local original=V.get() +local bit=require('bit') +local consumed=0 +local function unused()consumed=consumed+1;return 0 end +T.eq(Calls.chooseRandomCaller(nil,unused),nil,'missing candidate list has no caller') +T.eq(Calls.chooseRandomCaller({},unused),nil,'empty candidate list has no caller') +T.eq(consumed,0,'empty selection consumes no random byte') +for n=1,10 do + local callers={};for i=1,n do callers[i]=100+i end + for byte=0,255 do + local used=0 + local got=Calls.chooseRandomCaller(callers,function()used=used+1;return byte end) + -- Independent bytewise SWAP / AND $1f / division remainder oracle. + local swapped=bit.band(bit.bor(bit.lshift(byte,4),bit.rshift(byte,4)),255) + local expected=callers[bit.band(swapped,31)%n+1] + T.eq(got,expected,'native byte mapping n='..n..' byte='..byte) + T.eq(used,1,'native choice consumes exactly one byte') + end +end +local old=math.random +for _,version in ipairs({'gold','silver','crystal'}) do + V.set(version) + local data={map_scripts={scripts={},phone={}}} + local ids={9,7,11} + for i,id in ipairs(ids) do + local label='NATIVE_RECEIVE_'..version..'_'..id + data.map_scripts.phone[id]={receive=label,receiveTime=2,group=2,number=1} + data.map_scripts.scripts[label]={{'setval',80+i},{'end'}} + end + local function save() + return {g2LastCallMinute=0,g2Phone={[9]=true,[7]=true,[11]=true}, + g2PhoneSlots={9,7,11,0,0,0,0,0,0,0}} + end + local map={group=1,number=1,phoneService=true} + local function randomBytes(bytes) + local used=0 + math.random=function(a,b) + T.eq(a,0,'phone RNG requests an unsigned byte lower bound') + T.eq(b,255,'phone RNG requests an unsigned byte upper bound') + used=used+1;assert(bytes[used]~=nil,'unexpected phone RNG draw') + return bytes[used] + end + return function()return used end + end + -- Byte $10 swaps to $01, choosing slot two, not sorted numeric ID order. + local draws=randomBytes({0,16});local s=save() + local id,rows=Calls.rollIncomingCall(data,s,map,'DAY',30,false) + T.eq(id,7,version..' actual incoming path selects the native saved-slot sample') + T.eq(draws(),2,version..' gate and caller choice consume one byte each') + T.eq(s.g2CallCycles,0,version..' accepted call resets the receive cycle') + T.eq(s.g2LastCallMinute,30,version..' accepted call restarts at the actual minute') + local runner=require('src.script.ScriptRunner').new({data=data,save=s},{}) + runner:run(rows) + T.eq(runner.ctx.g2Var,82,version..' selected native receive program actually executes') + draws=randomBytes({128});s=save() + T.eq(Calls.rollIncomingCall(data,s,map,'DAY',30,false),nil,version..' failed coin flip suppresses choice') + T.eq(draws(),1,version..' failed coin flip consumes no caller-choice byte') + T.eq(s.g2CallCycles,1,version..' failed coin flip still consumes timer expiry') + draws=randomBytes({0});s=save() + T.eq(Calls.rollIncomingCall(data,s,{phoneService=false},'DAY',30,false),nil,version..' missing service suppresses choice') + T.eq(draws(),1,version..' missing service consumes no caller-choice byte') + draws=randomBytes({0});s=save();s.g2Phone={};s.g2PhoneSlots={} + T.eq(Calls.rollIncomingCall(data,s,map,'DAY',30,false),nil,version..' empty native book suppresses choice') + T.eq(draws(),1,version..' empty native book consumes no caller-choice byte') + draws=randomBytes({});s=save() + T.eq(Calls.rollIncomingCall(data,s,map,'DAY',30,true),nil,version..' entrance blocks before timer and RNG') + T.eq(draws(),0,version..' entrance consumes no random bytes') + T.eq(s.g2LastCallMinute,0,version..' entrance preserves pending timer') + draws=randomBytes({});s=save() + T.eq(Calls.rollIncomingCall(data,s,map,'DAY',1,false),nil,version..' unexpired timer suppresses gate and selection') + T.eq(draws(),0,version..' unexpired timer consumes no random bytes') +end +math.random=old;V.set(original) +T.finish('native phone caller byte selection, saved order, timer gates and actual receive programs') From cef8bebd1af77eb815e89d425b925f52ce566804 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:03:10 +0100 Subject: [PATCH 078/138] Link upstream caller selection contribution permission --- docs/native-gen2-phone-call-selection.md | 3 +-- tests/engine/gen2_phone_call_selection.lua | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/native-gen2-phone-call-selection.md b/docs/native-gen2-phone-call-selection.md index 18b995104..a380ac381 100644 --- a/docs/native-gen2-phone-call-selection.md +++ b/docs/native-gen2-phone-call-selection.md @@ -20,5 +20,4 @@ Gen2ScriptVM and ScriptRunner in Gold/Silver/Crystal. This does not establish complete radio/phone parity, a hardware call or original-story acceptance. Original contributions by Roxas2712; existing reference, upstream and -third-party rights and credits remain applicable. See NOTICE.md and -LICENSE-ROXAS2712.md. +third-party rights and credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/tests/engine/gen2_phone_call_selection.lua b/tests/engine/gen2_phone_call_selection.lua index e89503baa..56834198f 100644 --- a/tests/engine/gen2_phone_call_selection.lua +++ b/tests/engine/gen2_phone_call_selection.lua @@ -1,4 +1,4 @@ --- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-phone-call-selection.md. package.path='./?.lua;./?/init.lua;'..package.path local T=require('tests.modkit') local Calls=require('src.script.Gen2Commands') From b77b27bb5ce20ab3c4b7fde11cfb8a3d65a3fe07 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:04:28 +0100 Subject: [PATCH 079/138] Exercise upstream native no-service header in caller selection regression --- tests/engine/gen2_phone_call_selection.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/engine/gen2_phone_call_selection.lua b/tests/engine/gen2_phone_call_selection.lua index 56834198f..fbf148c8a 100644 --- a/tests/engine/gen2_phone_call_selection.lua +++ b/tests/engine/gen2_phone_call_selection.lua @@ -61,7 +61,7 @@ for _,version in ipairs({'gold','silver','crystal'}) do T.eq(draws(),1,version..' failed coin flip consumes no caller-choice byte') T.eq(s.g2CallCycles,1,version..' failed coin flip still consumes timer expiry') draws=randomBytes({0});s=save() - T.eq(Calls.rollIncomingCall(data,s,{phoneService=false},'DAY',30,false),nil,version..' missing service suppresses choice') + T.eq(Calls.rollIncomingCall(data,s,{phoneService=false,mapPalette=0x10},'DAY',30,false),nil,version..' missing service suppresses choice') T.eq(draws(),1,version..' missing service consumes no caller-choice byte') draws=randomBytes({0});s=save();s.g2Phone={};s.g2PhoneSlots={} T.eq(Calls.rollIncomingCall(data,s,map,'DAY',30,false),nil,version..' empty native book suppresses choice') From 00f7faf5eba13134926817d81266a653a311a443 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:14:14 +0100 Subject: [PATCH 080/138] Emit native incoming call notification at actual random and special admission --- docs/native-gen2-phone-received-event.md | 38 ++++++ src/script/Gen2Commands.lua | 20 +++ src/world/OverworldController.lua | 2 + tests/engine/gen2_phone_received_event.lua | 147 +++++++++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 docs/native-gen2-phone-received-event.md create mode 100644 tests/engine/gen2_phone_received_event.lua diff --git a/docs/native-gen2-phone-received-event.md b/docs/native-gen2-phone-received-event.md new file mode 100644 index 000000000..5f745476d --- /dev/null +++ b/docs/native-gen2-phone-received-event.md @@ -0,0 +1,38 @@ +# Native incoming phone notification + +Gold, Silver and Crystal now emit `phone.call_received` after the actual +overworld controller queues an admitted incoming call. Both random calls and +queued special calls use this boundary. Rejected candidates, outgoing calls +and other generations do not emit it. The callback is an observation, not a +veto; returning false or editing its descriptor does not redirect the queued +receive program or change the saved caller. + +The notification follows the read-only reference's PhoneRing.script boundary +at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`: +`call`, `contact`, `name`, `className`, `special` and `scriptKey`. +Names come from the actual native contact/trainer roster. The detached call +descriptor has incoming direction, native numeric contact ID and actual +script key; canonical trainer class/member metadata is included when known. +This engine retains native extracted script keys rather than inventing +unavailable symbol labels. Special notifications use the queued wrapper key, +which can differ from the contact's ordinary receive script. + +The actual native controller dispatches this event only for Gen2. Native +API1/2 share delivery. Unsubscribe, entry rollback and teardown use the +existing event-owner journal. + +Focused synthetic checks run actual controller admission and queueScript, the real +incoming selector, Gen2ScriptVM and ScriptRunner in all three editions and +two native API levels. Only audio IO, RTC-facing time and RNG are +controlled. The controller's process-local Game binding is installed and +restored by the test; protected player instances are never used. Tests cover +random/special descriptors, actual queued program execution/completion, +observer mutations and false returns, rejected candidates, outgoing calls, +unsubscribe, rollback and other-generation isolation. + +This does not reproduce the complete PhoneRing dialogue/choreography, add the +legacy Phone facade, establish Mom shopping-call production, or prove full +story/hardware acceptance. Those remain separate work. + +Original contributions by Roxas2712; existing reference, upstream and +third-party rights and credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index 60e494371..e8b106b06 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2061,6 +2061,26 @@ function Gen2Commands.phoneName(data, id) return (names and names[entry.trainer]) or best.name, best.name end +-- Observation of an admitted native incoming call, never of a model-side +-- candidate. Keep the payload detached from the native row and queued IR. +function Gen2Commands.announceIncomingCall(data, id, special) + local Runtime = require("src.mods.Runtime") + if not Runtime.wants("phone.call_received") then return end + local row = phoneContact(data, id) or {} + local pool = data and data.map_scripts or {} + local specialRow = special and pool.specialCalls and pool.specialCalls[special] + local key = specialRow and specialRow.script or row.receive + local name, className = Gen2Commands.phoneName(data, id) + local constants = data and data.constants or {} + local class = (constants.trainerClassOrder or {})[row.class] + local member = class and (constants.trainerClassMembers or {})[class] + local call = {kind="call", direction="incoming", contact=id, + class=class, member=member and member[row.trainer], number=row.contactNumber, + script=key, scriptKey=key, special=special} + Runtime.emit("phone.call_received", {call=call, contact=id, + name=name, className=className, special=special, scriptKey=key}) +end + -- The row's second `dba` is the script PhoneCall (36:$4298) runs when the -- player rings the contact; the first is the one they run when they ring in. -- Both are walked into map_scripts.scripts by the extractor, so hand back the diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 05f6dc9d0..05a14d424 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3274,6 +3274,7 @@ function OverworldState:checkSpecialPhoneCall() Game.save.g2CurCaller = caller self:queueScript(script, { phoneCaller = caller, onDone = function() Game.save.g2CurCaller = nil end }) + Gen2Commands.announceIncomingCall(Game.data, caller, Game.save.g2SpecialCallActive) end -- CheckPhoneCall (36:$4074): the random incoming call, rolled on every step @@ -3309,6 +3310,7 @@ function OverworldState:checkIncomingPhoneCall() Game.save.g2CurCaller = nil pcall(function() require("src.core.Sound").play(Game.data, "Hang_Up") end) end }) + Gen2Commands.announceIncomingCall(Game.data, id) end function OverworldState:update(dt) diff --git a/tests/engine/gen2_phone_received_event.lua b/tests/engine/gen2_phone_received_event.lua new file mode 100644 index 000000000..d85e7aeab --- /dev/null +++ b/tests/engine/gen2_phone_received_event.lua @@ -0,0 +1,147 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-phone-received-event.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local Calls=require('src.script.Gen2Commands');local Runner=require('src.script.ScriptRunner') +local World=require('src.world.OverworldController') +local gameSlot,oldGame +for i=1,100 do + local name,value=debug.getupvalue(World.checkIncomingPhoneCall,i) + if name=='Game' then gameSlot=i;oldGame=value;break end + if not name then break end +end +assert(gameSlot,'actual native controller Game upvalue') +local function load(d,source,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local fs={['mods/phone_event/manifest.json']=Json.encode({id='phone_event',name='Phone event',version='1.0.0', + api=mode and mode[1] or 2,content_format=mode and mode[2] or 'native',entry='main.lua',affects_link=false}), + ['mods/phone_event/main.lua']='local mod=...;mod.exports.api=mod;'..source} + return T.sdk.loadMods({'mods/phone_event'},{data=d,fs=T.sdk.memfs(fs)}) +end +local function fixture() + local d=T.fixtures.fresh();d.constants.trainerClassOrder={'YOUNGSTER'} + d.constants.trainerClassMembers={YOUNGSTER={'JOEY1'}} + d.trainers={OPP_LOCAL={index=1,name='LOCAL CLASS',partyNames={'LOCAL JOEY'}}} + d.map_scripts={phone={[7]={class=1,trainer=1,call='OUT',callTime=7,receive='IN',receiveTime=7,group=2,number=1}, + [4]={name='LOCAL ELM',contactNumber=4,receive='ELM',receiveTime=0}}, + specialCalls={[1]={caller=4,script='SPECIAL',outside=true}}, + scripts={OUT={{'setval',74},{'end'}},IN={{'setval',71},{'end'}},ELM={{'setval',72},{'end'}}, + SPECIAL={{'setval',73},{'specialphonecall',0},{'end'}}}} + return d +end +local function gameFor(d) + local g={data=d,save={playTime=1800,g2LastCallMinute=0,g2Phone={[7]=true}}} + local w=setmetatable({map={id='HERE',def={group=1,number=1,phoneService=true,environment=1}, + warpAtCell=function()return nil end},player={cellX=0,cellY=0},scriptMoves={}, + timeOfDay=function()return 'DAY'end},{__index=World}) + w.runner=Runner.new(g,w);g.overworld=w + debug.setupvalue(World.checkIncomingPhoneCall,gameSlot,g) + return g,w +end +-- Upstream controller methods return no admission value. Observe their +-- actual queue growth without changing that native return protocol. +local function incoming(w) + local count=#(w.pendingScripts or {}) + w:checkIncomingPhoneCall() + return #(w.pendingScripts or {})>count +end +local function special(w) + local count=#(w.pendingScripts or {}) + w:checkSpecialPhoneCall() + return #(w.pendingScripts or {})>count +end +local Sound=require('src.core.Sound');local oldSound=Sound.play;Sound.play=function()end +local oldRandom=math.random;math.random=function()return 0 end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture();local r=load(d,[[ + mod.exports.seen={} + mod.exports.stop=mod.events:on("phone.call_received",function(e) + mod.exports.seen[#mod.exports.seen+1]={contact=e.contact,name=e.name,className=e.className, + special=e.special,scriptKey=e.scriptKey,call={kind=e.call.kind,contact=e.call.contact, + direction=e.call.direction,class=e.call.class,member=e.call.member,scriptKey=e.call.scriptKey}} + e.contact=255;e.call.contact=255;e.call.scriptKey='MUTATED';return false + end) + ]],mode) + local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' public event registration succeeds') + local e=r.loader.exports.phone_event;local g,w=gameFor(d) + T.eq(incoming(w),true,label..' actual native controller admits a random call') + T.eq(#e.seen,1,label..' admitted random call emits exactly once') + local seen=e.seen[1] + T.eq(seen.contact,7,label..' notification exposes the selected native contact ID') + T.eq(seen.name,'LOCAL JOEY',label..' caller name comes from the native trainer roster') + T.eq(seen.className,'LOCAL CLASS',label..' caller class uses native display text') + T.eq(seen.special,nil,label..' random call has no fabricated special ID') + T.eq(seen.scriptKey,'IN',label..' notification names the actual compiled native key') + T.eq(seen.call.contact,7,label..' descriptor and notification agree') + T.eq(seen.call.direction,'incoming',label..' descriptor retains incoming direction') + T.eq(seen.call.class,'YOUNGSTER',label..' descriptor class uses explicit canonical metadata') + T.eq(seen.call.member,'JOEY1',label..' descriptor member uses explicit canonical metadata') + T.eq(g.save.g2CurCaller,7,label..' observer mutation cannot change the real caller') + T.eq(w.pendingScripts[1].extra.phoneCaller,7,label..' observer mutation cannot redirect the queued call') + w.runner:run(w.pendingScripts[1].script,w.pendingScripts[1].extra) + T.eq(w.runner.ctx.g2Var,71,label..' actual selected receive program still executes') + T.eq(g.save.g2CurCaller,nil,label..' actual completion clears caller ownership') + T.eq(d.map_scripts.phone[7].receive,'IN',label..' observer mutation cannot rewrite imported contact') + g,w=gameFor(d);g.save.g2SpecialCall=1 + T.eq(special(w),true,label..' actual controller admits the queued outside special') + T.eq(#e.seen,2,label..' admitted special emits exactly once') + seen=e.seen[2] + T.eq(seen.contact,4,label..' special exposes its actual caller') + T.eq(seen.name,'LOCAL ELM',label..' non-trainer name comes from actual contact data') + T.eq(seen.className,nil,label..' non-trainer has no fabricated trainer class') + T.eq(seen.special,1,label..' special ID survives disarming of its pending queue') + T.eq(seen.scriptKey,'SPECIAL',label..' wrapper script overrides contact receive key') + T.eq(w.pendingScripts[1].extra.phoneCaller,4,label..' observer mutation cannot redirect the special') + w.runner:run(w.pendingScripts[1].script,w.pendingScripts[1].extra) + T.eq(w.runner.ctx.g2Var,73,label..' actual special receive program executes') + T.eq(g.save.g2SpecialCallActive,nil,label..' native specialphonecall NONE clears active ownership') + T.eq(g.save.g2CurCaller,nil,label..' special completion clears real caller ownership') + T.eq(special(w),false,label..' completed special cannot notify twice') + T.eq(#e.seen,2,label..' completed queue has no phantom event') + g,w=gameFor(d);w.transitioning=true + T.eq(incoming(w),false,label..' transition rejects a random candidate') + g.save.g2SpecialCall=1 + T.eq(special(w),false,label..' transition rejects a special candidate') + g,w=gameFor(d);g.save.g2SpecialCall=1;w.map.def.environment=3 + T.eq(special(w),false,label..' indoor map keeps an outside-only call pending') + T.eq(g.save.g2SpecialCall,1,label..' rejected special remains queued') + T.eq(#e.seen,2,label..' rejected candidates never notify') + g,w=gameFor(d) + local gear=setmetatable({game=g},{__index=require('src.ui.PokegearMenu')}) + T.eq(gear:runCallScript(7),true,label..' actual outgoing native call executes') + T.eq(#e.seen,2,label..' outgoing call cannot raise an incoming notification') + e.stop();g,w=gameFor(d) + T.eq(incoming(w),true,label..' unsubscribe preserves native incoming behavior') + T.eq(#e.seen,2,label..' unsubscribe stops event delivery') + Loader.teardown();r.release() + end +end +for _,version in ipairs({'red','firered','emerald','heartgold'})do + V.set(version);local d=fixture();local r=load(d,[[ + mod.exports.count=0 + mod.events:on("phone.call_received",function()mod.exports.count=mod.exports.count+1 end) + ]]) + T.eq(#r.errors,0,version..' native listener registration remains available without a native target') + local g,w=gameFor(d);g.save.g2SpecialCall=1 + T.eq(incoming(w),false,version..' native random call gate remains isolated') + T.eq(special(w),false,version..' native special call gate remains isolated') + T.eq(r.loader.exports.phone_event.count,0,version..' no unrelated generation event') + Loader.teardown();r.release() +end +do + V.set('crystal');local d=fixture();local r=load(d,[[ + mod.events:on("phone.call_received",function()error('rolled-back listener ran')end) + error('entry failure') + ]]) + T.check(#r.errors>0,'failed entry is reported') + T.eq(require('src.mods.Runtime').wants("phone.call_received"),false,'failed entry rolls back its actual event listener') + local g,w=gameFor(d) + T.eq(incoming(w),true,'failed listener cannot block the actual native incoming call') + Loader.teardown();r.release() +end +debug.setupvalue(World.checkIncomingPhoneCall,gameSlot,oldGame) +Sound.play=oldSound;math.random=oldRandom;V.set(original) +T.finish('native phone.call_received from actual random/special controller queues and receive programs') From 0fb2ad456a015dc9226b5414808ad8d3b6f1defc Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:16:55 +0100 Subject: [PATCH 081/138] Keep native trainer caller class after authored contact renaming --- docs/native-gen2-phone-received-event.md | 3 ++- src/script/Gen2Commands.lua | 2 +- tests/engine/gen2_phone_received_event.lua | 7 +++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/native-gen2-phone-received-event.md b/docs/native-gen2-phone-received-event.md index 5f745476d..e05e5cd82 100644 --- a/docs/native-gen2-phone-received-event.md +++ b/docs/native-gen2-phone-received-event.md @@ -10,7 +10,8 @@ receive program or change the saved caller. The notification follows the read-only reference's PhoneRing.script boundary at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`: `call`, `contact`, `name`, `className`, `special` and `scriptKey`. -Names come from the actual native contact/trainer roster. The detached call +Names come from the actual native contact/trainer roster or authored contact +name. Renaming a trainer contact preserves its actual trainer-class label. The detached call descriptor has incoming direction, native numeric contact ID and actual script key; canonical trainer class/member metadata is included when known. This engine retains native extracted script keys rather than inventing diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index e8b106b06..562f8a208 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2054,8 +2054,8 @@ end function Gen2Commands.phoneName(data, id) local entry = phoneContact(data, id) if not entry then return nil end - if entry.name then return entry.name, entry.role end local best = phoneTrainerDef(data, entry) + if entry.name then return entry.name, entry.role or (best and best.name) end if not best then return nil end local names = best.partyNames return (names and names[entry.trainer]) or best.name, best.name diff --git a/tests/engine/gen2_phone_received_event.lua b/tests/engine/gen2_phone_received_event.lua index d85e7aeab..72bddc0dd 100644 --- a/tests/engine/gen2_phone_received_event.lua +++ b/tests/engine/gen2_phone_received_event.lua @@ -55,8 +55,11 @@ local oldRandom=math.random;math.random=function()return 0 end for _,version in ipairs({'gold','silver','crystal'})do V.set(version) for _,mode in ipairs({{1,'native'},{2,'native'}})do - local d=fixture();local r=load(d,[[ + local d=fixture() + T.eq(Calls.phoneName(d,7),'LOCAL JOEY','unmodified contact still reads the actual native roster') + local r=load(d,[[ mod.exports.seen={} + mod.content.phone_contacts:patch('phone:7',{name='AUTHORED JOEY'}) mod.exports.stop=mod.events:on("phone.call_received",function(e) mod.exports.seen[#mod.exports.seen+1]={contact=e.contact,name=e.name,className=e.className, special=e.special,scriptKey=e.scriptKey,call={kind=e.call.kind,contact=e.call.contact, @@ -71,7 +74,7 @@ for _,version in ipairs({'gold','silver','crystal'})do T.eq(#e.seen,1,label..' admitted random call emits exactly once') local seen=e.seen[1] T.eq(seen.contact,7,label..' notification exposes the selected native contact ID') - T.eq(seen.name,'LOCAL JOEY',label..' caller name comes from the native trainer roster') + T.eq(seen.name,'AUTHORED JOEY',label..' caller name consumes the actual native content override') T.eq(seen.className,'LOCAL CLASS',label..' caller class uses native display text') T.eq(seen.special,nil,label..' random call has no fabricated special ID') T.eq(seen.scriptKey,'IN',label..' notification names the actual compiled native key') From 28e158fd17d2213ff918aea81128b07dba34f2aa Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:21:29 +0100 Subject: [PATCH 082/138] perf(P5): cache native palette vectors and pixel-aligned zone geometry --- docs/palette-zone-cache.md | 65 +++++++ src/core/Performance.lua | 2 + src/render/PaletteFX.lua | 75 ++++++++ src/render/Renderer.lua | 86 +++++++++- tests/engine/palette_zone_cache.lua | 157 +++++++++++++++++ tools/check_frame_pacing.py | 10 +- tools/frame-pacing-check/main.lua | 4 + tools/frame-pacing-check/palette.lua | 246 +++++++++++++++++++++++++++ 8 files changed, 641 insertions(+), 4 deletions(-) create mode 100644 docs/palette-zone-cache.md create mode 100644 tests/engine/palette_zone_cache.lua create mode 100644 tools/frame-pacing-check/palette.lua diff --git a/docs/palette-zone-cache.md b/docs/palette-zone-cache.md new file mode 100644 index 000000000..912434625 --- /dev/null +++ b/docs/palette-zone-cache.md @@ -0,0 +1,65 @@ +# P5: palette uploads and cropped zone quads + +`Performance.paletteZoneCache` defaults to `true`; set it to `false` to restore +four fresh uniform vectors per zone and the original full-canvas/scissor blit. +This switch is independent of P1–P4. + +Native uploads compute effective palette row indices without allocating the +intermediate inverted/shade-mapped palette tables. The public `effectiveColors` +and `permute` methods retain their existing return shape/identity behavior. +The names `c0`, `c1`, `c2`, `c3` remain unchanged; no uniform packing is introduced. + +Each shader has a weakly held private last-value record and four reusable +vectors. All twelve normalized floats are compared; only changed rows are sent +inside one contiguous native blit. Every later blit starts with a full upload, +so direct raw shader writes between blits cannot leave stale colors. Public +`sendColors` calls always upload. Custom table send providers still receive fresh +vectors. Replacing public palette methods, graphics draw or shader dispatch +disables batch assumptions and preserves the original call seam. + +The native nearest-filtered texture path uses one private cached Quad with +`setViewport`, clipped to the source canvas and the old pixel coverage. It is +limited to identity transforms, positive whole-framebuffer-pixel magnification +and origins, matching X/Y target DPI and nonnegative clipping boxes. Fractional +zoom/origins, flipped/scaled/rotated views, anisotropic DPI, linear filtering, +high-DPI source textures and compound render targets use the original path. +LÖVE 12 additionally requires target DPI 1/2/4 for exact half-pixel rounding. +Zones and their palettes are never changed or pooled. + +LÖVE 11 truncates logical scissor arguments before DPI conversion; LÖVE 12 uses +float arguments and rounds physical scissor values. The crop deliberately +matches the existing behavior in each supported path. Primary API/backend +sources: [11.5 Lua API](https://github.com/love2d/love/blob/11.5/src/modules/graphics/wrap_Graphics.cpp#L524), +[11.5 backend](https://github.com/love2d/love/blob/11.5/src/modules/graphics/opengl/Graphics.cpp#L968), +[12 Lua API](https://github.com/love2d/love/blob/main/src/modules/graphics/wrap_Graphics.cpp), +[12 Metal backend](https://github.com/love2d/love/blob/main/src/modules/graphics/metal/Graphics.mm). + +The guarded `tools/check_frame_pacing.py --palette-zones --love /path/to/love +--report /owned/qa/palette.json` first compares actual GPU readbacks byte for +byte. Each backend passes 432 cases across Red/Gold, four display modes, +shade-register remapping, overlapping/true-color zones, clipping, transforms, +three target densities, fractional origins/zoom and source filtering/density. +OpenGL exercises 48 optimized and 384 fallback cases; Metal 16 optimized and +416 fallback cases. First versions of the crop failed these tests and were +restricted/corrected before qualification. Uniform behavior has 491 focused +checks, including in-place palette mutations, raw shader writes between blits, +public wrappers and fresh consumer-owned vectors. + +The timed workload draws 96 equal-palette zones over owned generated source +pixels, with 120 warm-up + 120 measured frames per switch phase, macOS arm64, +60 Hz/VSync 1. GC is stopped during each measured window to expose allocation +growth, then restarted. Heap growth includes host/profiler work and is not +retained memory or a leak measurement. Shader:send calls fall from 384 to four +per contiguous identical-palette blit; later blits still resynchronize. + +| Tree / backend | F11 zone blit, ms/frame | Raw draw mean, ms | CPU, ms/frame | Heap growth / 120 frames, KiB | +| --- | ---: | ---: | ---: | ---: | +| Fork / LÖVE 11.5 OpenGL | 0.34 → 0.42 | 0.358 → 0.451 | 0.956 → 1.210 | 4368 → 49 | +| Fork / LÖVE 12 Metal | 16.39 → 16.40 | 16.397 → 16.407 | 0.630 → 0.613 | 4369 → 49 | +| Public adaptation / LÖVE 11.5 OpenGL | 0.41 → 0.22 | 0.445 → 0.243 | 0.968 → 0.705 | 4368 → 49 | +| Public adaptation / LÖVE 12 Metal | 16.37 → 16.35 | 16.376 → 16.364 | 0.759 → 0.735 | 4368 → 49 | + +Timing/CPU results are mixed across short runs; no general FPS/CPU gain is +established. Metal queue submission can include GPU/VSync waiting. Full-game, +Android, dedicated ARM handheld and long session: not measured. Reports retain +the exact measured source fingerprints and raw F11 logs. diff --git a/src/core/Performance.lua b/src/core/Performance.lua index c7bd61b1a..8e42d3b93 100644 --- a/src/core/Performance.lua +++ b/src/core/Performance.lua @@ -49,6 +49,8 @@ Performance.gcBudget = true Performance.hotPathHoist = true -- P4: independent world-draw closure, list and protection optimization. Performance.worldDrawHoist = true +-- P5: palette upload batching and cropped zone quads. +Performance.paletteZoneCache = true Performance.GC_PAUSE = 120 Performance.GC_STEP_MUL = 300 Performance.GC_BUDGET_SECONDS = 0.0005 diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 731d74675..aa85b113a 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -21,6 +21,9 @@ local GameVersion = require("src.core.GameVersion") local HotModules = require("src.core.HotModules") +local Performance = require("src.core.Performance") +local nativeDraw = love and love.graphics and love.graphics.draw +local nativeSetShader = love and love.graphics and love.graphics.setShader local PaletteFX = {} @@ -1182,7 +1185,60 @@ end -- send a 4-color (0-255 RGB) palette to the shade-remap shader, after -- applying the active COLORS display mode +local nativeEffectiveColors, nativePermute = PaletteFX.effectiveColors, PaletteFX.permute +local _uniforms = setmetatable({}, {__mode="k"}) +local UNIFORM_NAMES = {"c0", "c1", "c2", "c3"} + +-- Native upload code resolves rows by index, never by a borrowed public palette. +local function effectiveSource(c) + if PaletteFX.effectiveColors ~= nativeEffectiveColors or PaletteFX.permute ~= nativePermute then + return PaletteFX.effectiveColors(c), 1, 2, 3, 4 + end + if not c then return nil end + local mode = gen2Native() and "gbc" or PaletteFX.mode or "gbc" + local reverse = mode == "og_inv" or mode == "gbc_inv" + if mode == "og" or mode == "og_inv" then c = PaletteFX.GRAYS + elseif mode == "classic" then c = PaletteFX.CLASSIC end + local a, b, d, e = 1, 2, 3, 4 + if shadeMap then a,b,d,e = shadeMap[0]+1,shadeMap[1]+1,shadeMap[2]+1,shadeMap[3]+1 end + if reverse then a,b,d,e = 5-a,5-b,5-d,5-e end + return c, a, b, d, e +end +local function uniformState(shader) + local state = _uniforms[shader] + if not state then + state = {rows={{},{},{},{}}} + _uniforms[shader] = state + end + return state +end +local function uploadRow(shader, state, index, color, force) + local row = state.rows[index] + local r,g,b = color[1]/255,color[2]/255,color[3]/255 + if force or not state.valid or row[1] ~= r or row[2] ~= g or row[3] ~= b then + row[1],row[2],row[3] = r,g,b + shader:send(UNIFORM_NAMES[index], row) + end +end +local function uploadColors(shader, c, state, force) + local source,a,b,d,e = effectiveSource(c) + if not source then return end + uploadRow(shader,state,1,source[a],force) + uploadRow(shader,state,2,source[b],force) + uploadRow(shader,state,3,source[d],force) + uploadRow(shader,state,4,source[e],force) + state.valid = true +end +local function nativeShader(shader) + return type(shader) == "userdata" and shader.typeOf and shader:typeOf("Shader") +end + +-- A caller can write raw shader uniforms between public calls. Each public +-- call therefore uploads unconditionally. Reused vectors stay inside LÖVE. function PaletteFX.sendColors(shader, c) + if Performance.paletteZoneCache and nativeShader(shader) then + return uploadColors(shader, c, uniformState(shader), true) + end c = PaletteFX.effectiveColors(c) if not c then return end shader:send("c0", { c[1][1] / 255, c[1][2] / 255, c[1][3] / 255 }) @@ -1190,5 +1246,24 @@ function PaletteFX.sendColors(shader, c) shader:send("c2", { c[3][1] / 255, c[3][2] / 255, c[3][3] / 255 }) shader:send("c3", { c[4][1] / 255, c[4][2] / 255, c[4][3] / 255 }) end +PaletteFX._nativeSendColors = PaletteFX.sendColors + +-- Cache validity is limited to one contiguous native blit. There are no mod +-- callbacks between its zones; every later blit starts with a full upload. +function PaletteFX._beginColorBatch(shader) + if not Performance.paletteZoneCache or not nativeShader(shader) + or PaletteFX.effectiveColors ~= nativeEffectiveColors or PaletteFX.permute ~= nativePermute + or love.graphics.draw ~= nativeDraw or love.graphics.setShader ~= nativeSetShader then return nil end + local state = uniformState(shader) + if state.busy then state = {rows={{},{},{},{}}} end + state.valid, state.busy = false, true + return state +end +function PaletteFX._sendColorsInBatch(shader, c, state) + return uploadColors(shader, c, state, false) +end +function PaletteFX._endColorBatch(state) + if state then state.busy = nil end +end return PaletteFX diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index da0b3754c..391e05581 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -683,6 +683,76 @@ local function withTrueColor(zoneList, pass) return merged end +-- Cropped zone geometry is equivalent only on a native nearest-filtered, +-- pixel-density-matched, untransformed target. Public unusual layouts keep +-- their established scissor path (including flips and anisotropic DPI). +local rasterMajor = love and love.getVersion and love.getVersion() or 11 +local function wholePixel(value) + return math.abs(value - math.floor(value + 0.5)) <= 1e-7 +end +local function truncate(value) + return value < 0 and math.ceil(value) or math.floor(value) +end +local function zonePixelSpan(first, last, density) + local lo,hi = math.floor(first*density),math.ceil(last*density) + if rasterMajor < 12 then + -- The 11.x Lua API truncates logical scissor arguments before DPI conversion. + local left = truncate(truncate((lo+0.5)/density)*density) + local width = truncate(truncate((hi-lo+0.5)/density)*density) + return left/density,(left+width)/density + end + -- 12.x rounds float scissor values. This path is used only for power-of-two + -- DPI so the existing half-pixel padding is exactly representable. + return (lo+1)/density,(hi+2)/density +end +local function zoneQuad(self, canvas, sx, sy, bx, by, dpiX, dpiY) + if not Performance.paletteZoneCache or type(canvas) ~= "userdata" or not canvas.typeOf + or not (canvas:typeOf("Canvas") or canvas:typeOf("Texture")) + or sx <= 0 or sy <= 0 then return nil end + if not canvas.getDPIScale or canvas:getDPIScale() ~= 1 then return nil end + if not canvas.getFilter or canvas:getFilter() ~= "nearest" then return nil end + local g = love.graphics + if not (g.newQuad and g.transformPoint) then return nil end + local target = g.getCanvas() + if target and type(target) ~= "userdata" then return nil end + local density = target and target.getDPIScale and target:getDPIScale() or g.getDPIScale() + if math.abs(density - (dpiX or 1)) > 1e-7 or math.abs(density - (dpiY or 1)) > 1e-7 then return nil end + -- Fractional magnification/origins can put sample centres exactly on a + -- texel boundary. Cropping then changes floating-point tie rounding. + if not (wholePixel(sx*density) and wholePixel(sy*density) + and wholePixel(bx*density) and wholePixel(by*density)) then return nil end + if rasterMajor >= 12 and density ~= 1 and density ~= 2 and density ~= 4 then return nil end + local x0,y0 = g.transformPoint(0,0) + local xx,xy = g.transformPoint(1,0) + local yx,yy = g.transformPoint(0,1) + if x0 ~= 0 or y0 ~= 0 or xx ~= 1 or xy ~= 0 or yx ~= 0 or yy ~= 1 then return nil end + local quad = self._zoneQuad + if not quad then + local w,h = canvas:getDimensions() + quad = g.newQuad(0,0,w,h,w,h) + self._zoneQuad = quad + end + return quad +end +function Renderer:_drawZoneQuad(quad, canvas, sx, sy, zoneSx, zoneSy, bx, by, + boxX, boxY, boxW, boxH, dpiX, dpiY, z) + local x,y = bx + z.x*zoneSx, by + z.y*zoneSy + local x2,y2 = math.min(x+z.w*zoneSx,boxX+boxW),math.min(y+z.h*zoneSy,boxY+boxH) + x,y = math.max(x,boxX),math.max(y,boxY) + if x2 <= x or y2 <= y then return false end + dpiX,dpiY = dpiX or 1,dpiY or 1 + -- The old scissor rounds starts down and ends up in physical pixels. + x,x2 = zonePixelSpan(x,x2,dpiX) + y,y2 = zonePixelSpan(y,y2,dpiY) + local cw,ch = canvas:getDimensions() + local qx,qy = math.max(0,(x-bx)/sx),math.max(0,(y-by)/sy) + local qx2,qy2 = math.min(cw,(x2-bx)/sx),math.min(ch,(y2-by)/sy) + if qx2 <= qx or qy2 <= qy then return false end + quad:setViewport(qx,qy,qx2-qx,qy2-qy,cw,ch) + love.graphics.draw(canvas,quad,bx+qx*sx,by+qy*sy,0,sx,sy) + return true +end + -- Palette-correct blit of `canvas` at (sx, sy) LOVE-unit scales into origin -- (bx, by), scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/ -- zoneSy convert zone coords (canvas-space) into screen units. Public so a @@ -699,9 +769,14 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, return end love.graphics.setShader(shader) + local colorBatch = PaletteFX.sendColors == PaletteFX._nativeSendColors + and PaletteFX._beginColorBatch(shader) or nil -- a colors == false zone is the trueColor opt-out: its rect draws with -- no shader at all. Nothing sets one without a mod, so a vanilla zone -- list never toggles and issues exactly the calls it always did. + local quad = zoneSx > 0 and zoneSy > 0 and boxX >= 0 and boxY >= 0 + and zoneQuad(self, canvas, sx, sy, bx, by, dpiX, dpiY) or nil + if quad then love.graphics.setScissor() end local bare = false for _, z in ipairs(zoneList) do local plain = z.colors == false @@ -709,8 +784,14 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, bare = plain love.graphics.setShader(not plain and shader or nil) end - if not plain then PaletteFX.sendColors(shader, z.colors) end - if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy, + if not plain then + if colorBatch then PaletteFX._sendColorsInBatch(shader, z.colors, colorBatch) + else PaletteFX.sendColors(shader, z.colors) end + end + if quad then + self:_drawZoneQuad(quad, canvas, sx, sy, zoneSx, zoneSy, bx, by, + boxX, boxY, boxW, boxH, dpiX, dpiY, z) + elseif scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy, z.w * zoneSx, z.h * zoneSy, boxX, boxY, boxW, boxH, dpiX, dpiY) then love.graphics.draw(canvas, bx, by, 0, sx, sy) @@ -718,6 +799,7 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, end love.graphics.setScissor() love.graphics.setShader() + PaletteFX._endColorBatch(colorBatch) closeBlit() end diff --git a/tests/engine/palette_zone_cache.lua b/tests/engine/palette_zone_cache.lua new file mode 100644 index 000000000..de520e464 --- /dev/null +++ b/tests/engine/palette_zone_cache.lua @@ -0,0 +1,157 @@ +local T = require("tests.harness") +love = require("tests.love_stub") +local Version = require("src.core.GameVersion") +local Performance = require("src.core.Performance") +local P = require("src.render.PaletteFX") +local Renderer = require("src.render.Renderer") +local base = { { 255, 240, 220 }, { 180, 150, 120 }, { 80, 50, 30 }, { 5, 3, 1 } } +local function shader() + local records = {} + local methods = { + typeOf = function(_, kind) + return kind == "Shader" + end, + send = function(_, name, value) + records[#records + 1] = { name, { value[1], value[2], value[3] }, value } + end, + } + local object = methods + if newproxy then + object = newproxy(true) + getmetatable(object).__index = methods + end + return object, records +end +for _, version in ipairs({ "red", "gold" }) do + Version.set(version) + for _, mode in ipairs(P.MODES) do + P.mode = mode + for _, map in ipairs({ false, { [0] = 0, [1] = 3, [2] = 1, [3] = 2 } }) do + P.setShadeMap(map or nil) + local expected = P.effectiveColors(base) + local sh, records = shader() + local state = { rows = { {}, {}, {}, {} } } + P._sendColorsInBatch(sh, base, state) + for i = 1, 4 do + T.eq(records[i][1], "c" .. (i - 1), "uniform names remain unchanged") + for channel = 1, 3 do + T.eq(records[i][2][channel], expected[i][channel] / 255, version .. "/" .. mode .. " row mapping") + end + end + P._sendColorsInBatch(sh, base, state) + T.eq(#records, 4, "unchanged twelve floats skip redundant uploads") + end + end +end +Version.set("red") +P.mode = "gbc" +P.setShadeMap(nil) +local sh, records = shader() +local state = { rows = { {}, {}, {}, {} } } +P._sendColorsInBatch(sh, base, state) +base[2][1] = 181 +P._sendColorsInBatch(sh, base, state) +T.eq(#records, 5, "in-place palette mutation invalidates the affected row") +T.eq(records[5][1], "c1", "only changed row is uploaded") +local custom = P.effectiveColors +P.effectiveColors = function(c) + return { c[4], c[3], c[2], c[1] } +end +P._sendColorsInBatch(sh, base, state) +T.eq(records[#records][2][1], base[1][1] / 255, "public effectiveColors override is honored") +P.effectiveColors = custom +local oldPermute = P.permute +P.permute = function(c) + return { c[4], c[3], c[2], c[1] } +end +P._sendColorsInBatch(sh, base, state) +T.eq(records[#records][2][1], base[1][1] / 255, "public permute override is honored") +P.permute = oldPermute +local plain, plainRecords = shader() +Performance.paletteZoneCache = true +P.sendColors(plain, base) +P.sendColors(plain, base) +T.eq(#plainRecords, 8, "public calls always resend after possible external shader writes") +local fake = { + send = function(self, name, value) + self[#self + 1] = value + end, +} +P.sendColors(fake, base) +P.sendColors(fake, base) +T.check(fake[1] ~= fake[5], "consumer-provided send callbacks retain fresh public vectors") +local shaderFactory = P.shader +P.shader = function() + return sh +end +local zones = { + { x = 0, y = 0, w = 16, h = 16, colors = base }, + { x = 0, y = 0, w = 16, h = 16, colors = base }, + { + x = 0, + y = 0, + w = 16, + h = 16, + colors = base, + }, +} +local canvas = love.graphics.newCanvas(16, 16) +local function blit() + Renderer:blitCanvas(canvas, 1, 1, zones, 1, 1, 0, 0, 0, 0, 16, 16, 1, 1) +end +records = {} +local count = 0 +local sender = P.sendColors +P.sendColors = function(...) + count = count + 1 + return sender(...) +end +blit() +T.eq(count, 3, "public sendColors wrapper is still called for every original zone") +P.sendColors = sender +local real, sent = shader() +P.shader = function() + return real +end +Performance.paletteZoneCache = true +blit() +local perBlit = newproxy and 4 or 12 +T.eq(#sent, perBlit, "native contiguous blit skips repeated rows only on real shader-shaped userdata") +real:send("c0", { 0.4, 0.5, 0.6 }) +blit() +T.eq(#sent, perBlit * 2 + 1, "new blit resets validity after external raw shader writes") +T.same( + sent[perBlit + 2][2], + { base[1][1] / 255, base[1][2] / 255, base[1][3] / 255 }, + "first upload restores the native palette" +) +Performance.paletteZoneCache = false +local before = #sent +blit() +T.eq(#sent - before, 12, "P5 off restores four sends for every zone") +Performance.paletteZoneCache = true +local oldDraw = love.graphics.draw +love.graphics.draw = function(...) + return oldDraw(...) +end +before = #sent +blit() +T.eq(#sent - before, 12, "a graphics callback override disables cache assumptions") +love.graphics.draw = oldDraw +P.shader = shaderFactory +local q = { + setViewport = function(self, ...) + self.viewport = { ... } + end, +} +local draw = love.graphics.draw +local drawn +love.graphics.draw = function(...) + drawn = { ... } +end +Renderer:_drawZoneQuad(q, canvas, 2, 2, 2, 2, 0, 0, 3, 5, 20, 20, 1, 1, { x = 0, y = 0, w = 16, h = 16 }) +T.same(q.viewport, { 1.5, 2.5, 10, 10, 16, 16 }, "quad viewport crops source coordinates to the original clip") +T.eq(drawn[3], 3, "cropped quad retains horizontal origin mapping") +T.eq(drawn[4], 5, "cropped quad retains vertical origin mapping") +love.graphics.draw = draw +T.finish("palette uploads and zone quads") diff --git a/tools/check_frame_pacing.py b/tools/check_frame_pacing.py index a46da9de5..cbe6b211c 100644 --- a/tools/check_frame_pacing.py +++ b/tools/check_frame_pacing.py @@ -18,6 +18,7 @@ def main(): workload.add_argument('--gc', action='store_true', help='compare fixed-step GC with the P2 idle budget') workload.add_argument('--hot-path', action='store_true', help='compare actual native update/draw dependency dispatch') workload.add_argument('--world-draw', action='store_true', help='compare native world entity/FX dispatch over synthetic content') + workload.add_argument('--palette-zones', action='store_true', help='verify native zone pixels and compare palette upload batching') args = parser.parse_args() root = Path(__file__).resolve().parents[1] sys.path.insert(0, str(args.qa_tools or root / 'tools')) @@ -40,6 +41,10 @@ def main(): 'src/world/OverworldController.lua', 'src/render/Renderer.lua', 'src/render/PaletteFX.lua', 'src/render/Pipelines.lua', 'tests/world_draw/fixture.lua', 'tools/frame-pacing-check/world.lua') + if args.palette_zones: + files += ('src/core/Performance.lua', 'src/core/HotModules.lua', + 'src/render/Renderer.lua', 'src/render/PaletteFX.lua', + 'tools/frame-pacing-check/palette.lua') hashes = {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in files} source = qa_run(['git', 'rev-parse', 'HEAD'], cwd=root, text=True, capture_output=True, check=True).stdout.strip() @@ -52,10 +57,11 @@ def main(): FRAME_PACING_REPO=str(root), FRAME_PACING_QA_GUARDED='1', FRAME_PACING_GC='1' if args.gc else '0', FRAME_PACING_HOT='1' if args.hot_path else '0', - FRAME_PACING_WORLD='1' if args.world_draw else '0'), + FRAME_PACING_WORLD='1' if args.world_draw else '0', + FRAME_PACING_PALETTE='1' if args.palette_zones else '0'), capture_output=True, text=True, timeout=90) args.report.with_suffix('.log').write_text(result.stdout + result.stderr) - prefix = 'P4_NATIVE_RESULT ' if args.world_draw else 'P3_NATIVE_RESULT ' if args.hot_path else 'P2_NATIVE_RESULT ' if args.gc else 'P1_NATIVE_RESULT ' + prefix = 'P5_NATIVE_RESULT ' if args.palette_zones else 'P4_NATIVE_RESULT ' if args.world_draw else 'P3_NATIVE_RESULT ' if args.hot_path else 'P2_NATIVE_RESULT ' if args.gc else 'P1_NATIVE_RESULT ' records = [line.removeprefix(prefix) for line in result.stdout.splitlines() if line.startswith(prefix)] if result.returncode or len(records) != 1: raise RuntimeError('native pacing probe failed; see ' + str(args.report.with_suffix('.log'))) diff --git a/tools/frame-pacing-check/main.lua b/tools/frame-pacing-check/main.lua index 8f689eaf0..83722749e 100644 --- a/tools/frame-pacing-check/main.lua +++ b/tools/frame-pacing-check/main.lua @@ -16,6 +16,10 @@ if os.getenv("FRAME_PACING_WORLD") == "1" then require("tools.frame-pacing-check.world") return end +if os.getenv("FRAME_PACING_PALETTE") == "1" then + require("tools.frame-pacing-check.palette") + return +end local Cap = require("src.core.FrameCap") local Profile = require("src.core.FrameProfile") local Json = require("src.link.Json") diff --git a/tools/frame-pacing-check/palette.lua b/tools/frame-pacing-check/palette.lua new file mode 100644 index 000000000..8bc00e13c --- /dev/null +++ b/tools/frame-pacing-check/palette.lua @@ -0,0 +1,246 @@ +local Version = require("src.core.GameVersion") +local Performance = require("src.core.Performance") +local Renderer = require("src.render.Renderer") +local Palette = require("src.render.PaletteFX") +local Profile = require("src.core.FrameProfile") +local Cap = require("src.core.FrameCap") +local Json = require("src.link.Json") +local g = love.graphics +local colors = { { 255, 230, 200 }, { 180, 120, 80 }, { 50, 100, 150 }, { 5, 10, 15 } } +local other = { { 210, 240, 180 }, { 110, 160, 90 }, { 20, 70, 120 }, { 0, 0, 0 } } +local pixels = love.image.newImageData(32, 24) +pixels:mapPixel(function(x, y) + local shade = (math.floor(x / 2) + math.floor(y / 3)) % 4 / 3 + return shade, shade, shade, (x + y) % 7 == 0 and 0.5 or 1 +end) +local image = g.newImage(pixels) +image:setFilter("nearest", "nearest") +local function makeSource(density, filter) + local source = g.newCanvas(32, 24, { dpiscale = density }) + source:setFilter(filter or "nearest", filter or "nearest") + g.setCanvas(source) + g.origin() + g.clear(0, 0, 0, 0) + g.setColor(1, 1, 1, 1) + g.setBlendMode("replace") + g.draw(image) + g.setBlendMode("alpha") + return source +end +local source = makeSource(1) +local highSource = makeSource(2) +local linearSource = makeSource(1, "linear") +local zones = { + { x = 0, y = 0, w = 32, h = 8, colors = colors }, + { x = 0, y = 8, w = 32, h = 8, colors = colors }, + { x = 0, y = 16, w = 32, h = 8, colors = colors }, + { x = 5.2, y = 4.1, w = 15.6, h = 10.7, colors = other }, + { x = 10.5, y = 9.5, w = 5.2, h = 7.3, colors = false }, + { x = -3, y = -4, w = 8, h = 6, colors = colors }, + { x = 30, y = 21, w = 8, h = 6, colors = colors }, +} +local checked, quadCases, fallbackCases = 0, 0, 0 +local cases = {} +for _, density in ipairs({ 1, 1.25, 2.625 }) do + for _, scale in ipairs({ 1, 1.75, 2.625 }) do + for _, origin in ipairs({ 0, 3.2 }) do + cases[#cases + 1] = { density = density, scale = scale, x = origin, y = origin + 1.1 } + end + end +end +for _, kind in ipairs({ "anisotropic", "translate", "rotate", "highSource", "linearSource", "flip" }) do + cases[#cases + 1] = { density = 1.25, scale = 1.75, x = 3.2, y = 4.1, kind = kind } +end +for _, density in ipairs({ 1, 1.25, 2.625 }) do + cases[#cases + 1] = { density = density, scale = 3 / density, x = 2 / density, y = 3 / density } +end +local originalShader = Palette.shader +local function pixelChecks() + for _, version in ipairs({ "red", "gold" }) do + Version.set(version) + for _, mode in ipairs({ "gbc", "gbc_inv", "og_inv", "classic" }) do + Palette.mode = mode + for _, shade in ipairs({ false, { [0] = 2, [1] = 3, [2] = 3, [3] = 3 } }) do + Palette.setShadeMap(shade or nil) + for index, c in ipairs(cases) do + local target = g.newCanvas(96, 80, { dpiscale = c.density }) + local selected = c.kind == "highSource" and highSource + or c.kind == "linearSource" and linearSource + or source + local xScale = c.kind == "flip" and -c.scale or c.scale + local dpiY = c.kind == "anisotropic" and c.density * 1.05 or c.density + local before + local quadCount = 0 + for _, enabled in ipairs({ false, true }) do + Performance.paletteZoneCache = enabled + g.setCanvas(target) + g.origin() + g.clear(0.1, 0.2, 0.3, 1) + g.setColor(1, 1, 1, 1) + if c.kind == "translate" then + g.translate(1.2, 2.3) + end + if c.kind == "rotate" then + g.rotate(0.07) + end + local actualDraw = Renderer._drawZoneQuad + Renderer._drawZoneQuad = function(self, ...) + quadCount = quadCount + 1 + return actualDraw(self, ...) + end + Renderer:blitCanvas( + selected, + xScale, + c.scale, + zones, + c.scale, + c.scale, + c.x, + c.y, + 2.3, + 3.1, + 75.7, + 58.8, + c.density, + dpiY + ) + Renderer._drawZoneQuad = actualDraw + g.setCanvas() + g.origin() + local data = target:newImageData() + local bytes = data:getString() + data:release() + if not enabled then + before = bytes + else + if before ~= bytes then + local changed = 0 + for i = 1, #bytes do + if bytes:byte(i) ~= before:byte(i) then + changed = changed + 1 + end + end + error( + ("zone pixel mismatch %s/%s/shade=%s/case=%d/%s, %d bytes"):format( + version, + mode, + tostring(shade ~= false), + index, + c.kind or "native", + changed + ) + ) + end + end + end + target:release() + checked = checked + 1 + if quadCount > 0 then + quadCases = quadCases + 1 + else + fallbackCases = fallbackCases + 1 + end + end + end + end + end + assert(quadCases > 0 and fallbackCases > 0, "both optimized and compatibility geometry paths must be exercised") + Palette.mode = "gbc" + Palette.setShadeMap(nil) + Version.set("red") +end +local phase, frame, deadline, started, cpuStarted = 1, 0, 0, 0, 0 +local WARMUP, WINDOW = 120, 120 +local drawTotal, drawMax, heapStart = 0, 0, 0 +local results = {} +local _, _, mode = love.window.getMode() +Cap.current = tonumber(mode.refreshrate) or 60 +if Cap.current <= 0 then + Cap.current = 60 +end +Cap.vsyncPacing = true +local repeated = {} +for y = 0, 11 do + for x = 0, 7 do + repeated[#repeated + 1] = { x = x * 4, y = y * 2, w = 4, h = 2, colors = colors } + end +end +local function draw() + g.origin() + g.clear(0.1, 0.2, 0.3, 1) + g.setColor(1, 1, 1, 1) + Renderer:blitCanvas(source, 4, 4, repeated, 4, 4, 32, 32, 32, 32, 128, 96, 1, 1) + Profile.frame() +end +local function startPhase() + Profile.setEnabled(false) + Performance.paletteZoneCache = phase == 2 + collectgarbage("restart") + frame, drawTotal, drawMax = 0, 0, 0 + deadline = love.timer.getTime() +end +local function finishPhase() + results[phase] = { + enabled = Performance.paletteZoneCache, + frames = WINDOW, + seconds = love.timer.getTime() - started, + drawMeanMs = drawTotal / WINDOW * 1000, + drawMaxMs = drawMax * 1000, + processCpuMsPerFrame = (os.clock() - cpuStarted) / WINDOW * 1000, + heapGrowthKiB = collectgarbage("count") - heapStart, + } + collectgarbage("restart") + if phase == 2 then + Palette.shader = originalShader + print("P5_NATIVE_RESULT " .. Json.encode({ + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + renderer = g.getRendererInfo(), + refresh = mode.refreshrate, + vsync = love.window.getVSync(), + cap = Cap.current, + pixelCases = checked, + quadCases = quadCases, + fallbackCases = fallbackCases, + zones = #repeated, + phases = results, + scope = "Exact GPU pixel comparisons over owned generated source; timed 96-zone native blits; GC stopped in measured windows, no ROM/mod/game scene", + })) + return true + end + phase = 2 + startPhase() +end +function love.run() + pixelChecks() + startPhase() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + collectgarbage("collect") + collectgarbage("stop") + Profile.setEnabled(true) + heapStart = collectgarbage("count") + started, cpuStarted = love.timer.getTime(), os.clock() + end + local before = love.timer.getTime() + draw() + if frame >= WARMUP then + local elapsed = love.timer.getTime() - before + drawTotal, drawMax = drawTotal + elapsed, math.max(drawMax, elapsed) + end + local close = Profile.section("present (GPU wait / vsync)") + g.present() + close() + deadline = Cap.pace(love.timer, love.window, true, deadline) + frame = frame + 1 + if frame == WARMUP + WINDOW and finishPhase() then + return 0 + end + end +end From 0594d15daa1fada437d8ad3481978664649b4792 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:35:34 +0100 Subject: [PATCH 083/138] Read native apricorn recipes and correct GSC Kurt selection transactions --- docs/native-gen2-apricorn-selection.md | 55 +++++++ src/import/RomExtractorGen2.lua | 28 ++++ src/import/RomImporter.lua | 1 + src/script/Gen2Commands.lua | 112 +++++++-------- src/world/Gen2Apricorns.lua | 44 ++++++ tests/engine/gen2_apricorn_native.lua | 190 +++++++++++++++++++++++++ 6 files changed, 368 insertions(+), 62 deletions(-) create mode 100644 docs/native-gen2-apricorn-selection.md create mode 100644 src/world/Gen2Apricorns.lua create mode 100644 tests/engine/gen2_apricorn_native.lua diff --git a/docs/native-gen2-apricorn-selection.md b/docs/native-gen2-apricorn-selection.md new file mode 100644 index 000000000..907b5b1a0 --- /dev/null +++ b/docs/native-gen2-apricorn-selection.md @@ -0,0 +1,55 @@ +# Native ApricornBalls and Kurt selection + +The GSC importer reads the actual `ApricornBalls` pairs in cartridge order, +retaining native apricorn/ball item IDs and row indices. A single `$ff` ends +the table. Missing symbols leave no invented recipe data; explicit empty or +malformed/truncated tables never fall back to retail rows. Reads are bounded +and cannot cross a cartridge bank boundary. The map-script output includes +recipe records and completeness metadata. Stock Gold/Silver/Crystal cache +markers require reimport; other edition markers are unchanged. + +Kurt's actual native special now consumes this imported order. The old fixed +item-ID order put several colours in the wrong positions. Historical stock +caches retain selection until reimport in native red/blue/yellow/green/white/ +black/pink order, without fabricating their missing ball mappings. + +Gold/Silver take one apricorn without opening a quantity menu. Crystal asks +for quantity, including a one-item stack; cancelling quantity returns to the +apricorn list with its cursor retained and consumes nothing. An explicit +CANCEL row and bounded scrolling retain access to the complete list. The +quantity is capped at99 and the native inventory is rechecked before removal, +so a changed bag cannot mint a paid job. Unreviewed hack layouts retain their +previous quantity-cancel protocol and are not given a guessed table import. + +The native script variable still receives the actual selected item byte. +The existing `g2KurtApricorns` field holds its confirmed quantity. Native +script branches, event/working flags, daily resets and ball choice remain +script-owned. This does not add a competing Kurt job model or rewrite a ROM +script's ball operand merely because its selection table contains a pair. + +Validation:198 synthetic checks use actual importer/map-script serialization, +Gen2ScriptVM, ScriptRunner, StateStack, Menu input and Bag operations. They +cover order/bounds, malformed data, cache readiness, Gold/Silver single-item +handover, Crystal confirmation/reselection/cancellation/scrolling and a real +`verbosegiveitemvar` collection path: a full ball stack preserves the event, +successful delivery grants the confirmed quantity and event clearing prevents +duplicate collection. Only text/audio IO is substituted. A private read-only +Crystal Rev0 table probe finds seven complete rows, agrees with an independent +byte decoder and leaves the original ROM unchanged. It is not a full import, +GPU, original-story or Gold/Silver actual-ROM acceptance claim. + +The complete `apricorns` registry and legacy Apricorns facade, authored recipe +events/ball-output binding, complete native daily/tree lifecycle and original +story/hardware acceptance remain open. Full Gen1 parity is unproven. + +Behavior was checked against the read-only Gen1 reference +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91` and primary disassembly sources: +[Gold selection/table](https://github.com/pret/pokegold/blob/master/engine/menus/menu_2.asm), +[Gold handover](https://github.com/pret/pokegold/blob/master/engine/events/specials.asm), +[Crystal selection/quantity](https://github.com/pret/pokecrystal/blob/master/engine/events/kurt.asm), +[Crystal recipe table](https://github.com/pret/pokecrystal/blob/master/data/items/apricorn_balls.asm). +These links identify behavior/provenance; no ROM or extracted table bytes are +included in the contribution. + +Original contributions by Roxas2712; existing reference, upstream and +third-party rights and credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 90b74029e..74eae7b9f 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -3317,6 +3317,33 @@ function RomExtractorGen2:gen2MovementTable() return out end +-- Kurt's actual selection-order and recipe pairs, not the bag's item order. +function RomExtractorGen2:gen2ApricornBalls() + if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end + local sym=self:symbol("ApricornBalls") + if not (sym and self.rom) then return nil end + local rows,seen={},{} + local info={source="ApricornBalls",complete=false,rowsRead=0} + -- Native db apricorn,ball pairs, terminated by a single $ff apricorn. + -- Never walk through a bank boundary or accept an unterminated prefix. + for slot=0,128 do + local at=sym.address+slot*2 + local ok,apricorn=pcall(function()return self.rom:byte(sym.bank,at)end) + if not ok then info.reason="truncated table";return {},info end + if apricorn==255 then info.complete=true;return rows,info end + if slot==128 then info.reason="unterminated table";return {},info end + local read,ball=pcall(function()return self.rom:byte(sym.bank,at+1)end) + if not read then info.reason="truncated pair";return {},info end + if apricorn==0 or ball==0 or ball==255 or seen[apricorn] then + info.reason="invalid or duplicate native item";return {},info + end + seen[apricorn]=true + rows[#rows+1]={index=slot+1,apricorn=string.format("ITEM_%03d",apricorn), + ball=string.format("ITEM_%03d",ball)} + info.rowsRead=#rows + end +end + -- Marts is a dw pointer table; each mart is `db count`, the item ids, `db -1`. -- The table ends where its own first entry starts. function RomExtractorGen2:gen2Marts() @@ -8892,6 +8919,7 @@ function RomExtractorGen2:extractMapScripts() self:beginStage("Gen2 map scripts out.stds = self:gen2QueueStdScripts(pool) out.marts = self:gen2Marts() + out.apricornBalls, out.apricornInfo = self:gen2ApricornBalls() out.spawns = self:gen2SpawnPoints(maps, keys) -- extractField runs after this stage and needs both of these: GSC has no -- Fly-warp list of its own, because Fly lands on the SPAWN POINT of the diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index cbd913e5a..bd4b8d88c 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1311,6 +1311,7 @@ local function markerFor(version) -- v8: field.gen2TradeAnim, GSC's own trade movie art (Gen2TradeArt). if version == "gold" or version == "silver" or version == "crystal" then revision = "gen2-field-encounters-v8:" + revision = revision .. "gen2-apricorn-table-v1:" end -- field.tradeArt: the cable-trade movie's tiles out of the ROM -- (Gen1TradeArt); without it the trade ran on drawn stand-ins. diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..adea141ad 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -3002,72 +3002,60 @@ end -- run of `ifequal` rows that decide which ball Kurt starts on. -- --------------------------------------------------------------------------- --- RED, BLU, BLK, WHT, PNK, GRN, YLW -- the same ids Kurt1 `checkitem`s before --- it bothers to ask -local APRICORNS = { 85, 89, 92, 93, 97, 99, 101 } - function Commands.g2_select_apricorn(ctx) - local game, runner, save = ctx.game, ctx.runner, ctx.save - local Bag = require("src.inventory.Bag") - local Menu = require("src.ui.Menu") - local picked, held, items = 0, 0, {} - -- SelectApricornForKurt zeroes wKurtApricornQuantity on entry, so a - -- cancelled pick cannot leave the previous count standing. - save.g2KurtApricorns = 0 - for _, id in ipairs(APRICORNS) do - local key = string.format("ITEM_%03d", id) - local qty = save.inventory and save.inventory[key] or 0 - if qty > 0 then - local def = game.data.items and game.data.items[key] - items[#items + 1] = { - label = string.format("%s x%d", (def and def.name) or key, qty), - onSelect = function() picked, held = id, qty runner:resume() end, - } + local game,runner,save=ctx.game,ctx.runner,ctx.save + local Bag=require("src.inventory.Bag") + local Menu=require("src.ui.Menu") + local Apricorns=require("src.world.Gen2Apricorns") + local version=require("src.core.GameVersion").get() + local stock=version=="gold" or version=="silver" or version=="crystal" + save.g2KurtApricorns=0 + local selected=1 + while true do + local picked,held,key=0,0,nil + local items={} + for _,row in ipairs(Apricorns.inBag(game.data,save))do + local id,quantity,item=row.item,row.quantity,row.apricorn + local def=game.data.items and game.data.items[item] + items[#items+1]={label=string.format("%s x%d",(def and def.name) or item,quantity), + onSelect=function()picked,held,key=id,quantity,item;runner:resume()end} end - end - if #items == 0 then - ctx.g2Var, ctx.lastCheck = 0, false - return - end - game.stack:push(Menu.new(game, items, { - onCancel = function() runner:resume() end, - })) - runner:yield() - if picked == 0 then - ctx.g2Var, ctx.lastCheck = 0, false - return - end - - -- "How many should I make?" -- Kurt_SelectQuantity (engine/events/kurt.asm) - -- loads wItemQuantity with how many of that apricorn you HAVE and lets you - -- scroll 1..that, then Kurt_GiveUpSelectedQuantityOfSelectedApricorn takes - -- exactly that many and the count goes into wKurtApricornQuantity -- - -- VAR_KURT_APRICORNS, which the `verbosegiveitemvar` on the collection - -- branch reads back to decide how many balls to hand over. - -- - -- Taking one apricorn and never writing that var is what left the var at 0 - -- and, with verbosegiveitemvar unlowered as well, Kurt holding a ball he - -- could never give. - local count = 1 - if held > 1 then - local rows = {} - for n = 1, held do - rows[#rows + 1] = { - label = string.format("x%d", n), - onSelect = function() count = n runner:resume() end, - } + if #items==0 then ctx.g2Var,ctx.lastCheck=0,false;return end + if stock then + items[#items+1]={label="CANCEL",onSelect=function()runner:resume()end} + end + local menu=Menu.new(game,items,{maxVisible=version=="crystal" and 4 or 8, + onCancel=function()runner:resume()end}) + menu.index=math.min(selected,#items);menu:clampScroll() + game.stack:push(menu);runner:yield() + if picked==0 then ctx.g2Var,ctx.lastCheck=0,false;return end + selected=menu.index + local count,cancelled=1,false + -- Gold/Silver's special always tosses exactly one. Crystal asks even + -- for a one-item stack and goes back to the list on quantity cancel. + if version=="crystal" or version~="gold" and version~="silver" and held>1 then + local choices={} + for n=1,held do + local quantity=n + choices[#choices+1]={label=string.format("x%d",n), + onSelect=function()count=quantity;runner:resume()end} + end + game.stack:push(Menu.new(game,choices,{maxVisible=5, + onCancel=function()cancelled=true;runner:resume()end})) + runner:yield() + end + if not cancelled or version~="crystal" then + count=math.max(1,math.min(count,held)) + local available=tonumber(save.inventory and save.inventory[key]) or 0 + if available~=available or available0 and id<255 and id or nil +end +function M.rows(data) + local pool=data and data.map_scripts + local source=pool and pool.apricornBalls + local out={} + if source~=nil then + if type(source)~='table' or pool.apricornInfo and pool.apricornInfo.complete==false then return out end + for _,row in ipairs(source)do + if type(row)~='table' or not item(row.apricorn) or not item(row.ball) then return {} end + out[#out+1]={index=#out+1,apricorn=row.apricorn,ball=row.ball} + end + return out + end + -- Historical caches have no recipe table. Keep selection available until + -- reimport; do not invent their missing ball mappings. + local version=Version.get() + local legacy=stock(version) and {85,89,101,99,93,92,97} or {85,89,92,93,97,99,101} + for i,id in ipairs(legacy)do out[i]={index=i,apricorn=string.format('ITEM_%03d',id)}end + return out +end +function M.inBag(data,save) + local out={} + local nativeStock=stock(Version.get()) + for _,row in ipairs(M.rows(data))do + local n=tonumber(save and save.inventory and save.inventory[row.apricorn]) + if n and n==n and n=1 then + local quantity=math.floor(n) + if nativeStock then quantity=math.min(99,quantity)end + out[#out+1]={index=row.index,apricorn=row.apricorn,ball=row.ball, + item=item(row.apricorn),quantity=quantity} + end + end + return out +end +return M diff --git a/tests/engine/gen2_apricorn_native.lua b/tests/engine/gen2_apricorn_native.lua new file mode 100644 index 000000000..3ea58a195 --- /dev/null +++ b/tests/engine/gen2_apricorn_native.lua @@ -0,0 +1,190 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local E=require('src.import.RomExtractorGen2');local Writer=require('src.import.LuaWriter') +local A=require('src.world.Gen2Apricorns');local VM=require('src.script.Gen2ScriptVM') +local Runner=require('src.script.ScriptRunner');local Stack=require('src.core.StateStack') +local Commands=require('src.script.Commands');local Flags=require('src.script.Gen2Flags') +local Importer=require('src.import.RomImporter') +local markerFor +for i=1,100 do + local name,value=debug.getupvalue(Importer.readyReport,i) + if name=='markerFor'then markerFor=value;break end + if not name then break end +end +assert(markerFor) +local function extractor(version,bytes,address,length) + address=address or 0x4000 + local raw=string.rep('\0',address)..string.char(unpack(bytes)) + if length and #raw0 and 3-count or nil,version..' actual bag toss removes exactly the confirmed quantity') + T.eq(g.save.inventory.ITEM_119,1,version..' selection cannot consume another apricorn') + T.eq(g.stack:top().name,'map',version..' actual Menu/Runner returns to its native owner') + g,r=gameFor(version,{ITEM_117=3});g.save.g2KurtApricorns=0 + press(g,'down');press(g,'a') + T.eq(r.ctx.g2Var,0,version..' explicit CANCEL returns false') + T.eq(g.save.inventory.ITEM_117,3,version..' CANCEL preserves all bag items') + T.eq(g.save.g2KurtApricorns,0,version..' CANCEL cannot retain a previous quantity') + g,r=gameFor(version,{}) + T.eq(r:isRunning(),false,version..' empty native bag does not open an empty menu') + T.eq(r.ctx.g2Var,0,version..' empty native bag returns false') +end +do + local g,r=gameFor('crystal',{ITEM_117=3,ITEM_119=1}) + press(g,'a');press(g,'b') + T.eq(r:isRunning(),true,'Crystal quantity cancel returns to the actual apricorn menu') + T.eq(g.stack:top().items[1].label,'AUTHORED APRICORN x3','quantity cancel restores native list') + T.eq(g.save.inventory.ITEM_117,3,'quantity cancel does not silently take one apricorn') + press(g,'down');press(g,'a') + T.eq(#g.stack:top().items,1,'Crystal asks for quantity even when only one is held') + press(g,'a') + T.eq(r.ctx.g2Var,119,'selection after quantity cancel can choose a different actual item') + T.eq(g.save.inventory.ITEM_117,3,'reselection preserves the original apricorn stack') + T.eq(g.save.inventory.ITEM_119,nil,'reselection consumes the confirmed other apricorn') + g,r=gameFor('crystal',{ITEM_117=2});press(g,'a');g.save.inventory.ITEM_117=nil;press(g,'a') + T.eq(r.ctx.g2Var,0,'concurrent native bag removal cannot create a paid Kurt job') + T.eq(g.save.g2KurtApricorns,0,'failed revalidation cannot mint a job quantity') + g,r=gameFor('crystal',{ITEM_117=3});press(g,'a');press(g,'down');press(g,'down');press(g,'a') + local event=Flags.eventFlag(604);g.save.flags={[event]=true};g.save.inventory.ITEM_202=97 + local function collect() + local runner=Runner.new(g,g.overworld);runner:run(assert(VM.compile(g.data,'COLLECT')));return runner + end + collect() + T.eq(g.save.inventory.ITEM_202,97,'actual verbosegiveitemvar refuses the whole overflowing ball stack') + T.eq(g.save.flags[event],true,'failed native ball delivery preserves the recipe event') + g.save.inventory.ITEM_202=96;collect() + T.eq(g.save.inventory.ITEM_202,99,'actual native delivery grants the confirmed three-ball quantity') + T.eq(g.save.flags[event],false,'successful native script clears its real event') + collect();T.eq(g.save.inventory.ITEM_202,99,'native event guard prevents a duplicate ball collection') + g,r=gameFor('crystal',{ITEM_117=3,ITEM_119=1}) + press(g,'down');press(g,'a');press(g,'b') + T.eq(g.stack:top().index,2,'Crystal quantity cancel retains the selected apricorn cursor') + press(g,'b') + T.eq(r.ctx.g2Var,0,'cancelling the restored list cancels the native transaction') + T.eq(g.save.inventory.ITEM_119,1,'cancelling restored list cannot remove the selected apricorn') + g,r=gameFor('crystal',{ITEM_117=200});press(g,'a') + T.eq(#g.stack:top().items,99,'native Crystal quantity caps at99 for an oversized historical stack') + T.eq(g.stack:top().maxVisible,5,'quantity input uses bounded scrolling instead of extending offscreen') + press(g,'up');press(g,'a') + T.eq(g.save.g2KurtApricorns,99,'actual Menu wrap can confirm the native maximum quantity') + T.eq(g.save.inventory.ITEM_117,101,'capped native handover removes no unconfirmed excess') + local rows,inventory={},{} + for i=1,7 do + local key=string.format('ITEM_%03d',110+i);rows[i]={index=i,apricorn=key,ball='ITEM_202'};inventory[key]=1 + end + g,r=gameFor('crystal',inventory,rows) + T.eq(#g.stack:top().items,8,'full native list retains seven recipes and CANCEL') + T.eq(g.stack:top().maxVisible,4,'Crystal recipe list uses four visible scrolling rows') + press(g,'up') + T.eq(g.stack:top().index,8,'actual recipe cursor wraps to CANCEL') + T.eq(g.stack:top().scroll,4,'actual recipe scroll keeps wrapped CANCEL visible') + press(g,'a');T.eq(r.ctx.g2Var,0,'visible wrapped CANCEL exits the native special') +end +for _,version in ipairs({'prism','polishedcrystal'})do + local g,r=gameFor(version,{ITEM_085=2},{{index=1,apricorn='ITEM_085',ball='ITEM_202'}}) + press(g,'a');press(g,'b') + T.eq(r.ctx.g2Var,85,version..' unreviewed hack retains its legacy quantity-cancel protocol') + T.eq(g.save.g2KurtApricorns,1,version..' hack quantity-cancel behavior is not silently converted to Crystal') + T.eq(g.save.inventory.ITEM_085,1,version..' hack legacy selection still consumes its original single item') +end +Commands.show_text=oldText;require('src.core.Sound').play=oldSound;V.set(original) +T.finish('native ApricornBalls importer, actual Kurt menus, cartridge quantities and native ball delivery') From c27f836bb9beaf808b38d55592cb29725d80962e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:04:42 +0100 Subject: [PATCH 084/138] Keep historical apricorn fallback in actual cartridge item order --- docs/native-gen2-apricorn-selection.md | 11 ++++++----- src/world/Gen2Apricorns.lua | 3 +-- tests/engine/gen2_apricorn_native.lua | 4 +++- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/native-gen2-apricorn-selection.md b/docs/native-gen2-apricorn-selection.md index 907b5b1a0..9fc793b5c 100644 --- a/docs/native-gen2-apricorn-selection.md +++ b/docs/native-gen2-apricorn-selection.md @@ -8,10 +8,11 @@ and cannot cross a cartridge bank boundary. The map-script output includes recipe records and completeness metadata. Stock Gold/Silver/Crystal cache markers require reimport; other edition markers are unchanged. -Kurt's actual native special now consumes this imported order. The old fixed -item-ID order put several colours in the wrong positions. Historical stock -caches retain selection until reimport in native red/blue/yellow/green/white/ -black/pink order, without fabricating their missing ball mappings. +Kurt's actual native special now consumes the imported order, including +cartridge-specific table changes. Historical stock caches retain the original +native item order85/89/92/93/97/99/101 until reimport, without fabricating their +missing ball mappings. The earlier colour-based fallback reordering was +incorrect and is corrected here; the ROM-table import itself was unaffected. Gold/Silver take one apricorn without opening a quantity menu. Crystal asks for quantity, including a one-item stack; cancelling quantity returns to the @@ -27,7 +28,7 @@ script branches, event/working flags, daily resets and ball choice remain script-owned. This does not add a competing Kurt job model or rewrite a ROM script's ball operand merely because its selection table contains a pair. -Validation:198 synthetic checks use actual importer/map-script serialization, +Validation:216 synthetic checks use actual importer/map-script serialization, Gen2ScriptVM, ScriptRunner, StateStack, Menu input and Bag operations. They cover order/bounds, malformed data, cache readiness, Gold/Silver single-item handover, Crystal confirmation/reselection/cancellation/scrolling and a real diff --git a/src/world/Gen2Apricorns.lua b/src/world/Gen2Apricorns.lua index 54e7090d9..bc7268d4c 100644 --- a/src/world/Gen2Apricorns.lua +++ b/src/world/Gen2Apricorns.lua @@ -22,8 +22,7 @@ function M.rows(data) end -- Historical caches have no recipe table. Keep selection available until -- reimport; do not invent their missing ball mappings. - local version=Version.get() - local legacy=stock(version) and {85,89,101,99,93,92,97} or {85,89,92,93,97,99,101} + local legacy={85,89,92,93,97,99,101} for i,id in ipairs(legacy)do out[i]={index=i,apricorn=string.format('ITEM_%03d',id)}end return out end diff --git a/tests/engine/gen2_apricorn_native.lua b/tests/engine/gen2_apricorn_native.lua index 3ea58a195..5a15f1da0 100644 --- a/tests/engine/gen2_apricorn_native.lua +++ b/tests/engine/gen2_apricorn_native.lua @@ -63,7 +63,9 @@ for _,version in ipairs({'gold','silver','crystal'})do T.eq(bounds.complete,false,version..' pair cannot cross a cartridge bank boundary') T.eq(#bad,0,version..' bank-boundary failure has no invented pair') local fallback=A.rows({}) - T.eq(fallback[3].apricorn,'ITEM_101',version..' historical stock cache retains yellow-before-green native order') + for i,id in ipairs({85,89,92,93,97,99,101})do + T.eq(fallback[i].apricorn,string.format('ITEM_%03d',id),version..' historical fallback retains native cartridge item order at slot'..i) + end T.eq(fallback[3].ball,nil,version..' historical fallback does not invent missing ball mappings') local marker=markerFor(version) T.check(marker:find('gen2%-apricorn%-table%-v1:')~=nil,version..' stock native cache records the recipe import format') From 491d25aa2a885ec105f99b7f96293bbf7b65b3a5 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:31:36 +0100 Subject: [PATCH 085/138] Bind native GSC apricorn recipes to Kurt scripts and delivery events --- docs/native-gen2-apricorn-selection.md | 43 ++- src/import/RomExtractorGen2.lua | 16 + src/import/RomImporter.lua | 2 +- src/mods/ApricornContent.lua | 198 +++++++++++++ src/mods/Loader.lua | 29 +- src/mods/Schemas.lua | 7 + src/mods/UnifiedIds.lua | 85 ++++++ src/script/Gen2Commands.lua | 7 + src/script/Gen2ScriptVM.lua | 1 + src/world/Gen2ApricornPrograms.lua | 188 ++++++++++++ tests/engine/gen2_apricorn_content.lua | 276 ++++++++++++++++++ .../engine/gen2_apricorn_programs_import.lua | 59 ++++ 12 files changed, 906 insertions(+), 5 deletions(-) create mode 100644 src/mods/ApricornContent.lua create mode 100644 src/mods/UnifiedIds.lua create mode 100644 src/world/Gen2ApricornPrograms.lua create mode 100644 tests/engine/gen2_apricorn_content.lua create mode 100644 tests/engine/gen2_apricorn_programs_import.lua diff --git a/docs/native-gen2-apricorn-selection.md b/docs/native-gen2-apricorn-selection.md index 9fc793b5c..58d3b969e 100644 --- a/docs/native-gen2-apricorn-selection.md +++ b/docs/native-gen2-apricorn-selection.md @@ -39,9 +39,46 @@ Crystal Rev0 table probe finds seven complete rows, agrees with an independent byte decoder and leaves the original ROM unchanged. It is not a full import, GPU, original-story or Gold/Silver actual-ROM acceptance claim. -The complete `apricorns` registry and legacy Apricorns facade, authored recipe -events/ball-output binding, complete native daily/tree lifecycle and original -story/hardware acceptance remain open. Full Gen1 parity is unproven. +The `apricorns` registry now projects authored recipe records into the actual +decoded native Kurt programs. The importer queues four named roots and their +reachable scripts; incomplete or home-bank bindings are unavailable rather +than guessed. An additional stock-GSC cache revision requires that import. +The adapter derives each base recipe's event from its native selection and +collection branches, rather than assuming an event/colour order. For example: + +```lua +mod.content.apricorns:patch("RED_APRICORN", {ball="ULTRA_BALL"}) +``` + +All fields are `apricorn`, `ball`, `event` and `index`, as in the reference +registry. Items must resolve to real indexed GSC item bytes. Additional recipes +can use previously indexed native items and new word-sized events, excluding +the `$ffff` indirect-event sentinel. The selector still returns the real item +byte; it is never replaced with a fabricated seven-colour ordinal. Actual +native membership and event-dispatch branches, including overlapping roots, +consume the resulting table. Working/day flags, quantity, dialogue and +full-bag refusal stay in native scripts. Removed/rekeyed recipes stop new +jobs while unclaimed original pending events retain their original output. + +The observational `apricorn.converted` event is emitted only after a native +gift succeeds and its real event is cleared. API1/2 callbacks retain native +item IDs. The fork separately tests its API3 semantic callback boundary. It cannot veto or redirect +the gift. Recipe record views use semantic IDs through upstream API1/2 native content. The fork also tests its API2/3 +unified content modes; those additional APIs are not imported by this PR. Later item registrations resolve before +projection; invalid owners and affected consumers roll back before any native +script mutation. Unload restores unchanged owned rows/scripts, preserving +external edits. A new script table invalidates the VM's compiled-root cache. + +Synthetic integration checks cover actual Loader, VM, Menu, daily resets, +bag operations, observers, invalid/forward references, removal, unload and +generation isolation. A private read-only Crystal Rev0 probe also decodes +31 reachable native programs (264 instructions, zero desyncs), registers an +eighth recipe and executes the native selection, map reentry, working branch, +daily reset, full-bag refusal, three-ball delivery and exactly-once completion. +Only external text/audio IO is substituted; the original ROM is unchanged. +These checks do not establish full GPU/story or Gold/Silver actual-ROM +acceptance. The legacy Apricorns facade, full native daily/tree lifecycle and +remaining Gen1 feature parity remain open. Behavior was checked against the read-only Gen1 reference `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91` and primary disassembly sources: diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 74eae7b9f..ae6a3b668 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -3344,6 +3344,21 @@ function RomExtractorGen2:gen2ApricornBalls() end end +function RomExtractorGen2:gen2QueueApricornPrograms(pool) + if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end + local symbols={ask="Kurt1.AskApricorn",pending="Kurt1.GotLureBall", + gave="Kurt1.GaveKurtApricorns",cancel="Kurt1.Cancel"} + local found={} + for role,name in pairs(symbols)do + local at=self:symbol(name) + if not at or at.address<0x4000 or not self:gen2InRom(at.bank,at.address) then return nil end + found[role]=at + end + local out={} + for role,at in pairs(found)do out[role]=self:gen2QueueScript(at.bank,at.address,pool)end + return out +end + -- Marts is a dw pointer table; each mart is `db count`, the item ids, `db -1`. -- The table ends where its own first entry starts. function RomExtractorGen2:gen2Marts() @@ -8920,6 +8935,7 @@ function RomExtractorGen2:extractMapScripts() self:beginStage("Gen2 map scripts out.stds = self:gen2QueueStdScripts(pool) out.marts = self:gen2Marts() out.apricornBalls, out.apricornInfo = self:gen2ApricornBalls() + out.apricornPrograms = self:gen2QueueApricornPrograms(pool) out.spawns = self:gen2SpawnPoints(maps, keys) -- extractField runs after this stage and needs both of these: GSC has no -- Fly-warp list of its own, because Fly lands on the SPAWN POINT of the diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index bd4b8d88c..9e9dbef47 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1311,7 +1311,7 @@ local function markerFor(version) -- v8: field.gen2TradeAnim, GSC's own trade movie art (Gen2TradeArt). if version == "gold" or version == "silver" or version == "crystal" then revision = "gen2-field-encounters-v8:" - revision = revision .. "gen2-apricorn-table-v1:" + revision = revision .. "gen2-apricorn-table-v1:gen2-apricorn-programs-v1:" end -- field.tradeArt: the cable-trade movie's tiles out of the ROM -- (Gen1TradeArt); without it the trade ran on drawn stand-ins. diff --git a/src/mods/ApricornContent.lua b/src/mods/ApricornContent.lua new file mode 100644 index 000000000..cf674e37b --- /dev/null +++ b/src/mods/ApricornContent.lua @@ -0,0 +1,198 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +local Merge=require('src.mods.Merge') +local Ids=require('src.mods.UnifiedIds') +local Programs=require('src.world.Gen2ApricornPrograms') +local M={} +local mounted=setmetatable({},{__mode='k'}) +local previous=setmetatable({},{__mode='k'}) +local copy=Merge.deepCopy +local function equal(a,b) + if a==b then return true end + if type(a)~='table' or type(b)~='table'then return false end + for k,v in pairs(a)do if not equal(v,b[k])then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +local function finite(v,seen) + if type(v)=='number'then assert(v==v and math.abs(v)(ranks[owner] or 0)then owner=other end + problems[#problems+1]={owner=owner,message='apricorns: duplicate '..label..' in '..old..' and '..id} + end + for id,value in registry:each()do + local ok,r=pcall(row,loader,id,value,false) + if not ok then problems[#problems+1]={owner=registry.owners[id],message=tostring(r)} + else + if items[r.apricorn]then conflict(id,items[r.apricorn],'native apricorn item')else items[r.apricorn]=id end + if events[r.event]then conflict(id,events[r.event],'recipe event')else events[r.event]=id end + end + end + return problems +end +function M.prepare(loader,data) + local history=data and previous[data] + if history and data.map_scripts==history.pool then + local pool=history.pool + if pool.apricornBalls==history.rows and equal(history.rows,history.rowSnapshot)then + pool.apricornBalls=history.beforeRows + end + if pool.scripts==history.scripts then + for key,value in pairs(history.changed)do + if pool.scripts[key]==value and equal(value,history.snapshots[key])then + pool.scripts[key]=history.beforeScripts[key] + end + end + local same=true + for key,value in pairs(pool.scripts)do if history.beforeScripts[key]~=value then same=false;break end end + if same then + for key,value in pairs(history.beforeScripts)do if pool.scripts[key]~=value then same=false;break end end + end + if same then pool.scripts=history.beforeScripts end + end + end + if data then previous[data]=nil end + local desc,why + if loader:_generation()==2 then desc,why=Programs.describe(data)end + local state={data=data,description=desc,why=why,base={}} + if desc then + local ids=Ids.new(loader) + for _,r in ipairs(desc.base)do + local id=ids:read('items',r.apricorn) or r.apricorn + if state.base[id]then id=r.apricorn end + state.base[id]=copy(r) + end + end + mounted[loader]=state + loader.content.apricorns.base=function()return state.base end +end +function M.apply(loader,data) + if loader:_generation()~=2 or not data then return end + local registry=loader.content.apricorns + if not next(registry.ops) and not require('src.mods.Runtime').wants('apricorn.converted')then return end + local state=assert(mounted[loader]);local desc=state.description + if not desc and not next(registry.ops)then return end + assert(desc,state.why) + local pool=data.map_scripts + assert(pool and pool.scripts==desc.scripts,'apricorns: native Kurt programs changed during mod load') + local recipes,normalized=ordered(loader) + -- A removed/rekeyed native recipe stops accepting new work, but an old + -- event can still deliver its original job unless an active rule claims it. + local scripts,touched=Programs.project(desc,recipes,desc.base) + local history={pool=pool,beforeRows=pool.apricornBalls,rows=copy(recipes), + rowSnapshot=copy(recipes),beforeScripts=pool.scripts,scripts=scripts,changed={},snapshots={}} + for key in pairs(touched)do history.changed[key]=scripts[key];history.snapshots[key]=copy(scripts[key])end + -- Keep the folded view in actual native keys after deferred references + -- resolve. The generic post-merge reference audit reads the same view as + -- the native projection; the owner journals remain intact for rollback. + for id,value in pairs(normalized)do registry.cache[id]={value=value}end + -- All validation and IR construction precedes the first native mutation. + pool.apricornBalls=history.rows;pool.scripts=scripts + previous[data]=history +end +function M.api(loader,mod,native,generation) + local available=generation==2 + local reason=not available and 'unsupported: apricorns has no native target in this dataset' or nil + local registry=loader.content.apricorns + local function key(id) + if registry:get(id)~=nil or registry.ops[id]then return id end + local ok,item=pcall(nativeItem,loader,id) + if ok then + for other,value in registry:each()do if value.apricorn==item then return other end end + end + return id + end + local function view(value) + if not value then return nil end + local ids=Ids.new(loader);value=copy(value) + value.apricorn=ids:read('items',value.apricorn) + value.ball=ids:read('items',value.ball) + return value + end + local out={available=available,supports=function()return available,reason end} + function out:get(id)if not available then return nil,reason end;return view(native:get(key(id)))end + function out:has(id)return self:get(id)~=nil end + function out:each() + if not available then return function()end end + local nextRow=native:each() + return function()local id,value=nextRow();return id,view(value)end + end + for _,verb in ipairs({'register','override','patch','remove'})do + out[verb]=function(_,id,value) + if not available then return nil,reason end + assert(type(id)=='string' and id~='','apricorns: id is required');id=key(id) + local state=assert(mounted[loader]);assert(state.description,state.why) + if verb=='remove'then native:remove(id);return end + finite(value);assert(type(value)=='table' and value~=Merge.DELETE,'apricorns: expected recipe record') + for k in pairs(value)do assert(registry.spec.fields[k],'apricorns: unknown field '..tostring(k))end + local payload=copy(value) + local effective=verb=='patch' and Merge.deepMerge(copy(registry:get(id) or{}),payload,'record') or payload + effective=row(loader,id,effective,true) + ordered(loader,id,effective,true) + native[verb](native,id,effective) + return out:get(id) + end + end + return out +end +return M diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..6e87ec547 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -717,7 +717,7 @@ function Loader:_contentApi(mod, registry, deprecation) if apiLevel >= 2 then error(err, 0) end Logger.warn("[%s] %s", modId, err) end - return { + local api = { register = function(_, id, value) note() validate("register", id, value) @@ -750,6 +750,10 @@ function Loader:_contentApi(mod, registry, deprecation) return registry:each() end, } + if registry.name == "apricorns" then + return require("src.mods.ApricornContent").api(loader,mod,api,loader:_generation()) + end + return api end -- mod.commands is sugar over the commands registry; the engine's own verbs @@ -1326,6 +1330,7 @@ function Loader:load(data) end end end + require("src.mods.ApricornContent").prepare(self, data) -- vanilla content is registrations too, and they land before discovery so -- a mod's register collides with the engine's and has to say override require("src.mods.Builtins").install(self.content, data) @@ -1490,6 +1495,27 @@ function Loader:load(data) -- the commands registry is final once every entry chunk has run, so -- each map_scripts contribution's rows can be judged before they merge self:_validateScripts() + -- Resolve recipe/item forward references against all surviving entries, + -- before any native script projection or data merge. Repeat after owner + -- rollback so another recipe cannot retain an item supplied by a failed mod. + for _=1,#self.order+1 do + local problems=require("src.mods.ApricornContent").validate(self) + local failed=false + for _,problem in ipairs(problems)do + local mod=problem.owner and self.mods[problem.owner] + if mod and not mod.failed then self:_fail(mod,"failed",problem.message);failed=true end + end + if not failed then break end + self:_enforceDependencies() + for i=#self.loaded,1,-1 do + local mod=self.loaded[i] + if mod.failed then self:_rollback(mod.manifest.id);table.remove(self.loaded,i)end + end + for i=#self.order,1,-1 do + local mod=self.mods[self.order[i]] + if mod and mod.failed then table.remove(self.order,i)end + end + end -- merge: fold every touched id from its pristine base value and write it -- home, creating the Data namespace when the base modules never shipped -- one. A registry nobody wrote to -- engine included -- is skipped, so @@ -1551,6 +1577,7 @@ function Loader:load(data) stampAudioOwners(data, name, registry) end end + require("src.mods.ApricornContent").apply(self, data) -- dangling f.id references are attributed to the id's last writer; -- api 1 mods keep the warning-only compat path if data then diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 9014835cc..d5d216eab 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -1259,6 +1259,13 @@ R.text_pointers = { -- ------- persistence +R.apricorns = { + semantics="record", generations="2", + fields={apricorn=f.id("items"),ball=f.id("items"),event=f.int(0,65534),index=f.int(1,65535)}, + example='mod.content.apricorns:patch("RED_APRICORN", {ball="ULTRA_BALL"})', + notes="Native Gen2 recipe records bind real Kurt item selection, event flags and ball delivery. A current native Kurt program import and indexed GSC item IDs are required. New recipes preserve the selected native item byte; generated native branches retain original dialogue/day flags/refusal behavior.", +} + -- compose, keyed by the owning mod id: the runner walks each owner's chain -- in semver order against the versions recorded in the save R.migrations = { diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua new file mode 100644 index 000000000..50866a636 --- /dev/null +++ b/src/mods/UnifiedIds.lua @@ -0,0 +1,85 @@ +-- Resolve portable names through this boot's own data, never a hard-coded +-- National Dex/item-number table (ROM hacks can assign entirely different IDs). +local Ids = {} +local function slug(name) + if type(name) ~= "string" then return nil end + name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M") + :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2") + return name ~= "" and name or nil +end +local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" } +local function canonical(key, row, kind) + if kind == "pokemon" or kind == "moves" or kind == "items" then + if type(key) ~= "string" or key:match("^SPECIES_%d+$") + or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then + return aliases[slug(row.name)] or slug(row.name) + end + return aliases[key] or key + end + -- Trainer classes, individual trainers and map IDs are game-specific. + return type(key) == "number" and tostring(key) or key +end + +function Ids.new(loader) + local indexes = {} + local function index(kind) + if indexes[kind] then return indexes[kind] end + local out = { public = {}, native = {} } + indexes[kind] = out + local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind)) + -- Preserve identity/name metadata before the loader merges alias keys + -- into separate result tables. Presentation-name patches cannot rename IDs. + loader._unifiedIdBase = loader._unifiedIdBase or {} + local base = loader._unifiedIdBase[kind] + if not base then + base = {} + for key, row in pairs(registry.base and registry.base() or {}) do + if type(row) == "table" then + base[key] = { identity = row, id = canonical(key, row, kind) } + end + end + loader._unifiedIdBase[kind] = base + end + local groups = {} + for key, row in registry:each() do + if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then + -- Aliases pointing at one pristine record form one identity even + -- after an earlier native mod patched only one of its keys. + local identity = base[key] and base[key].identity or row + local group = groups[identity] + if not group then + local id = base[key] and base[key].id or canonical(key, row, kind) + if id then + group = { id = id, key = key, keys = {} } + groups[identity] = group + local previous = out.public[id] + -- false is an ambiguity marker, never last-writer-wins. + if previous ~= nil then out.public[id] = false else out.public[id] = group end + end + end + if group then + group.keys[#group.keys + 1] = key + out.native[key] = group + if type(key) == "number" then group.key = key end + end + end + end + return out + end + return { + resolve = function(_, kind, id) + local found = index(kind).public[id] + if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end + if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end + return found + end, + read = function(_, kind, id) + if id == nil or id == false or id == 0 or id == "" then return nil end + local found = index(kind).native[id] + return found and found.id or tostring(id) + end, + entries = function(_, kind) return index(kind).public end, + } +end +return Ids diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index adea141ad..a70b7cfa7 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -896,6 +896,13 @@ function Gen2Commands.scriptVarValue(ctx, var) return 1 end +function Commands.g2_apricorn_converted(ctx,apricorn,ball,event) + local Runtime=require("src.mods.Runtime") + if Runtime.wants("apricorn.converted")then + Runtime.emit("apricorn.converted",{apricorn=apricorn,ball=ball,event=event}) + end +end + -- Script_loadmem (25:$7495): write an immediate byte to a WRAM address. The -- same address-keyed mirror readmem/writemem use -- keyed by the raw -- address number exactly as they key it, so a script that pokes a byte here diff --git a/src/script/Gen2ScriptVM.lua b/src/script/Gen2ScriptVM.lua index 0d5c51cfe..d30338cc8 100644 --- a/src/script/Gen2ScriptVM.lua +++ b/src/script/Gen2ScriptVM.lua @@ -477,6 +477,7 @@ L.setevent = function(ir, s) else emit(s,{"set_flag",eventFlag(ir[2])}) end end L.clearevent = function(ir, s) emit(s, { "clear_flag", eventFlag(ir[2]) }) end +L.apricornconverted = function(ir,s)emit(s,{"g2_apricorn_converted",ir[2],ir[3],ir[4]})end L.checkflag = function(ir, s) emit(s, { "check_flag", engineFlag(ir[2]) }) end L.setflag = function(ir, s) emit(s, { "set_flag", engineFlag(ir[2]) }) end L.clearflag = function(ir, s) emit(s, { "clear_flag", engineFlag(ir[2]) }) end diff --git a/src/world/Gen2ApricornPrograms.lua b/src/world/Gen2ApricornPrograms.lua new file mode 100644 index 000000000..b80a71476 --- /dev/null +++ b/src/world/Gen2ApricornPrograms.lua @@ -0,0 +1,188 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +-- Project recipe records into decoded native Kurt programs. Dialogue, +-- working/day flags, quantity and bag-refusal branches remain native IR. +local Merge=require('src.mods.Merge') +local M={} +local copy=Merge.deepCopy +local function number(value) + return type(value)=='number' and value%1==0 and value>=0 and value<=65534 +end +local function item(key) + local n=type(key)=='string' and tonumber(key:match('^ITEM_(%d%d%d)$')) + return n and n>0 and n<255 and n or nil +end +M.item=item +local function selected(row) + return type(row)=='table' and row[1]=='special' and + (row[3]=='SelectApricornForKurt' or row[3]=='Special_SelectApricornForKurt' or row[3]=='select_apricorn') +end +local function eventBranch(scripts,label,gave) + local rows=scripts[label] + if type(rows)~='table' or #rows~=2 then return nil end + if type(rows[1])~='table' or type(rows[2])~='table' then return nil end + if rows[1][1]~='setevent' or not number(rows[1][2]) or + rows[2][1]~='sjump' or rows[2][2]~=gave then return nil end + return rows[1][2] +end +local function chain(rows,start,kind,known,count,ask) + local seen,targets={},{} + for n=0,count-1 do + local a,b=rows[start+n*2],rows[start+n*2+1] + if type(a)~='table' or type(b)~='table' or a[1]~=kind or + not known[a[2]] or seen[a[2]] or b[1]~='iftrue' or + type(b[2])~='string' or ask and b[2]~=ask then return nil end + seen[a[2]]=true;targets[a[2]]=b[2] + end + return targets +end +function M.describe(data) + local pool=data and data.map_scripts + local roles=pool and pool.apricornPrograms + local scripts=pool and pool.scripts + local recipes=pool and pool.apricornBalls + if type(roles)~='table' or type(scripts)~='table' or type(recipes)~='table' or #recipes==0 then + return nil,'apricorns: native Kurt programs require a current ROM import' + end + for _,role in ipairs({'ask','pending','gave','cancel'})do + if type(roles[role])~='string' or type(scripts[roles[role]])~='table' then + return nil,'apricorns: missing native Kurt program '..role + end + end + local ask=scripts[roles.ask];local zero + for i,row in ipairs(ask)do + if selected(row)then + if zero then return nil,'apricorns: ambiguous native selection' end + local nextRow=ask[i+1] + if type(nextRow)=='table' and nextRow[1]=='ifequal' and nextRow[2]==0 and nextRow[3]==roles.cancel then zero=i+1 end + end + end + if not zero then return nil,'apricorns: native selection/cancel signature differs' end + local cases,at={},zero+1 + while type(ask[at])=='table' and ask[at][1]=='ifequal' do + local row=ask[at];local id=row[2] + local event=eventBranch(scripts,row[3],roles.gave) + if type(id)~='number' or id%1~=0 or id<1 or id>254 or cases[id] or not event then + return nil,'apricorns: invalid native selection branch' + end + cases[id]=event;at=at+1 + end + if #ask~=at+1 or type(ask[at])~='table' or type(ask[at+1])~='table' or + ask[at][1]~='setevent' or not number(ask[at][2]) or + ask[at+1][1]~='sjump' or ask[at+1][2]~=roles.gave then + return nil,'apricorns: native default selection signature differs' + end + local base,items,events,default={},{},{},nil + for i,row in ipairs(recipes)do + if type(row)~='table'then return nil,'apricorns: invalid native recipe record' end + local id=item(row.apricorn) + if not id or not item(row.ball) or items[id] then return nil,'apricorns: invalid native recipe table' end + local event=cases[id] + if not event then + if default then return nil,'apricorns: ambiguous native default recipe' end + default=id;event=ask[at][2] + end + if events[event] then return nil,'apricorns: duplicate native recipe event' end + items[id]=true;events[event]=true + base[i]={apricorn=row.apricorn,ball=row.ball,event=event,index=i} + end + if not default then return nil,'apricorns: no native default recipe' end + for id in pairs(cases)do if not items[id] then return nil,'apricorns: native selection is outside recipe table' end end + local pending=scripts[roles.pending];local targets + for i=1,#pending do + local found=chain(pending,i,'checkevent',events,#base) + if found then + if targets then return nil,'apricorns: ambiguous native collection chain' end + targets=found + end + end + if not targets then return nil,'apricorns: native collection chain differs' end + local template,giftAt,clearAt + for _,row in ipairs(base)do + local rows=scripts[targets[row.event]] + if type(rows)~='table' then return nil,'apricorns: native delivery program is absent' end + local gift,clear + for i,op in ipairs(rows)do + if type(op)~='table'then return nil,'apricorns: invalid native delivery row' end + if op[1]=='verbosegiveitem' or op[1]=='verbosegiveitemvar' then + if gift or op[2]~=item(row.ball) then return nil,'apricorns: native ball output differs from recipe' end + gift=i + elseif op[1]=='clearevent' and op[2]==row.event then + if clear then return nil,'apricorns: ambiguous native event clear' end + clear=i + end + end + if not gift or not clear or clear<=gift then return nil,'apricorns: native delivery/event signature differs' end + if not template then template,giftAt,clearAt=rows,gift,clear end + end + return {roles=copy(roles),base=base,items=items,events=events,template=template, + giftAt=giftAt,clearAt=clearAt,zero=zero,scripts=scripts,ask=ask} +end +function M.project(description,recipes,retired) + local d=description;local scripts=d.scripts;local out={} + for key,value in pairs(scripts)do out[key]=value end + local choices,deliveries,generated={},{},{} + local used,generation={},1 + for label in pairs(scripts)do + if type(label)=='string'then + local n=tonumber(label:match('^engine:apricorn:(%d+):')) + if n then used[n]=true end + end + end + while used[generation]do generation=generation+1 end + local function key(kind,index) + local id='engine:apricorn:'..generation..':'..kind..':'..index + assert(scripts[id]==nil,'apricorns: generated script label collision') + return id + end + for i,row in ipairs(recipes)do + assert(item(row.apricorn) and item(row.ball) and number(row.event),'apricorns: invalid projected recipe') + local choose=key('choose',i) + choices[#choices+1]={'ifequal',item(row.apricorn),choose} + generated[choose]={{'setevent',row.event},{'sjump',d.roles.gave}} + end + local deliveryRules,events={},{} + for _,list in ipairs({recipes,retired or {}})do + for _,row in ipairs(list)do + if not events[row.event]then deliveryRules[#deliveryRules+1]=row;events[row.event]=true end + end + end + for i,row in ipairs(deliveryRules)do + local deliver=key('deliver',i) + deliveries[#deliveries+1]={'checkevent',row.event};deliveries[#deliveries+1]={'iftrue',deliver} + local body=copy(d.template);body[d.giftAt][2]=item(row.ball);body[d.clearAt][2]=row.event + table.insert(body,d.clearAt+1,{'apricornconverted',row.apricorn,row.ball,row.event}) + generated[deliver]=body + end + choices[#choices+1]={'sjump',d.roles.cancel} + local touched,memberCount,pendingCount={ },0,0 + for label,rows in pairs(scripts)do + if type(rows)=='table'then + local result,i,changed={},1,false + while i<=#rows do + local op=rows[i] + if chain(rows,i,'checkitem',d.items,#d.base,d.roles.ask) then + for _,row in ipairs(recipes)do + result[#result+1]={'checkitem',item(row.apricorn)} + result[#result+1]={'iftrue',d.roles.ask} + end + i=i+#d.base*2;changed=true;memberCount=memberCount+1 + elseif chain(rows,i,'checkevent',d.events,#d.base) then + for _,row in ipairs(deliveries)do result[#result+1]=copy(row)end + i=i+#d.base*2;changed=true;pendingCount=pendingCount+1 + else result[#result+1]=op;i=i+1 end + end + -- Ask is an actual queued root. Its original prefix includes the real + -- selected item and native zero/cancel branch; never substitute an ID. + if label==d.roles.ask then + result={};for n=1,d.zero do result[n]=copy(rows[n])end + for _,row in ipairs(choices)do result[#result+1]=copy(row)end + changed=true + end + if changed then out[label]=copy(result);touched[label]=true end + end + end + assert(memberCount>0 and pendingCount>0,'apricorns: native item/event consumers are absent') + for label,rows in pairs(generated)do out[label]=rows;touched[label]=true end + return out,touched +end +return M diff --git a/tests/engine/gen2_apricorn_content.lua b/tests/engine/gen2_apricorn_content.lua new file mode 100644 index 000000000..fb8b4f904 --- /dev/null +++ b/tests/engine/gen2_apricorn_content.lua @@ -0,0 +1,276 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +-- Authored IR, not cartridge scripts or dialogue. Exercise the real loader, +-- native VM, menus, flags, inventory and observer boundaries together. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner') +local Stack=require('src.core.StateStack');local Flags=require('src.script.Gen2Flags') +local P=require('src.world.Gen2ApricornPrograms');local Merge=require('src.mods.Merge') +local Commands=require('src.script.Commands');local Sound=require('src.core.Sound') +local oldText,oldSound=Commands.show_text,Sound.play +Commands.show_text=function()end;Sound.play=function()end -- external IO only +local function working(version)return version=='crystal' and 80 or 79 end +local function fixture(version) + local d=T.fixtures.fresh() + for i=1,7 do + local key=string.format('ITEM_%03d',110+i) + d.items[key]={id=key,name='APRICORN '..i,pocket='ITEM'} + end + d.items.ITEM_173={id='ITEM_173',name='BERRY',pocket='ITEM'} + for i=201,202 do + local key=string.format('ITEM_%03d',i) + d.items[key]={id=key,name='BALL '..i,pocket='BALL',ball=true} + end + local ask={{'special',86,'SelectApricornForKurt'},{'ifequal',0,'CANCEL'}} + local scripts={ASK=ask,GAVE={{'setflag',working(version)},{'end'}},CANCEL={{'setval',0},{'end'}}, + WAIT={{'setval',98},{'end'}},FULL={{'setval',99},{'end'}},DONE={{'end'}}} + local pending,rows={},{} + for i=1,7 do + local key=string.format('ITEM_%03d',110+i) + rows[i]={apricorn=key,ball='ITEM_201',index=i} + if i>1 then ask[#ask+1]={'ifequal',110+i,'CHOOSE'..i}end + scripts['CHOOSE'..i]={{'setevent',599+i},{'sjump','GAVE'}} + pending[#pending+1]={'checkevent',599+i};pending[#pending+1]={'iftrue','DELIVER'..i} + scripts['DELIVER'..i]={{'checkflag',working(version)},{'iftrue','WAIT'}, + version=='crystal' and {'verbosegiveitemvar',201,22} or {'verbosegiveitem',201,1}, + {'iffalse','FULL'},{'clearevent',599+i},{'sjump','DONE'}} + end + ask[#ask+1]={'setevent',600};ask[#ask+1]={'sjump','GAVE'} + for i=1,7 do pending[#pending+1]={'checkitem',110+i};pending[#pending+1]={'iftrue','ASK'}end + pending[#pending+1]={'end'} + scripts.PENDING=pending;scripts.OVERLAPPING=Merge.deepCopy(pending) + d.map_scripts={scripts=scripts,apricornBalls=rows,apricornInfo={complete=true}, + apricornPrograms={ask='ASK',pending='PENDING',gave='GAVE',cancel='CANCEL'}} + return d +end +local function load(d,sources,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local files,paths={},{} + for _,entry in ipairs(sources or{})do + local id,source=entry[1],entry[2];local path='mods/'..id + files[path..'/manifest.json']=Json.encode({id=id,name=id,version='1.0.0',api=mode and mode[1] or 2, + content_format=mode and mode[2] or 'native',entry='main.lua',affects_link=false}) + files[path..'/main.lua']='local mod=...;mod.exports.api=mod;'..source;paths[#paths+1]=path + end + return T.sdk.loadMods(paths,{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +local function game(d,inventory) + local stack=setmetatable({},{__index=Stack});stack:init();stack:push({name='map'}) + return {data=d,save={inventory=inventory or{},flags={}},stack=stack,overworld={}} +end +local function run(g,key) + local runner=Runner.new(g,g.overworld);runner:run(assert(VM.compile(g.data,key)));return runner +end +local function press(g,key) + g.input={wasPressed=function(_,k)return k==key end};g.stack:update(0) +end +local recipe=[[{apricorn='BERRY',ball='BALL_202',event=5000,index=8}]] +local source=[[ + local p=mod.content["apricorns"] + assert(p:supports()) + assert(p:get('APRICORN_1').apricorn=='APRICORN_1') + local v=p:get('APRICORN_1');v.ball='LEAK' + assert(p:get('APRICORN_1').ball=='BALL_201') + local n=0;for id,row in p:each()do n=n+1;row.event=65534 end;assert(n==7) + p:register('BERRY_RECIPE',]]..recipe..[[) + p:patch('APRICORN_1',{ball='BALL_202',event=5100,index=1}) + mod.exports.seen={} + mod.exports.stop=mod.events:on("apricorn.converted",function(e) + mod.exports.seen[#mod.exports.seen+1]={apricorn=e.apricorn,ball=e.ball,event=e.event} + e.apricorn='LEAK';e.ball='LEAK';e.event=123;return false + end) +]] +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture(version);local originalScripts=d.map_scripts.scripts + local originalRows=d.map_scripts.apricornBalls + local oldCompiled=assert(VM.compile(d,'PENDING')) + local r=load(d,{{'recipes',source}},mode);local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' native registry and event registration load') + if #r.errors==0 then + local e=r.loader.exports.recipes + T.eq(#d.map_scripts.apricornBalls,8,label..' actual selector receives all eight native recipes') + T.eq(d.map_scripts.apricornBalls[8].apricorn,'ITEM_173',label..' semantic input resolves to a real native item byte') + T.check(d.map_scripts.scripts~=originalScripts,label..' projection changes VM cache identity') + T.check(VM.compile(d,'PENDING')~=oldCompiled,label..' an already compiled root is recompiled') + T.eq(originalScripts.ASK[3][2],112,label..' original native branches are not overwritten') + local g=game(d,{ITEM_173=3}) + local runner=run(g,'OVERLAPPING') + T.eq(g.stack:top().items[1].label,'BERRY x3',label..' overlapping native membership root accepts the eighth item') + press(g,'a') + if version=='crystal'then press(g,'down');press(g,'down');press(g,'a')end + local quantity=version=='crystal' and 3 or 1 + T.eq(runner:isRunning(),false,label..' actual native selection finishes') + T.eq(runner.ctx.g2Var,173,label..' VM retains actual item byte without fake ordinal remapping') + T.eq(g.save.g2KurtApricorns,quantity,label..' native handover quantity survives projection') + T.eq(g.save.flags[Flags.eventFlag(5000)],true,label..' native selection sets the authored word-sized event') + T.eq(g.save.flags[Flags.scriptFlag(working(version))],true,label..' native GAVE working flag is preserved') + run(g,'PENDING') + T.eq(g.save.inventory.ITEM_202,nil,label..' working native branch defers delivery') + T.eq(#e.seen,0,label..' waiting job cannot emit a converted event') + require('src.script.Gen2Daily').onNewDay(g.save) + T.eq(g.save.flags[Flags.scriptFlag(working(version))],nil,label..' actual daily reset clears native work') + g.save.inventory.ITEM_202=99;run(g,'PENDING') + T.eq(g.save.flags[Flags.eventFlag(5000)],true,label..' actual bag refusal retains the job event') + T.eq(#e.seen,0,label..' refused native gift cannot emit a converted event') + g.save.inventory.ITEM_202=nil;run(g,'PENDING') + T.eq(g.save.inventory.ITEM_202,quantity,label..' actual VM gives the authored ball in confirmed quantity') + T.eq(g.save.inventory.ITEM_201,nil,label..' template ball is not accidentally awarded') + T.eq(g.save.flags[Flags.eventFlag(5000)],false,label..' real native clear precedes completion') + T.eq(#e.seen,1,label..' successful native delivery emits exactly once') + local observed=e.seen[1] + T.eq(observed.apricorn,mode[1]==3 and 'BERRY' or 'ITEM_173',label..' observer receives the API-specific item identity') + T.eq(observed.ball,mode[1]==3 and 'BALL_202' or 'ITEM_202',label..' observer receives the actual delivered ball') + T.eq(observed.event,5000,label..' observer receives the actual cleared event') + run(g,'PENDING');T.eq(#e.seen,1,label..' cleared event prevents duplicate completion') + g=game(d,{ITEM_173=3});runner=run(g,'PENDING');press(g,'a') + if version=='crystal'then + press(g,'b');T.eq(g.save.inventory.ITEM_173,3,label..' quantity cancellation consumes nothing') + press(g,'b') + else -- Gold/Silver have already confirmed their one-item transaction + T.eq(g.save.inventory.ITEM_173,2,label..' Gold/Silver remain one-at-a-time') + end + if version=='crystal'then + T.eq(g.save.flags[Flags.eventFlag(5000)],nil,label..' cancellation cannot create a pending job') + end + local api=e.api.content.apricorns + T.check(not pcall(function()api:patch('APRICORN_1',{event=5300})end),label..' frozen content refuses late writes') + end + finish(r) + local clean=load(d) + T.eq(#clean.errors,0,label..' unloading reloads cleanly') + T.eq(d.map_scripts.scripts,originalScripts,label..' disabling restores original native script table') + T.eq(d.map_scripts.apricornBalls,originalRows,label..' disabling restores original native recipe table') + finish(clean) + end +end +V.set('crystal') +for _,bad in ipairs({ + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=65535,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=-1,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=0/0,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=5000,index=math.huge})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=601,index=8})", + "p:register('BAD',{apricorn='APRICORN_2',ball='BALL_202',event=5000,index=8})", + "p:register('BAD',{apricorn='MISSING',ball='BALL_202',event=5000,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='MISSING',event=5000,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=5000,index=8,typo=true})", + "p:patch('APRICORN_1',{apricorn=mod.DELETE})", + "local x={};x.apricorn=x;p:register('BAD',x)", + "p:register('BAD',"..recipe..");error('entry failure')", +})do + local d=fixture('crystal');local scripts,rows=d.map_scripts.scripts,d.map_scripts.apricornBalls + local r=load(d,{{'broken',"local p=mod.content["apricorns"];p:patch('APRICORN_1',{event=5100});"..bad}}) + T.check(#r.errors>0,'invalid authored recipe is attributed to its owner: '..bad) + T.eq(d.map_scripts.scripts,scripts,'failed owner cannot leak projected native scripts') + T.eq(d.map_scripts.apricornBalls,rows,'failed owner rolls back prior valid recipe patches') + finish(r) +end +do + local d=fixture('crystal') + local r=load(d,{{'a_recipe',[[mod.content.apricorns:register('FORWARD', + {apricorn='LATE_FRUIT',ball='BALL_202',event=5200,index=8})]]}, + {'z_item',[[mod.content.items:register('ITEM_174',{id='ITEM_174',name='LATE FRUIT',price=0,pocket='ITEM'})]]}}) + T.eq(#r.errors,0,'semantic native item references may be provided by a later mod') + T.eq(d.map_scripts.apricornBalls[8] and d.map_scripts.apricornBalls[8].apricorn,'ITEM_174', + 'forward item reference resolves before actual native projection') + local g=game(d,{ITEM_174=1});local runner=run(g,'PENDING');press(g,'a');press(g,'a') + T.eq(runner.ctx.g2Var,174,'later native item executes through actual selection byte') + T.eq(g.save.flags[Flags.eventFlag(5200)],true,'forward recipe enters its actual pending event') + finish(r) +end +do + local d=fixture('crystal');local scripts,rows=d.map_scripts.scripts,d.map_scripts.apricornBalls + local r=load(d,{{'a_recipe',[[mod.content.apricorns:register('FORWARD', + {apricorn='LATE_FRUIT',ball='BALL_202',event=5200,index=8})]]}, + {'z_item',[[mod.content.items:register('ITEM_174',{id='ITEM_174',name='LATE FRUIT',price=0,pocket='ITEM'});error('supplier failed')]]}}) + T.check(#r.errors>=2,'failed item supplier also attributes unresolved recipe to its owner') + T.eq(d.items.ITEM_174,nil,'failed supplier leaves no actual native item behind') + T.eq(d.map_scripts.scripts,scripts,'failed forward recipe leaves native script identity intact') + T.eq(d.map_scripts.apricornBalls,rows,'failed forward recipe leaves native table intact') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,{{'no_byte',[[ + mod.content.items:register('AUTHORED_FRUIT',{id='AUTHORED_FRUIT',name='Authored fruit',price=0,pocket='ITEM'}) + mod.content.apricorns:register('UNINDEXED', + {apricorn='AUTHORED_FRUIT',ball='BALL_202',event=5200,index=8}) + ]]}}) + T.check(#r.errors>0,'an arbitrary semantic item cannot fabricate a native ROM byte') + T.eq(d.items.AUTHORED_FRUIT,nil,'unindexed recipe failure rolls back its item registration') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,{{'a_first',[[ + mod.content.apricorns:register('FIRST',{apricorn='BERRY',ball='BALL_202',event=5200,index=8}) + ]]},{'z_conflict',[[ + mod.content.apricorns:register('SECOND',{apricorn='ITEM_173',ball='ITEM_201',event=5300,index=9}) + ]]}}) + T.check(#r.errors>0,'semantic/native aliases cannot register the same byte twice') + T.eq(d.map_scripts.apricornBalls[8] and d.map_scripts.apricornBalls[8].event,5200, + 'later conflicting owner rolls back without discarding the earlier valid owner') + T.eq(#d.map_scripts.apricornBalls,8,'duplicate alias never enters native menu') + finish(r) +end +do + local d=fixture('crystal');local scripts,rows=d.map_scripts.scripts,d.map_scripts.apricornBalls + local r=load(d,{{'a_supplier',[[ + mod.content.items:register('ITEM_174',{id='ITEM_174',name='LATE FRUIT',price=0}) + mod.content.apricorns:register('BAD',{apricorn='BERRY',ball='MISSING',event=5200,index=8}) + ]]},{'b_consumer',[[mod.content.apricorns:register('FORWARD', + {apricorn='LATE_FRUIT',ball='BALL_202',event=5300,index=9})]]}}) + T.check(#r.errors>=2,'late recipe failure revalidates consumers after its item supplier rolls back') + T.eq(d.items.ITEM_174,nil,'late validation rolls back supplier item content') + T.eq(d.map_scripts.scripts,scripts,'cascading forward-reference failure leaves no projected script table') + T.eq(d.map_scripts.apricornBalls,rows,'cascading failure leaves pristine native rows') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,{{'removed',[[mod.content.apricorns:remove('APRICORN_1') + mod.exports.count=0;mod.events:on("apricorn.converted",function()mod.exports.count=mod.exports.count+1 end)]]}}) + T.eq(#r.errors,0,'removal projects native membership and pending branches') + T.eq(#d.map_scripts.apricornBalls,6,'removed recipe cannot be newly selected') + local g=game(d,{ITEM_111=2});run(g,'PENDING') + T.eq(g.stack:top().name,'map','removed item no longer opens the native selector') + g.save.flags[Flags.eventFlag(600)]=true;g.save.g2KurtApricorns=2;run(g,'PENDING') + T.eq(g.save.inventory.ITEM_201,2,'removed recipe still delivers an already pending native job') + T.eq(r.loader.exports.removed.count,1,'retired pending job completes only after native gift') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,{{'recipes',source}}) + local scripts=d.map_scripts.scripts;local label + for key in pairs(scripts)do if key:match('^engine:apricorn:')then label=key;break end end + scripts[label][1]={'setval',77};local external=scripts[label] + d.map_scripts.apricornBalls[1].ball='ITEM_201' + finish(r);local clean=load(d) + T.eq(d.map_scripts.scripts[label],external,'reload preserves an externally edited generated label') + T.eq(d.map_scripts.apricornBalls[1].ball,'ITEM_201','reload preserves externally edited native rows') + finish(clean) +end +for _,version in ipairs({'red','firered','emerald','heartgold'})do + V.set(version);local d=fixture('crystal');local scripts,rows=d.map_scripts.scripts,d.map_scripts.apricornBalls + local r=load(d,{{'isolation',[[local p=mod.content["apricorns"];assert(not p:supports()) + assert(p:get('APRICORN_1')==nil);p:register('NO_WRITE',]]..recipe..[[) + assert(not p:supports())]]}}) + T.eq(#r.errors,0,version..' unsupported handle is readable and reports unavailable') + T.eq(d.map_scripts.scripts,scripts,version..' cannot mutate a lookalike native script pool') + T.eq(d.map_scripts.apricornBalls,rows,version..' cannot acquire Gen2 native recipes') + finish(r) +end +V.set('crystal') +do + local d=fixture('crystal');d.map_scripts.apricornPrograms=nil + local r=load(d,{{'oldcache',"mod.content.apricorns:register('NEW',"..recipe..")"}}) + T.check(#r.errors>0,'old cache reports missing native Kurt bindings to the writing owner') + finish(r) + r=load(d,{{'observer',"mod.events:on("apricorn.converted",function()end)"}}) + T.eq(#r.errors,0,'an observer alone cannot prevent an older cache from booting') + finish(r) +end +Commands.show_text=oldText;Sound.play=oldSound;V.set(original) +T.finish('native apricorn registry, actual Kurt VM transactions, completion observers and reload isolation') diff --git a/tests/engine/gen2_apricorn_programs_import.lua b/tests/engine/gen2_apricorn_programs_import.lua new file mode 100644 index 000000000..4154e8726 --- /dev/null +++ b/tests/engine/gen2_apricorn_programs_import.lua @@ -0,0 +1,59 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local E=require('src.import.RomExtractorGen2') +local V=require('src.core.GameVersion');local original=V.get() +local Importer=require('src.import.RomImporter');local P=require('src.world.Gen2ApricornPrograms') +local markerFor +for i=1,100 do local name,value=debug.getupvalue(Importer.readyReport,i) + if name=='markerFor'then markerFor=value;break end;if not name then break end +end +assert(markerFor) +local names={'Kurt1.AskApricorn','Kurt1.GotLureBall','Kurt1.GaveKurtApricorns','Kurt1.Cancel'} +local function extractor(version,missing,outside) + local symbols={} + for i,name in ipairs(names)do if i~=missing then + symbols[name]={1,outside==i and 0x8000 or outside==-i and 0x100 or 0x4000+i} + end end + return E.new(string.rep('\0',0x8000),version,{symbols=symbols}) +end +local function pool()return{scripts={},queue={}}end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local q=pool();local e=extractor(version) + local roles=e:gen2QueueApricornPrograms(q) + T.eq(#q.queue,4,version..' all native Kurt entry points enter the real decoder queue') + for _,role in ipairs({'ask','pending','gave','cancel'})do + T.check(type(roles[role])=='string',version..' '..role..' retains the actual native script key') + end + e:gen2QueueApricornPrograms(q) + T.eq(#q.queue,4,version..' repeated roots do not duplicate native decode work') + for i=1,4 do + q=pool();T.eq(extractor(version,i):gen2QueueApricornPrograms(q),nil,version..' missing role cannot advertise partial bindings') + T.eq(#q.queue,0,version..' missing role is discovered before any queue mutation') + q=pool();T.eq(extractor(version,nil,i):gen2QueueApricornPrograms(q),nil,version..' invalid bank address is rejected') + T.eq(#q.queue,0,version..' invalid role cannot add a partial native queue') + q=pool();T.eq(extractor(version,nil,-i):gen2QueueApricornPrograms(q),nil,version..' home-bank engine code is not native script bytecode') + T.eq(#q.queue,0,version..' home-bank rejection precedes queue mutation') + end + local marker=markerFor(version) + T.check(marker:find('gen2%-apricorn%-programs%-v1:')~=nil,version..' cache includes decoded Kurt binding revision') + love.filesystem.write(V.cachePrefix(version)..'rom-cache.complete',marker:gsub('gen2%-apricorn%-programs%-v1:','')) + local report=Importer.readyReport(version) + T.eq(report.ok,false,version..' old recipe-only cache cannot bypass native program reimport') + T.check(report.why:find('another version',1,true)~=nil,version..' cache revision is checked before generated-file readiness') +end +for _,version in ipairs({'prism','polishedcrystal','red','emerald'})do + local q=pool();T.eq(extractor(version):gen2QueueApricornPrograms(q),nil,version..' foreign layout has no guessed Kurt bindings') + T.eq(#q.queue,0,version..' foreign decoder queue is unchanged') + T.check(not markerFor(version):find('gen2%-apricorn%-programs%-v1:'),version..' unrelated import marker is unchanged') +end +for _,ask in ipairs({{},{{'special',86,'SelectApricornForKurt'},{'ifequal',0,'CANCEL'},false}, + {{'special',86,'SelectApricornForKurt'},{'ifequal',0,'CANCEL'},false,{'sjump','GAVE'}}})do + local d={map_scripts={apricornPrograms={ask='ASK',pending='PENDING',gave='GAVE',cancel='CANCEL'}, + scripts={ASK=ask,PENDING={},GAVE={},CANCEL={}},apricornBalls={{apricorn='ITEM_111',ball='ITEM_201'}}}} + local ok,description,why=pcall(P.describe,d) + T.eq(ok,true,'malformed native selection reports a binding error without crashing mod-free load') + T.eq(description,nil,'malformed native selection cannot produce a writable native projection') + T.check(type(why)=='string','malformed selection explains the unavailable native target') +end +V.set(original) +T.finish('native Kurt program import queue, completeness, cache revisions and malformed signatures') From d9933b8e51820ccbef7cec0d3f38c9c1156229ed Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:35:05 +0100 Subject: [PATCH 086/138] Preserve nested test-source quoting in native integration cases --- tests/engine/gen2_apricorn_content.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/engine/gen2_apricorn_content.lua b/tests/engine/gen2_apricorn_content.lua index fb8b4f904..c3783f601 100644 --- a/tests/engine/gen2_apricorn_content.lua +++ b/tests/engine/gen2_apricorn_content.lua @@ -164,7 +164,7 @@ for _,bad in ipairs({ "p:register('BAD',"..recipe..");error('entry failure')", })do local d=fixture('crystal');local scripts,rows=d.map_scripts.scripts,d.map_scripts.apricornBalls - local r=load(d,{{'broken',"local p=mod.content["apricorns"];p:patch('APRICORN_1',{event=5100});"..bad}}) + local r=load(d,{{'broken',"local p=mod.content.apricorns;p:patch('APRICORN_1',{event=5100});"..bad}}) T.check(#r.errors>0,'invalid authored recipe is attributed to its owner: '..bad) T.eq(d.map_scripts.scripts,scripts,'failed owner cannot leak projected native scripts') T.eq(d.map_scripts.apricornBalls,rows,'failed owner rolls back prior valid recipe patches') @@ -268,7 +268,7 @@ do local r=load(d,{{'oldcache',"mod.content.apricorns:register('NEW',"..recipe..")"}}) T.check(#r.errors>0,'old cache reports missing native Kurt bindings to the writing owner') finish(r) - r=load(d,{{'observer',"mod.events:on("apricorn.converted",function()end)"}}) + r=load(d,{{'observer',"mod.events:on('apricorn.converted',function()end)"}}) T.eq(#r.errors,0,'an observer alone cannot prevent an older cache from booting') finish(r) end From e5886cb49b7762c1db1e9de2d3baf2e05b036b8f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:38:34 +0100 Subject: [PATCH 087/138] Retire unchanged recipe records individually during native mod reload --- src/mods/ApricornContent.lua | 46 ++++++++++++++++++++++---- tests/engine/gen2_apricorn_content.lua | 12 +++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/mods/ApricornContent.lua b/src/mods/ApricornContent.lua index cf674e37b..bd55a63be 100644 --- a/src/mods/ApricornContent.lua +++ b/src/mods/ApricornContent.lua @@ -66,8 +66,8 @@ local function ordered(loader,overrideId,overrideRow,defer) if a.value.index~=b.value.index then return a.value.index Date: Mon, 26 Oct 2026 06:00:25 +0100 Subject: [PATCH 088/138] Bind native GSC landmarks to map markers, names and edition boundaries --- docs/native-gen2-landmarks.md | 70 ++++++++ src/import/RomExtractorGen2.lua | 13 +- src/import/RomImporter.lua | 2 +- src/mods/LandmarkContent.lua | 134 ++++++++++++++ src/mods/Loader.lua | 8 +- src/mods/Schemas.lua | 7 + src/ui/PokegearMenu.lua | 3 +- src/ui/TownMap.lua | 13 +- src/world/Gen2Landmarks.lua | 76 ++++++++ tests/engine/gen2_landmark_content.lua | 232 +++++++++++++++++++++++++ 10 files changed, 545 insertions(+), 13 deletions(-) create mode 100644 docs/native-gen2-landmarks.md create mode 100644 src/mods/LandmarkContent.lua create mode 100644 src/world/Gen2Landmarks.lua create mode 100644 tests/engine/gen2_landmark_content.lua diff --git a/docs/native-gen2-landmarks.md b/docs/native-gen2-landmarks.md new file mode 100644 index 000000000..66898dff0 --- /dev/null +++ b/docs/native-gen2-landmarks.md @@ -0,0 +1,70 @@ +# Native GSC landmark records + +`mod.content.landmarks` projects semantic records into the native numeric +`field.townMap.landmarks` table. Pokegear, TownMap and the native +`getcurlandmarkname` / `getlandmarkname` script commands read that same table. +It does not create a competing map or name service. + +```lua +mod.content.landmarks:patch("LANDMARK_ROUTE_29", {name="CUSTOM ROAD", x=80}) +mod.content.landmarks:register("LANDMARK_EXTRA", { + name="CUSTOM COVE", x=100, y=104, index=200, +}) +``` + +The shape matches the reference: optional `id`, required `name`, `x`, `y` +and `index`. The native map-header index is a byte, including zero. +Coordinates retain the cartridge's hardware OAM units. They are not tile +cells: the existing TownMap marker-centre and Pokegear sprite-origin +corrections still apply once at drawing time. Changing a display name does +not rename the landmark identity. + +The import records constant identities and the actual native row indices. +Gold/Silver have95 rows; Crystal has96, including byte95 for the ship. The +previous fixed95-row reader omitted Crystal's last row. Crystal's Battle +Tower also shifts the first Kanto byte from46 to47. Pokegear no longer uses +Crystal's literal boundary for Gold/Silver; both map readers consume +the authored native Pallet landmark index, retaining imported-map-header +fallbacks for older caches. An explicit manifest identity order overrides +the stock order. Unreviewed hack layouts do not acquire guessed stock names. +Stock caches require the `gen2-landmark-identities-v1` revision and reimport. + +Valid native names survive a failed/absent map-picture decode without +inventing map artwork. Missing coordinate data is not replaced with guessed +positions. A new indexed authored name can also serve native scripts in a +dataset with no town-map art. + +Upstream API1/2 native content uses detached record views. The fork separately +tests API2/3 unified content and its radio helper; those layers are not +imported by this standalone PR. +Failed entries roll back before native projection; content freezes after +loading. Numeric/string native aliases cannot resurrect a removed slot. +Index collisions follow the reference's deterministic rules: the native +identity wins its original slot, otherwise the lexically earlier ID wins. +The reference order remains a fallback for unclaimed original slots after +moving a record. Native map-header bytes are not implicitly rewritten. +Unload restores unchanged owned slots and removes exclusively created +namespaces, preserving external edits. Other generations report this native +target unavailable instead of mutating a similar-looking table. + +Validation exercises the real mod loader, ROM reader/text decoder, native VM, +TownMap controller and Pokegear draw commands. Image loading/drawing uses an +authored IO fixture; the independent existing town-map pixel decoder suite +still runs. A private read-only Crystal Rev0 probe reads96 actual records, +compares every raw coordinate pair with an independent byte reader and +executes the ship's authored name through the real VM. The original ROM is +unchanged. This is not fresh GPU, full original-story, Windows hardware, +actual Gold/Silver ROM or complete Gen1 parity acceptance. + +Reference behavior: read-only Gen1 source +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, `Schemas.landmarks` and +`core/gen2/Nests.lua`. Constant identities and order were checked against +[pokegold landmark constants](https://github.com/pret/pokegold/blob/master/constants/landmark_constants.asm) +and [pokecrystal landmark constants](https://github.com/pret/pokecrystal/blob/master/constants/landmark_constants.asm). +Only compatibility identifiers/order and original adapter logic are included; +no cartridge coordinates, dialogue, graphics or private mod content is shipped. +Original contributions by Roxas2712; retain existing engine/third-party rights +and credits. The [specific permission for Cedric / UNDERdecoded, with visible +credit](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md) +applies to offered contributions; it does not license cartridge data or +other authors' work. diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 90b74029e..f79895141 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -11418,7 +11418,10 @@ function RomExtractorGen2:gen2Landmarks() if not (sym and self.rom) then return nil end local charmap = self:readSourceTable("charmap") local out = {} - for id = 0, GEN2_LANDMARK_COUNT - 1 do + local order,why=require("src.world.Gen2Landmarks").order(self.version,self.manifest) + if why then return nil end + local count=#order>0 and #order or self:layout("townMapLandmarkCount",GEN2_LANDMARK_COUNT) + for id = 0, math.min(256,count) - 1 do pcall(function() local row = sym.address + id * GEN2_LANDMARK_BYTES local name = self:decodeGen2TextAt(sym.bank, @@ -11426,6 +11429,7 @@ function RomExtractorGen2:gen2Landmarks() -- $1F is the landmark line break (TownMap_ConvertLineBreakCharacters) name = name:gsub("{BYTE:1F}", "\n") out[id] = { + id = order[id+1], index = id, x = self.rom:byte(sym.bank, row), y = self.rom:byte(sym.bank, row + 1), name = name ~= "" and name or nil, @@ -11436,12 +11440,15 @@ function RomExtractorGen2:gen2Landmarks() end function RomExtractorGen2:gen2TownMap() + local landmarks = self:gen2Landmarks() local johto = self:gen2TownMapImage("JohtoMap", "ui/town_map_johto.png") - if not johto then return nil end + -- Native name scripts do not depend on a successfully decoded map picture. + if not johto and not landmarks then return nil end local out = { johto = johto, kanto = self:gen2TownMapImage("KantoMap", "ui/town_map_kanto.png"), - landmarks = self:gen2Landmarks(), + landmarks = landmarks, + landmarkOrder = require("src.world.Gen2Landmarks").order(self.version,self.manifest), } -- Player "you are here" icon: ChrisSpriteGFX facing-down (4 tiles = 16x16) -- tinted with PAL_OW_RED so it is not a greyscale silhouette on the map. diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index cbd913e5a..4903915fe 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1310,7 +1310,7 @@ local function markerFor(version) -- Only these three caches need rebuilding; other markers remain stable. -- v8: field.gen2TradeAnim, GSC's own trade movie art (Gen2TradeArt). if version == "gold" or version == "silver" or version == "crystal" then - revision = "gen2-field-encounters-v8:" + revision = "gen2-field-encounters-v8:gen2-landmark-identities-v1:" end -- field.tradeArt: the cable-trade movie's tiles out of the ROM -- (Gen1TradeArt); without it the trade ran on drawn stand-ins. diff --git a/src/mods/LandmarkContent.lua b/src/mods/LandmarkContent.lua new file mode 100644 index 000000000..1b45322bb --- /dev/null +++ b/src/mods/LandmarkContent.lua @@ -0,0 +1,134 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-landmarks.md. +-- Semantic landmark records project into the native town-map number space. +local Merge=require('src.mods.Merge') +local Native=require('src.world.Gen2Landmarks') +local Schemas=require('src.mods.Schemas') +local M={} +local copy=Merge.deepCopy +local previous=setmetatable({},{__mode='k'}) +local mounted=setmetatable({},{__mode='k'}) +local function equal(a,b) + if a==b then return true end + if type(a)~='table' or type(b)~='table'then return false end + for k,v in pairs(a)do if not equal(v,b[k])then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +local function finite(v,seen) + if type(v)=='number'then assert(v==v and math.abs(v)=0 and n<=255 and n or nil +end +local function record(registry,id,value) + assert(type(value)=='table' and value~=Merge.DELETE,'landmarks: expected record') + finite(value) + for key in pairs(value)do assert(registry.spec.fields[key],'landmarks: unknown field '..tostring(key))end + local ok,why=Schemas.check(registry.spec,'landmarks',id,value,'register');assert(ok,why) + return copy(value) +end +function M.prepare(loader,data) + local history=data and previous[data] + if history and data.field==history.field and history.field.townMap==history.town and + history.town.landmarks==history.after then + local rows=history.after + for key,entry in pairs(history.changed)do + if rows[key]==entry.value and equal(rows[key],entry.snapshot)then rows[key]=entry.before end + end + local pristine=true + for key,value in pairs(rows)do if history.before[key]~=value then pristine=false;break end end + if pristine then + for key,value in pairs(history.before)do if rows[key]~=value then pristine=false;break end end + end + if pristine then + if history.createdRows then history.town.landmarks=nil else history.town.landmarks=history.before end + end + if history.createdTown and not next(history.town)then history.field.townMap=nil end + if history.createdField and not next(history.field)then data.field=nil end + end + if data then previous[data]=nil end + local state={base={},order=Native.dataOrder(data)} + if loader:_generation()==2 then + local town=Native.town(data);local rows=town and town.landmarks or{} + local indices={} + for key in pairs(rows)do local n=index(key);if n then indices[n]=true end end + local ordered={};for n in pairs(indices)do ordered[#ordered+1]=n end;table.sort(ordered) + for _,n in ipairs(ordered)do + local native=rows[n] or rows[tostring(n)] + if type(native)=='table'then + local id=native.landmarkId or native.id or state.order[n+1] or 'landmark:'..n + if type(id)~='string' or id=='' or state.base[id]then id='landmark:'..n end + state.base[id]={id=native.id,name=native.name,x=native.x,y=native.y,index=n} + end + end + end + mounted[loader]=state + loader.content.landmarks.base=function()return state.base end +end +function M.apply(loader,data) + local registry=loader.content.landmarks + if loader:_generation()~=2 or not data or not next(registry.ops)then return end + local state=assert(mounted[loader]);local values,chosen={},{} + for id,value in registry:each()do + value=registry.ops[id] and record(registry,id,value) or copy(value) + values[id]=value + local n=index(value.index) + assert(n,'landmarks: native index must be a byte') + local old=chosen[n] + -- Match the reference's index arbitration: a native identity keeps its + -- original slot; newcomers use lexical ID order, independent of pairs(). + if not old or state.order[n+1]==id or state.order[n+1]~=old and id= 2 then error(err, 0) end Logger.warn("[%s] %s", modId, err) end - return { + local api = { register = function(_, id, value) note() validate("register", id, value) @@ -750,6 +750,10 @@ function Loader:_contentApi(mod, registry, deprecation) return registry:each() end, } + if registry.name == "landmarks" then + return require("src.mods.LandmarkContent").api(loader,mod,api,loader:_generation()) + end + return api end -- mod.commands is sugar over the commands registry; the engine's own verbs @@ -1326,6 +1330,7 @@ function Loader:load(data) end end end + require("src.mods.LandmarkContent").prepare(self, data) -- vanilla content is registrations too, and they land before discovery so -- a mod's register collides with the engine's and has to say override require("src.mods.Builtins").install(self.content, data) @@ -1551,6 +1556,7 @@ function Loader:load(data) stampAudioOwners(data, name, registry) end end + require("src.mods.LandmarkContent").apply(self, data) -- dangling f.id references are attributed to the id's last writer; -- api 1 mods keep the warning-only compat path if data then diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 9014835cc..101347247 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -1259,6 +1259,13 @@ R.text_pointers = { -- ------- persistence +R.landmarks = { + semantics="record",generations="2", + fields={id=f.opt(f.str),name=f.str,x=f.int(0),y=f.int(0),index=f.int(0,255)}, + example='mod.content.landmarks:patch("LANDMARK_ROUTE_29", {x=80})', + notes="Native Gen2 town-map records and script landmark names. Coordinates retain native OAM byte units, not tile cells. The index is the map-header byte. Native identities win index collisions; otherwise lexical ID order is deterministic. Imported identity metadata is independent of display names.", +} + -- compose, keyed by the owning mod id: the runner walks each owner's chain -- in semver order against the versions recorded in the save R.migrations = { diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index f0da924a2..23045f7fa 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -125,7 +125,6 @@ local function visibleCards(save) return list end -local KANTO_LANDMARK_FIRST = 0x2F local images = {} local function loadImage(path) @@ -837,7 +836,7 @@ function PokegearMenu:drawMap() local landmark = def and def.landmark local entry = landmark and town.landmarks and (town.landmarks[landmark] or town.landmarks[tostring(landmark)]) - local path = (landmark and landmark >= KANTO_LANDMARK_FIRST and town.kanto) + local path = (landmark and landmark >= require("src.world.Gen2Landmarks").kantoStart(game.data) and town.kanto) or town.johto local image = path and loadImage(path) if image then diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index 63a5a8155..646745a93 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -37,18 +37,19 @@ end -- Gen2 town map. field.townMap is a completely different shape from Gen1's: -- two full-screen pictures (johto/kanto) plus `landmarks`, a flat id -> {name, -- x, y} table whose coordinates are absolute screen pixels, and every map --- record carries the landmark id it belongs to. Landmarks from $2E up are --- Kanto: `DEF KANTO_LANDMARK EQU const_value` sits immediately BEFORE --- LANDMARK_PALLET_TOWN in constants/landmark_constants.asm, so Pallet is --- the first of them, not Route 1. A landmark short of it and Pallet read --- as Johto -- flown to off the Johto picture with no cursor drawn on it. -local GEN2_KANTO_FIRST = 0x2E +-- record carries the landmark id it belongs to. Gold/Silver's fallback +-- boundary is $2e; Crystal inserts the Battle Tower and starts Kanto at $2f. +-- Prefer the active cartridge's imported Pallet header below. local function gen2Locations(game) local tm = (game.data.field or {}).townMap if type(tm) ~= "table" or type(tm.landmarks) ~= "table" or not tm.johto then return nil end + -- Crystal inserts the Battle Tower landmark before Silver Cave. The + -- native Pallet header is the first Kanto landmark on the active cartridge; + -- the old Gold-only $2e boundary misclassified Crystal's Silver Cave. + local kantoFirst = require("src.world.Gen2Landmarks").kantoStart(game.data) local byLandmark, locs = {}, {} for id, e in pairs(tm.landmarks) do local x, y = tonumber(e and e.x), tonumber(e and e.y) diff --git a/src/world/Gen2Landmarks.lua b/src/world/Gen2Landmarks.lua new file mode 100644 index 000000000..2ce8d3b55 --- /dev/null +++ b/src/world/Gen2Landmarks.lua @@ -0,0 +1,76 @@ +-- Original adapter/selection logic by Roxas2712. See the PR contribution permission in docs/native-gen2-landmarks.md. +-- Constant identities/order only, not cartridge coordinates, names or art. +-- Orders: pret/pokegold and pret/pokecrystal constants/landmark_constants.asm. +local Version=require('src.core.GameVersion') +local M={} +local gold={} +for name in ([[ + SPECIAL NEW_BARK_TOWN ROUTE_29 CHERRYGROVE_CITY ROUTE_30 ROUTE_31 + VIOLET_CITY SPROUT_TOWER ROUTE_32 RUINS_OF_ALPH UNION_CAVE ROUTE_33 + AZALEA_TOWN SLOWPOKE_WELL ILEX_FOREST ROUTE_34 GOLDENROD_CITY RADIO_TOWER + ROUTE_35 NATIONAL_PARK ROUTE_36 ROUTE_37 ECRUTEAK_CITY TIN_TOWER BURNED_TOWER + ROUTE_38 ROUTE_39 OLIVINE_CITY LIGHTHOUSE ROUTE_40 WHIRL_ISLANDS ROUTE_41 + CIANWOOD_CITY ROUTE_42 MT_MORTAR MAHOGANY_TOWN ROUTE_43 LAKE_OF_RAGE + ROUTE_44 ICE_PATH BLACKTHORN_CITY DRAGONS_DEN ROUTE_45 DARK_CAVE ROUTE_46 + SILVER_CAVE PALLET_TOWN ROUTE_1 VIRIDIAN_CITY ROUTE_2 PEWTER_CITY ROUTE_3 + MT_MOON ROUTE_4 CERULEAN_CITY ROUTE_24 ROUTE_25 ROUTE_5 UNDERGROUND_PATH + ROUTE_6 VERMILION_CITY DIGLETTS_CAVE ROUTE_7 ROUTE_8 ROUTE_9 ROCK_TUNNEL + ROUTE_10 POWER_PLANT LAVENDER_TOWN LAV_RADIO_TOWER CELADON_CITY SAFFRON_CITY + ROUTE_11 ROUTE_12 ROUTE_13 ROUTE_14 ROUTE_15 ROUTE_16 ROUTE_17 ROUTE_18 + FUCHSIA_CITY ROUTE_19 ROUTE_20 SEAFOAM_ISLANDS CINNABAR_ISLAND ROUTE_21 + ROUTE_22 VICTORY_ROAD ROUTE_23 INDIGO_PLATEAU ROUTE_26 ROUTE_27 TOHJO_FALLS + ROUTE_28 FAST_SHIP +]]):gmatch('%S+')do gold[#gold+1]='LANDMARK_'..name end +local function validOrder(source) + if type(source)~='table' or #source==0 or #source>256 then return nil end + local result,seen={},{} + for i=1,#source do + local id=source[i] + if type(id)~='string' or id=='' or seen[id]then return nil end + seen[id]=true;result[i]=id + end + for key in pairs(source)do + if type(key)~='number' or key%1~=0 or key<1 or key>#source then return nil end + end + return result +end +function M.order(version,metadata) + local declared=metadata and (metadata.landmarkOrder or metadata.constants and metadata.constants.landmarkOrder) + if declared~=nil then + local order=validOrder(declared) + if not order then return {},'invalid landmark identity order' end + return order + end + if version~='gold' and version~='silver' and version~='crystal'then return{}end + local result={};for i,id in ipairs(gold)do result[i]=id end + if version=='crystal'then table.insert(result,30,'LANDMARK_BATTLE_TOWER')end + return result +end +function M.town(data)return data and data.field and data.field.townMap end +function M.dataOrder(data) + local town=M.town(data) + if town and town.landmarkOrder~=nil then return validOrder(town.landmarkOrder) or{}end + return M.order(Version.get(),data and data.constants and {landmarkOrder=data.constants.landmarkOrder}) +end +function M.index(data,id) + local town=M.town(data);local rows=town and town.landmarks + local found + for key,row in pairs(rows or{})do + if type(row)=='table' and (row.landmarkId or row.id)==id and tonumber(row.index or key)then + local n=tonumber(row.index or key) + if found and found~=n then return nil end + found=n + end + end + if found then return found,true end + for i,name in ipairs(M.dataOrder(data))do if name==id then return i-1,false end end +end +function M.kantoStart(data) + local index,native=M.index(data,'LANDMARK_PALLET_TOWN') + local town=M.town(data) + if index and (native or town and town.landmarkOrder~=nil or + data and data.constants and data.constants.landmarkOrder~=nil)then return index end + local pallet=data and data.maps and data.maps.PALLET_TOWN + return tonumber(pallet and pallet.landmark) or index or (Version.get()=='crystal' and 0x2f or 0x2e) +end +return M diff --git a/tests/engine/gen2_landmark_content.lua b/tests/engine/gen2_landmark_content.lua new file mode 100644 index 000000000..d7476c38d --- /dev/null +++ b/tests/engine/gen2_landmark_content.lua @@ -0,0 +1,232 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-landmarks.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local Native=require('src.world.Gen2Landmarks');local E=require('src.import.RomExtractorGen2') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner') +local Gear=require('src.ui.PokegearMenu');local TownMap=require('src.ui.TownMap') +local Importer=require('src.import.RomImporter') +local markerFor +for i=1,100 do local name,value=debug.getupvalue(Importer.readyReport,i) + if name=='markerFor'then markerFor=value;break end;if not name then break end +end +assert(markerFor) +local function extractor(version,order) + local records={} + for i=0,95 do records[#records+1]=string.char(i%160,(i+20)%144,0,0x50)end + local raw=string.rep('\0',0x4000)..table.concat(records) + raw=raw..string.rep('\0',0x5000-#raw)..string.char(0x80,0x80,0x50)..string.rep('\0',0x2ffd) + local e=E.new(raw,version,{symbols={Landmarks={1,0x4000}},landmarkOrder=order}) + function e:readSourceTable(key)assert(key=='charmap');return{['128']='A'}end -- authored source-table IO + return e +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local order=Native.order(version);local count=version=='crystal' and 96 or 95 + T.eq(#order,count,version..' exact native landmark count includes the final ship') + T.eq(order[30],version=='crystal' and 'LANDMARK_BATTLE_TOWER' or 'LANDMARK_ROUTE_40',version..' Duellturm insertion is edition-specific') + T.eq(order[#order],'LANDMARK_FAST_SHIP',version..' final identity remains the native ship') + local e=extractor(version);local rows=e:gen2Landmarks() + T.eq(rows[count-1].id,'LANDMARK_FAST_SHIP',version..' real ROM reader reaches final native row') + T.eq(rows[count-1].index,count-1,version..' importer records actual map-header byte') + T.eq(rows[count-1].name,'AA',version..' actual native text decoder supplies display name') + T.eq(rows[count-1].x,(count-1)%160,version..' raw OAM x coordinate is not converted to a tile cell') + T.eq(rows[count-1].y,(count+19)%144,version..' raw OAM y coordinate is unchanged') + T.eq(rows[count],nil,version..' stock import cannot read beyond its actual landmark count') + order[1]='LEAK';T.eq(Native.order(version)[1],'LANDMARK_SPECIAL',version..' identity views are detached') + local town=e:gen2TownMap() + T.eq(town.landmarks[count-1].id,'LANDMARK_FAST_SHIP',version..' missing picture does not discard valid script names') + T.eq(town.landmarkOrder[count],'LANDMARK_FAST_SHIP',version..' actual town-map output carries identity order') + T.eq(town.johto,nil,version..' missing picture cannot invent fallback map art') + local marker=markerFor(version) + T.check(marker:find('gen2%-landmark%-identities%-v1:')~=nil,version..' new native identity cache revision is present') + love.filesystem.write(V.cachePrefix(version)..'rom-cache.complete',marker:gsub('gen2%-landmark%-identities%-v1:','')) + T.eq(Importer.readyReport(version).ok,false,version..' old truncated identity-less cache requires reimport') +end +for _,version in ipairs({'prism','polishedcrystal','red'})do + T.eq(#Native.order(version),0,version..' unreviewed layout cannot acquire stock landmark identities') + T.eq(Native.order(version,{landmarkOrder={'AUTHORED_ZERO','AUTHORED_ONE'}})[2],'AUTHORED_ONE',version..' explicit manifest identities remain authoritative') +end +T.eq(extractor('crystal',{'DUPLICATE','DUPLICATE'}):gen2Landmarks(),nil,'invalid declared identities cannot silently fall back to retail count') +T.eq(extractor('crystal',{'ONLY'}):gen2Landmarks()[1],nil,'explicit custom count bounds actual ROM reads') +local function fixture(version) + local d=T.fixtures.fresh();local pallet=version=='crystal' and 47 or 46 + d.field.townMap={johto='qa-johto-'..version,kanto='qa-kanto-'..version,playerIcon='qa-player-'..version, + landmarkOrder=Native.order(version),landmarks={ + [0]={id='LANDMARK_SPECIAL',index=0,name='NOWHERE',x=0,y=0}, + [1]={id='LANDMARK_NEW_BARK_TOWN',index=1,name='LOCAL HOME',x=20,y=32}, + [2]={id='LANDMARK_ROUTE_29',index=2,name='LOCAL ROAD',x=40,y=48}, + [pallet]={id='LANDMARK_PALLET_TOWN',index=pallet,name='LOCAL PALLET',x=72,y=80}, + [pallet-1]={id='LANDMARK_SILVER_CAVE',index=pallet-1,name='LOCAL CAVE',x=88,y=96}}} + d.maps.HOME={id='HOME',landmark=1};d.maps.CAVE={id='CAVE',landmark=pallet-1} + d.maps.PALLET_TOWN={id='PALLET_TOWN',landmark=pallet};d.maps.EXTRA={id='EXTRA',landmark=200} + d.map_scripts={scripts={HERE={{'getcurlandmarkname',0},{'end'}},THERE={{'getlandmarkname',200,0},{'end'}}}} + return d +end +local function load(d,source,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local files,paths={},{} + if source then + files['mods/landmarks/manifest.json']=Json.encode({id='landmarks',name='Landmarks',version='1.0.0', + api=mode and mode[1] or 2,content_format=mode and mode[2] or 'native',entry='main.lua',affects_link=false}) + files['mods/landmarks/main.lua']='local mod=...;mod.exports.api=mod;'..source;paths={'mods/landmarks'} + end + return T.sdk.loadMods(paths,{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +local function game(d,map) + return {data=d,save={inventory={}},overworld={map={id=map,def=d.maps[map]}}, + stack={pop=function()end},input={wasPressed=function()return false end}} +end +local function run(g,label) + local r=Runner.new(g,g.overworld);r:run(assert(VM.compile(g.data,label)));return r +end +local oldImage,oldDraw=love.graphics.newImage,love.graphics.draw +local paints={} +love.graphics.newImage=function(path)return{path=path,getDimensions=function()return 160,144 end}end +love.graphics.draw=function(image,x,y)paints[#paints+1]={path=image.path,x=x,y=y}end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + do + local d=fixture(version);local before=d.field.townMap.landmarks;local clean=load(d) + T.eq(#clean.errors,0,version..' no-mod native boot stays valid') + T.eq(d.field.townMap.landmarks,before,version..' no-mod load leaves native target identity untouched') + finish(clean) + end + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture(version);local before=d.field.townMap.landmarks + local r=load(d,[[local p=mod.content["landmarks"] + assert(p:supports()) + assert(p:get('LANDMARK_ROUTE_29').name=='LOCAL ROAD') + local v=p:get('LANDMARK_ROUTE_29');v.name='LEAK' + for _,row in p:each()do row.x=999 end + assert(p:get('LANDMARK_ROUTE_29').x==40) + p:patch('LANDMARK_NEW_BARK_TOWN',{name='AUTHORED\nHOME',x=52,y=68}) + p:register('LANDMARK_EXTRA',{name='AUTHORED EXTRA',x=100,y=104,index=200}) + p:remove('LANDMARK_ROUTE_29') + ]],mode) + local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' public landmark records load through real mod API') + T.eq(d.field.townMap.landmarks[1].name,'AUTHORED\nHOME',label..' native numeric row receives authored text') + T.eq(d.field.townMap.landmarks[2],nil,label..' removed row cannot survive by fallback') + T.eq(d.field.townMap.landmarks[200].landmarkId,'LANDMARK_EXTRA',label..' new identity binds actual byte200') + T.eq(before[1].name,'LOCAL HOME',label..' source native row is not mutated') + local g=game(d,'HOME');run(g,'HERE') + T.eq(g.stringBuffers and g.stringBuffers[3],'AUTHORED HOME',label..' actual current-landmark VM reads authored native name') + run(g,'THERE');T.eq(g.stringBuffers and g.stringBuffers[3],'AUTHORED EXTRA',label..' actual named-landmark VM reads registered byte') + local screen=TownMap.new(g) + T.eq(screen.mode,'gen2',label..' real map controller consumes native table') + T.eq(screen.byMap.HOME.px,48,label..' actual map marker applies native OAM origin once') + T.eq(screen.byMap.HOME.py,56,label..' native marker y keeps raw coordinate units') + T.eq(screen.byMap.PALLET_TOWN.region,'kanto',label..' first actual Kanto byte selects Kanto') + T.eq(screen.byMap.CAVE.region,'johto',label..' preceding cave remains on Johto') + T.eq(screen.byMap.EXTRA.name,'AUTHORED EXTRA',label..' native map controller consumes added marker') + local text + local gear=setmetatable({game=g,drawStrip=function()end,drawModeArrow=function()end, + text=function(_,value)text=value end},{__index=Gear}) + paints={};gear:drawMap() + T.eq(text,'AUTHORED HOME',label..' actual Pokegear label reads the same authored name') + T.eq(paints[1].path,d.field.townMap.johto,label..' actual Pokegear paints the correct Johto image') + T.eq(paints[2].x,44,label..' actual Pokegear player sprite consumes authored OAM x') + T.eq(paints[2].y,52,label..' actual Pokegear player sprite consumes authored OAM y') + g.overworld.map={id='PALLET_TOWN',def=d.maps.PALLET_TOWN};paints={};gear:drawMap() + T.eq(paints[1].path,d.field.townMap.kanto,label..' Pokegear correctly paints Gold/Silver first Kanto town too') + local api=r.loader.exports.landmarks.api.content.landmarks + T.check(not pcall(function()api:patch('LANDMARK_EXTRA',{name='LATE'})end),label..' content freezes after load') + finish(r);local clean=load(d) + T.eq(d.field.townMap.landmarks,before,label..' disabling restores original numeric table identity') + T.eq(d.field.townMap.landmarks[200],nil,label..' disabling removes the authored byte') + finish(clean) + end +end +V.set('crystal') +for _,source in ipairs({ + "p:register('BAD',{name='BAD',x=1,y=1,index=256})", + "p:register('BAD',{name='BAD',x=-1,y=1,index=200})", + "p:register('BAD',{name='BAD',x=0/0,y=1,index=200})", + "p:register('BAD',{name='BAD',x=1,y=math.huge,index=200})", + "p:register('BAD',{name='BAD',x=1,y=1,index=200,typo=true})", + "p:patch('LANDMARK_NEW_BARK_TOWN',{name=mod.DELETE})", + "local x={};x.name=x;p:register('BAD',x)", + "error('entry failed')", +})do + local d=fixture('crystal');local before=d.field.townMap.landmarks + local r=load(d,"local p=mod.content.landmarks;p:patch('LANDMARK_NEW_BARK_TOWN',{name='FAIL'});"..source) + T.check(#r.errors>0,'invalid native record is attributed: '..source) + T.eq(d.field.townMap.landmarks,before,'failed entry rolls back prior valid landmark edits') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,[[local p=mod.content.landmarks + p:register('Z_NEW',{name='Z',x=10,y=20,index=200}) + p:register('A_NEW',{name='A',x=30,y=40,index=200}) + p:register('A_SHADOW',{name='SHADOW',x=99,y=99,index=1}) + ]]) + T.eq(#r.errors,0,'reference-compatible duplicate index claims are deterministic') + T.eq(d.field.townMap.landmarks[1].name,'LOCAL HOME','native identity wins its own slot') + T.eq(d.field.townMap.landmarks[200].name,'A','lexically earlier newcomer wins other slot') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,[[mod.content.landmarks:patch('LANDMARK_NEW_BARK_TOWN',{index=201})]]) + T.eq(#r.errors,0,'reference record may move to an unused native index') + T.eq(d.field.townMap.landmarks[201].landmarkId,'LANDMARK_NEW_BARK_TOWN','moved identity reaches new native index') + T.eq(d.field.townMap.landmarks[1].name,'LOCAL HOME','reference order remains fallback for unclaimed original slot') + finish(r) +end +do + local d=fixture('crystal');d.field.townMap.landmarks['2']=d.field.townMap.landmarks[2] + d.map_scripts.scripts.ZERO={{'getlandmarkname',0,0},{'end'}} + local before=d.field.townMap.landmarks;local r=load(d,[[local p=mod.content.landmarks + p:remove('LANDMARK_ROUTE_29');p:patch('LANDMARK_SPECIAL',{name='AUTHORED ZERO'}) + ]]) + T.eq(#r.errors,0,'numeric/string native aliases and byte zero remain supported') + T.eq(d.field.townMap.landmarks[2],nil,'removal drops numeric native slot') + T.eq(d.field.townMap.landmarks['2'],nil,'string fallback cannot resurrect removed landmark') + local g=game(d,'HOME');run(g,'ZERO') + T.eq(g.stringBuffers[3],'AUTHORED ZERO','actual native script names byte zero rather than treating it as absent') + finish(r);local clean=load(d) + T.eq(d.field.townMap.landmarks,before,'unload restores original numeric/string alias layout') + T.eq(before['2'],before[2],'restored aliases retain their original shared record') + finish(clean) +end +do + local d=fixture('crystal');local r=load(d,[[mod.content.landmarks:patch('LANDMARK_PALLET_TOWN',{index=48})]]) + T.eq(#r.errors,0,'moving the logical first Kanto landmark is a native byte edit') + T.eq(Native.kantoStart(d),48,'region boundary consumes authored landmark index metadata') + local g=game(d,'PALLET_TOWN');local screen=TownMap.new(g) + T.eq(screen.byMap.PALLET_TOWN.region,'johto','town map follows the same authored boundary') + paints={};setmetatable({game=g,drawStrip=function()end,drawModeArrow=function()end,text=function()end},{__index=Gear}):drawMap() + T.eq(paints[1].path,d.field.townMap.johto,'actual Pokegear background consumes authored boundary') + finish(r) +end +do + local d=fixture('crystal');local before=d.field.townMap.landmarks + local r=load(d,[[mod.content.landmarks:patch('LANDMARK_NEW_BARK_TOWN',{name='OWN'}) + mod.content.landmarks:register('EXTRA',{name='EXTRA',x=100,y=100,index=200})]]) + local row=d.field.townMap.landmarks[1];row.name='EXTERNAL' + finish(r);local clean=load(d) + T.eq(d.field.townMap.landmarks[1],row,'reload preserves externally edited native record identity') + T.eq(row.name,'EXTERNAL','reload preserves external text') + T.eq(d.field.townMap.landmarks[200],nil,'external edit does not retain unchanged owned addition') + T.eq(d.field.townMap.landmarks[2],before[2],'unrelated original record identity restores') + finish(clean) +end +do + local d=T.fixtures.fresh();d.field=nil + local r=load(d,[[mod.content.landmarks:register('NEW',{name='NEW',x=20,y=30,index=200})]]) + T.eq(#r.errors,0,'indexed authored names can create a native script target without map art') + T.eq(d.field.townMap.landmarks[200].name,'NEW','new native target is readable') + finish(r);local clean=load(d) + T.eq(d.field,nil,'unload retires exclusively owned rows, town and field namespaces') + finish(clean) +end +for _,version in ipairs({'red','firered','emerald','heartgold'})do + V.set(version);local d=fixture('crystal');local before=d.field.townMap.landmarks + local r=load(d,[[local p=mod.content.landmarks;assert(not p:supports());assert(p:get('LANDMARK_ROUTE_29')==nil) + p:register('NO_WRITE',{name='NO',x=20,y=30,index=200})]]) + T.eq(#r.errors,0,version..' absent native target is reported through public handle') + T.eq(d.field.townMap.landmarks,before,version..' cannot mutate lookalike Gen2 town map') + finish(r) +end +love.graphics.newImage=oldImage;love.graphics.draw=oldDraw;V.set(original) +T.finish('native landmark identities, real map/gear/name consumers, reload ownership and edition boundaries') From 40dfd6b930616713b70f05fde4435c52679c9d2d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:03:32 +0100 Subject: [PATCH 089/138] Use the native edition boundary in upstream map row classification --- src/ui/TownMap.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index 646745a93..4727a2725 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -63,7 +63,7 @@ local function gen2Locations(game) -- draws around -- taken raw, every nest sat 4px right and 12px -- below the town it belongs to. px = x - 4, py = y - 12, landmark = tonumber(id), - region = tonumber(id) >= GEN2_KANTO_FIRST and "kanto" or "johto", + region = tonumber(id) >= kantoFirst and "kanto" or "johto", } byLandmark[tonumber(id)] = loc locs[#locs + 1] = loc From 0cd5b7ab725ddc4fd672c4c8a57242ebc0ecc8ca Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:47:41 +0100 Subject: [PATCH 090/138] Run native GSC bedroom decorations through ROM tables, PC and room callbacks --- docs/native-gen2-decorations.md | 83 ++++++++++ src/import/RomExtractorGen2.lua | 134 +++++++++++++++- src/import/RomImporter.lua | 2 +- src/mods/DecorationContent.lua | 100 ++++++++++++ src/mods/Loader.lua | 9 +- src/mods/Schemas.lua | 7 + src/save_convert/Gen2Save.lua | 24 +++ src/script/Gen2Commands.lua | 27 ++++ src/script/Gen2ScriptVM.lua | 25 ++- src/script/Gen2Specials.lua | 14 +- src/ui/Gen2DecorationMenu.lua | 106 +++++++++++++ src/ui/PlayerPC.lua | 20 ++- src/world/Gen2Decorations.lua | 133 ++++++++++++++++ tests/engine/gen2_decoration_content.lua | 194 +++++++++++++++++++++++ tests/engine/gen2_decoration_import.lua | 113 +++++++++++++ 15 files changed, 974 insertions(+), 17 deletions(-) create mode 100644 docs/native-gen2-decorations.md create mode 100644 src/mods/DecorationContent.lua create mode 100644 src/ui/Gen2DecorationMenu.lua create mode 100644 src/world/Gen2Decorations.lua create mode 100644 tests/engine/gen2_decoration_content.lua create mode 100644 tests/engine/gen2_decoration_import.lua diff --git a/docs/native-gen2-decorations.md b/docs/native-gen2-decorations.md new file mode 100644 index 000000000..22c63ead3 --- /dev/null +++ b/docs/native-gen2-decorations.md @@ -0,0 +1,83 @@ +# Native GSC bedroom decorations + +The engine imports decoration attributes, display names, default placed bytes, +gift ordinals and room-description entry points from the user's current +Gold/Silver/Crystal cartridge. No retail attribute catalogue, dialogue or room +art is bundled. The virtual `decorations` registry writes the same DECO-byte +records that native room callbacks and the player's bedroom PC consume. + +```lua +local decorations = mod.content.decorations +local available, reason = decorations:supports() +if available then + decorations:patch("deco:2", { name = "CUSTOM" }) +end +``` + +Native API 1/2 use `deco:0` through `deco:255`. The number is +the cartridge's DECO byte, not a sorted list index. Records contain `type` +(1..6), `name`, optional `action`, `flag` (native event word) and `sprite` +(native object-sprite or map-block byte). Only the fourteen known +`SET_UP_`/`PUT_AWAY_` actions are accepted. Cancel and put-away rows do not grant +ownership. API views are detached; invalid/failed mod entries roll back and +reload retires owned writes individually while retaining external edits. +Removing a record removes both numeric and string aliases of its byte. + +The importer derives the attribute count from the adjacent native names-table +boundary and reads six bytes per row. Invalid/truncated attributes or names +publish no partial table. Species-backed names keep their native species ID, +so later species renames remain visible unless the decoration itself is +renamed. Native initialization is recognized only when the actual instruction +operands store valid bed/poster IDs to the corresponding WRAM symbols. Missing +initialization does not invent retail defaults. Existing placed state, +including an explicitly empty table, is retained. + +The house-PC option appears only with an imported native table. Owned records +form the native categories; their put-away headers and cancel choice follow +the records, with eight visible rows and the native right/left/cancel order +for ornaments. Feedback and side prompts enter the normal ROM text importer +and `TextBox` path. A replacement uses buffers three/four for old/new names. +State changes commit after feedback closes. Moving an ornament clears an +opposite duplicate; selecting the same ornament does not mutate the opposite +side. Ordinary item-storage PCs do not gain this option. + +`PlayersHousePC` waits until LOG OFF/B and returns the accumulated change bit. +The exact GSC `warp NONE` pair reloads the current map after the script ends, +without relocating the player to cell zero. Unknown map pairs are not reloads. +The existing native callback queue then applies placed object visibility, +variable sprites and room blocks. Description bytecode branches into the +actual imported poster/ornament/big-doll scripts rather than being skipped. +Block zero is valid for a nonzero DECO byte; carpet offsets wrap as native +bytes. An empty slot writes no block, leaving the newly loaded bare room. + +Raw SRAM conversion reads/writes the eight placed bytes using the selected +edition's WRAM-to-SRAM layout. The physical order ends with the big doll and +is distinct from the public slot list. Absent legacy decoration state leaves +the imported template intact; invalid placed bytes fail instead of wrapping. +Unreviewed hack layouts receive neither guessed retail tables nor guessed +save offsets. + +## Evidence and remaining scope + +ROM-free tests exercise actual importers, Loader, native script compiler and +runner, menus, TextBox, StateStack, ownership flags, callback commands and save +codec. The eight save offsets are independently checked against each shipped +GSC symbol manifest. Test cartridges and dialogue are authored synthetic data. +The callback/warp tests use controlled overworld presentation/queue boundaries; +they are not GPU-render or complete-game acceptance tests. + +A separate private read-only Crystal probe independently compares all 53 +attribute rows' numeric bytes, imports 45 gift ordinals and the native defaults, +executes the original house-PC/description scripts and room callbacks, and +round-trips all eight placed bytes. Original ROM hashes are checked unchanged. +The local font/audio/render harness is used; no visual acceptance is inferred. + +Full legacy `Decorations` facade parity, the complete gift/purchase lifecycle, +new graphical acceptance and a full unchanged VASC playthrough remain separate +open work. Importing the gift-ordinal table alone does not implement those +callers. The existing preview is not refreshed by this source change. + +Contract references: [Crystal decoration routines](https://github.com/pret/pokecrystal/blob/master/engine/overworld/decorations.asm), +[Gold bedroom script](https://github.com/pret/pokegold/blob/master/maps/PlayersHouse2F.asm). +Original fork additions: [Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md); +existing upstream and third-party rights remain unchanged. diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 90b74029e..c8f354e22 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -3317,6 +3317,133 @@ function RomExtractorGen2:gen2MovementTable() return out end +-- Six native bytes per attribute row: kind/name/action/event-word/sprite. +-- Names immediately follow attributes in stock GSC; that boundary supplies +-- the row count instead of a copied retail catalogue. +function RomExtractorGen2:gen2Decorations() + if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end + local info={complete=false,rowsRead=0,defaultsComplete=false,giftsComplete=false} + local attr,names=self:symbol("DecorationAttributes"),self:symbol("DecorationNames") + local function fail(why)info.reason=why;return {},info end + if not attr or not names then return fail("missing decoration table symbols")end + local count=(names.address-attr.address)/6 + if attr.bank~=names.bank or count%1~=0 or count<1 or count>256 or + attr.address<0x4000 or names.address>0x8000 then return fail("invalid decoration table boundary")end + local Native=require("src.world.Gen2Decorations") + local ok,rows=pcall(function() + local raw,maxName={},0 + for i=0,count-1 do + local r=self.rom:bytes(attr.bank,attr.address+i*6,6) + assert(r[1]>=1 and r[1]<=6,"invalid decoration kind") + assert(r[3]==0 or Native.actionName(r[3]),"invalid decoration action") + assert(r[4]+r[5]*256<65535,"indirect decoration event") + raw[i]=r;if r[1]<=3 then maxName=math.max(maxName,r[2])end + end + local charmap=self:readSourceTable("charmap") + local words,pc={},names.address + for i=0,maxName do + local bytes,ended={},false + for _=1,128 do + assert(pc<0x8000,"decoration name crosses bank") + local b=self.rom:byte(names.bank,pc);pc=pc+1 + if b==0x50 then ended=true;break end + bytes[#bytes+1]=b + end + assert(ended,"unterminated decoration name") + words[i]=self:gen2GlyphBytes(bytes,charmap) + end + local pokemon=self:symbol("PokemonNames") + local out={} + for i=0,count-1 do + local r=raw[i];local name=words[r[2]] + if r[1]>=4 then + assert(pokemon and r[2]>=1,"missing decoration species name") + local address=pokemon.address+(r[2]-1)*10 + assert(address>=0x4000 and address+10<=0x8000,"decoration species name crosses bank") + local bytes={} + for n=0,9 do local b=self.rom:byte(pokemon.bank,address+n);if b==0x50 then break end;bytes[#bytes+1]=b end + name=self:gen2GlyphBytes(bytes,charmap) + info.species=info.species or{};info.sourceNames=info.sourceNames or{} + info.species[i]=string.format("SPECIES_%03d",r[2]);info.sourceNames[i]=name + end + assert(type(name)=="string" and name~="","missing decoration name") + out[i]={type=r[1],name=name,action=Native.actionName(r[3]),flag=r[4]+r[5]*256,sprite=r[6]} + end + return out + end) + if not ok then return fail(tostring(rows))end + local init=self:symbol("InitDecorations") + if init then + local valid,bytes=pcall(self.rom.bytes,self.rom,init.bank,init.address,11) + if valid and bytes[1]==0x3e and bytes[3]==0xea and bytes[6]==0x3e and bytes[8]==0xea and bytes[11]==0xc9 and + rows[bytes[2]] and rows[bytes[2]].action=="SET_UP_BED" and rows[bytes[7]] and rows[bytes[7]].action=="SET_UP_POSTER" then + local bed,poster=self:symbol("wDecoBed"),self:symbol("wDecoPoster") + if bed and poster and bytes[4]+bytes[5]*256==bed.address and bytes[9]+bytes[10]*256==poster.address then + info.defaults={bed=bytes[2],poster=bytes[7]};info.defaultsComplete=true + end + end + end + local gifts=self:symbol("DecorationIDs") + if gifts then + local valid,list=pcall(function() + local out={} + for n=0,255 do + assert(gifts.address+n<0x8000,"decoration gift table crosses bank") + local id=self.rom:byte(gifts.bank,gifts.address+n) + if id==255 then return out end + assert(rows[id] and Native.action(rows[id].action) and not Native.action(rows[id].action).put,"invalid gift decoration") + out[#out+1]=id + end + error("unterminated decoration gift table") + end) + if valid then info.gifts=list;info.giftsComplete=true end + end + info.complete=true;info.rowsRead=count + return rows,info +end + +function RomExtractorGen2:gen2QueueDecorationPrograms(pool) + if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end + local roles={ornament="DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript", + big="DecorationDesc_GiantOrnament.BigDollScript",null="DecorationDesc_NullPoster"} + local found={} + for role,name in pairs(roles)do + local at=self:symbol(name) + if not at or at.address<0x4000 or not self:gen2InRom(at.bank,at.address)then return nil end + found[role]=at + end + local pointers=self:symbol("DecorationDesc_PosterPointers") + if not pointers then return nil end + local ok,posters=pcall(function() + local out={} + for n=0,255 do + local pc=pointers.address+n*3;assert(pc<0x8000,"poster pointers cross bank") + local id=self.rom:byte(pointers.bank,pc) + if id==255 then return out end + assert(pc+2<0x8000,"truncated poster pointer") + local address=self.rom:word(pointers.bank,pc+1) + assert(address>=0x4000 and self:gen2InRom(pointers.bank,address)and not out[id],"invalid poster pointer") + out[id]={bank=pointers.bank,address=address} + end + error("unterminated poster pointers") + end) + if not ok then return nil end + local out={posters={},texts={}} + self._gen2ScriptTexts=self._gen2ScriptTexts or{} + for role,name in pairs({put="PutAwayTheDecoText",empty="NothingToPutAwayText", + setup="SetUpTheDecoText",replace="PutAwayAndSetUpText",same="AlreadySetUpText", + noChoice="PopulateDecoCategoryMenu.NothingToChooseText", + sideSetup="WhichSidePutOnText",sidePut="WhichSidePutAwayText"})do + local at=self:symbol(name) + if at and self:gen2InRom(at.bank,at.address)then + out.texts[role]=self:gen2RegisterScriptText(at.bank,at.address) + end + end + for role,at in pairs(found)do out[role]=self:gen2QueueScript(at.bank,at.address,pool)end + for id,at in pairs(posters)do out.posters[id]=self:gen2QueueScript(at.bank,at.address,pool)end + return out +end + -- Marts is a dw pointer table; each mart is `db count`, the item ids, `db -1`. -- The table ends where its own first entry starts. function RomExtractorGen2:gen2Marts() @@ -8126,7 +8253,10 @@ function RomExtractorGen2:gen2ResolveScriptMapIds(maps, keys, pool) if slot and type(row[slot]) == "number" and type(row[slot + 1]) == "number" then local entry = mapIndex[row[slot] * 256 + row[slot + 1]] local key = entry and keyByLabel[entry.label] - if key then + if (row[1]=="warp"or row[1]=="warpfacing") and row[slot]==0 and row[slot+1]==0 and + (self.version=="gold"or self.version=="silver"or self.version=="crystal")then + row[slot]="NONE";row[slot+1]=0 + elseif key then row[slot] = key row[slot + 1] = 0 end @@ -8891,6 +9021,8 @@ function RomExtractorGen2:extractMapScripts() self:beginStage("Gen2 map scripts end out.stds = self:gen2QueueStdScripts(pool) + out.decorations,out.decorationInfo=self:gen2Decorations() + out.decorationPrograms=self:gen2QueueDecorationPrograms(pool) out.marts = self:gen2Marts() out.spawns = self:gen2SpawnPoints(maps, keys) -- extractField runs after this stage and needs both of these: GSC has no diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index cbd913e5a..d0e8c48bf 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1310,7 +1310,7 @@ local function markerFor(version) -- Only these three caches need rebuilding; other markers remain stable. -- v8: field.gen2TradeAnim, GSC's own trade movie art (Gen2TradeArt). if version == "gold" or version == "silver" or version == "crystal" then - revision = "gen2-field-encounters-v8:" + revision = "gen2-decorations-native-v1:gen2-field-encounters-v8:" end -- field.tradeArt: the cable-trade movie's tiles out of the ROM -- (Gen1TradeArt); without it the trade ran on drawn stand-ins. diff --git a/src/mods/DecorationContent.lua b/src/mods/DecorationContent.lua new file mode 100644 index 000000000..e6d1ddd16 --- /dev/null +++ b/src/mods/DecorationContent.lua @@ -0,0 +1,100 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- A DECO byte is identity, not the sorted position of a mod record. +local Merge=require('src.mods.Merge') +local Native=require('src.world.Gen2Decorations') +local Schemas=require('src.mods.Schemas') +local M={} +local copy=Merge.deepCopy +local mounted=setmetatable({},{__mode='k'}) +local previous=setmetatable({},{__mode='k'}) +local function equal(a,b) + if a==b then return true end + if type(a)~='table'or type(b)~='table'then return false end + for k,v in pairs(a)do if not equal(v,b[k])then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +local function byte(id) + local digits=type(id)=='string'and id:match('^deco:(%d+)$') + local n=digits and tonumber(digits) + assert(n and n>=0 and n<=255 and 'deco:'..n==id,'decorations: expected canonical deco: ID') + return n +end +local function finite(value,seen) + if type(value)=='number'then assert(value==value and math.abs(value)=0 and n<=255 and type(row)=='table'then + state.base['deco:'..n]=copy(data.map_scripts.decorations[n]or row) + end + end + end + mounted[loader]=state + loader.content.decorations.base=function()return state.base end +end +function M.apply(loader,data) + local registry=loader.content.decorations;local state=mounted[loader] + if not(state and state.available)or not next(registry.ops)then return end + local pool=data.map_scripts;local before=pool.decorations;local out={} + for key,row in pairs(before)do if tonumber(key)==nil then out[key]=row end end + for id,value in registry:each()do out[byte(id)]=registry.ops[id]and record(registry,id,value)or copy(value)end + local changed={};local keys={} + for key in pairs(before)do keys[key]=true end;for key in pairs(out)do keys[key]=true end + for key in pairs(keys)do + if before[key]~=out[key]then changed[key]={before=before[key],value=out[key],snapshot=copy(out[key])}end + end + pool.decorations=out;previous[data]={pool=pool,before=before,after=out,changed=changed} +end +function M.api(loader,mod,native,generation) + local state=mounted[loader] + local available=generation==2 and state and state.available or false + local reason=not available and 'unsupported: decorations requires an imported native GSC attribute table'or nil + local registry=loader.content.decorations + local out={available=available,supports=function()return available,reason end} + function out:get(id)if not available then return nil,reason end;byte(id);return copy(native:get(id))end + function out:has(id)return self:get(id)~=nil end + function out:each() + if not available then return function()end end + local nextRow=native:each();return function()local id,value=nextRow();return id,copy(value)end + end + for _,verb in ipairs({'register','override','patch','remove'})do + out[verb]=function(_,id,value) + if not available then return nil,reason end + byte(id) + if verb=='remove'then return native:remove(id)end + finite(value);assert(type(value)=='table'and value~=Merge.DELETE,'decorations: expected record') + for key in pairs(value)do assert(registry.spec.fields[key],'decorations: unknown field '..tostring(key))end + local effective=verb=='patch'and Merge.deepMerge(copy(registry:get(id)or{}),copy(value),'record')or value + effective=record(registry,id,effective);native[verb](native,id,effective) + return out:get(id) + end + end + return out +end +return M diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..22977dbfc 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -717,7 +717,7 @@ function Loader:_contentApi(mod, registry, deprecation) if apiLevel >= 2 then error(err, 0) end Logger.warn("[%s] %s", modId, err) end - return { + local api = { register = function(_, id, value) note() validate("register", id, value) @@ -750,6 +750,11 @@ function Loader:_contentApi(mod, registry, deprecation) return registry:each() end, } + if registry.name=="decorations"then + return require("src.mods.DecorationContent").api(loader,mod,api,loader:_generation()) + end + return api + end -- mod.commands is sugar over the commands registry; the engine's own verbs @@ -1326,6 +1331,7 @@ function Loader:load(data) end end end + require("src.mods.DecorationContent").prepare(self,data) -- vanilla content is registrations too, and they land before discovery so -- a mod's register collides with the engine's and has to say override require("src.mods.Builtins").install(self.content, data) @@ -1551,6 +1557,7 @@ function Loader:load(data) stampAudioOwners(data, name, registry) end end + require("src.mods.DecorationContent").apply(self,data) -- dangling f.id references are attributed to the id's last writer; -- api 1 mods keep the warning-only compat path if data then diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 9014835cc..dd69d2166 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -1257,6 +1257,13 @@ R.text_pointers = { example = 'mod.content.text_pointers:patch("PalletTown", { TEXT_PALLETTOWN_SIGN = { text = "_MySign" } })', } +R.decorations = { + semantics="record",generations="2", + fields={type=f.int(1,6),name=f.str,action=f.opt(f.str),flag=f.int(0,65534),sprite=f.int(0,255)}, + example='mod.content.decorations:patch("deco:2", {name="CUSTOM"})', + notes="Native imported GSC DECO bytes (deco:0..255), ownership event bits, room callbacks and house-PC selection. Action must be a known SET_UP_/PUT_AWAY_ native decoration action. No retail attribute catalogue is bundled. Requires a complete imported native attribute table.", +} + -- ------- persistence -- compose, keyed by the owning mod id: the runner walks each owner's chain diff --git a/src/save_convert/Gen2Save.lua b/src/save_convert/Gen2Save.lua index 79a875bd7..10ae779eb 100644 --- a/src/save_convert/Gen2Save.lua +++ b/src/save_convert/Gen2Save.lua @@ -141,6 +141,7 @@ local LAYOUT_GEN2 = { numBalls = 0xD5FC, balls = 0xD5FD, numPCItems = 0xD616, pcItems = 0xD617, pokegearFlags = 0xD67C, + decorations = 0xD959, -- wDecoBed; eight bytes, big doll is last eventFlags = 0xD7B7, curBox = 0xD8BC, visitedSpawns = 0xD9EE, mapGroup = 0xDA00, mapNumber = 0xDA01, yCoord = 0xDA02, xCoord = 0xDA03, partyCount = 0xDA22, partySpecies = 0xDA23, partyMons = 0xDA2A, @@ -268,6 +269,7 @@ local LAYOUT_CRYSTAL = { numBalls = 0xD8D7, balls = 0xD8D8, numPCItems = 0xD8F1, pcItems = 0xD8F2, pokegearFlags = 0xD957, + decorations = 0xDC0F, -- wDecoBed, distinct from Gold/Silver eventFlags = 0xDA72, curBox = 0xDB72, visitedSpawns = 0xDCA5, mapGroup = 0xDCB5, mapNumber = 0xDCB6, yCoord = 0xDCB7, xCoord = 0xDCB8, partyCount = 0xDCD7, partySpecies = 0xDCD8, partyMons = 0xDCDF, @@ -365,6 +367,11 @@ end function Gen2Save.setLayout(version) applyLayout((type(version) == "table" and version) or LAYOUTS[tostring(version)] or LAYOUT_GEN2) + -- An unreviewed hack's legacy fallback layout does not prove where its + -- decoration bytes live. Explicit custom layouts may name their own run. + if type(version)~="table" and version~="gold" and version~="silver" and version~="crystal" then + O.decorations=nil + end end applyLayout(LAYOUT_GEN2) @@ -1024,6 +1031,9 @@ end -- decode: raw 32768-byte SRAM string -> save.lua-shaped table -- ------------------------------------------------------------------ +-- Physical WRAM order differs from the public semantic slot order. +local DECO_SLOTS={"bed","carpet","plant","poster","console","leftOrnament","rightOrnament","bigDoll"} + function Gen2Save.decode(bytes, data) assert(#bytes == Gen2Save.SAVE_SIZE, "expected a 32768-byte save") assert(charmap, "Gen2Save.setCharmap must be called before decode") @@ -1060,6 +1070,11 @@ function Gen2Save.decode(bytes, data) currentBox = 1, } + if O.decorations then + save.decorations={} + for i,key in ipairs(DECO_SLOTS)do save.decorations[key]=u8(bytes,O.decorations+i-1)end + end + -- bag: four pockets folded into one flat inventory local bagOrder = {} decodePairList(bytes, O.items, MAX_ITEMS, cw, save.inventory, bagOrder) @@ -1240,6 +1255,15 @@ function Gen2Save.encode(save, data, template) for i = 1, Gen2Save.SAVE_SIZE do buf[i] = zero end end + if O.decorations and type(save.decorations)=="table"then + for i,key in ipairs(DECO_SLOTS)do + local value=save.decorations[key]or 0 + assert(type(value)=="number"and value>=0 and value<=255 and value%1==0, + "invalid native decoration byte: "..key) + setByte(buf,O.decorations+i-1,value) + end + end + local player = save.player or {} local playerName = player.name or "GOLD" encodeName(buf, O.playerName, NAME_LENGTH, playerName) diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..9a394bc36 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -896,6 +896,20 @@ function Gen2Commands.scriptVarValue(ctx, var) return 1 end +function Commands.g2_deco_visibility(ctx) + require('src.world.Gen2Decorations').visibility(ctx) +end +function Commands.g2_deco_tiles(ctx) + require('src.world.Gen2Decorations').tiles(ctx) +end +function Commands.g2_deco_description(ctx,kind,allowed) + local target,name=require('src.world.Gen2Decorations').description(ctx.game.data,ctx.save,kind) + if target and allowed and allowed[target]then + if name then setBuffer(ctx.game,3,name)end + return target + end +end + -- Script_loadmem (25:$7495): write an immediate byte to a WRAM address. The -- same address-keyed mirror readmem/writemem use -- keyed by the raw -- address number exactly as they key it, so a script that pokes a byte here @@ -1779,6 +1793,19 @@ end -- --------------------------------------------------------------------------- function Commands.g2_warp(ctx, mapId, x, y, facing) + if mapId=="NONE" then + local ow=ctx.overworld + if ow and ow.map and type(ow.reloadMap)=="function"then + local map=ow.map.id + ctx.afterScript=ctx.afterScript or{} + ctx.afterScript[#ctx.afterScript+1]=function() + if ow.player and DIRS[facing]then ow.player.facing=DIRS[facing]end + ow:reloadMap(map,"native-script") + end + return math.huge + end + return + end if type(mapId) ~= "string" or mapId == "" then return end Commands.warp(ctx, mapId, x, y, facing) end diff --git a/src/script/Gen2ScriptVM.lua b/src/script/Gen2ScriptVM.lua index 0d5c51cfe..08070b97b 100644 --- a/src/script/Gen2ScriptVM.lua +++ b/src/script/Gen2ScriptVM.lua @@ -278,19 +278,30 @@ L.givepokemail = function() end -- NPC instead. -- loadtemptrainer/randomwildmon/loadpikachudata build a battle out of WRAM -- scratch the port fills from its own encounter tables. --- _2dmenu/describedecoration are presentation the port has no +-- _2dmenu is presentation the port has no -- equivalent for; the surrounding dialogue still runs. -- autoinput replays a canned joypad script over a cutscene. -- (catchtutorial used to live here too; it is implemented now, below.) local function vmNoop() end for _, name in ipairs({ "xycompare", "writeobjectxy", "loadtemptrainer", "randomwildmon", - "loadpikachudata", "_2dmenu", "describedecoration", + "loadpikachudata", "_2dmenu", "autoinput", }) do L[name] = vmNoop end +L.describedecoration=function(ir,s) + local p=s.decorationPrograms + if type(p)~='table'then return end + local allowed={} + for _,label in pairs(p.posters or{})do local target=branch(s,label);if target then allowed[target]=true end end + for _,role in ipairs({'ornament','big','null'})do + local target=branch(s,p[role]);if target then allowed[target]=true end + end + emit(s,{'g2_deco_description',ir[2],allowed}) +end + L["end"] = function(_, s) emit(s, { "g2_return" }) end @@ -1080,6 +1091,12 @@ local SPECIALS = { PokemonCenterPC = "g2_pokemon_center_pc", Special_PokemonCenterPC = "g2_pokemon_center_pc", PlayersHousePC = "g2_players_house_pc", + ToggleDecorationsVisibility = "g2_deco_visibility", + ToggleDecorationsVisibilitySpecial = "g2_deco_visibility", + Special_ToggleDecorationsVisibility = "g2_deco_visibility", + ToggleMaptileDecorations = "g2_deco_tiles", + ToggleMaptileDecorationsSpecial = "g2_deco_tiles", + Special_ToggleMaptileDecorations = "g2_deco_tiles", Special_PlayersHousePC = "g2_players_house_pc", Special_KrissHousePC = "g2_players_house_pc", MoveTutor = "g2_move_tutor", @@ -1215,9 +1232,6 @@ local SPECIALS_NOOP = { PlayCurMonCry = true, PlaySlowCry = true, SurfStartStep = true, - -- decorations are not modelled, so toggling their visibility does nothing - ToggleDecorationsVisibility = true, - ToggleMaptileDecorations = true, UnusedDummySpecial = true, UnusedBattleTowerDummySpecial1 = true, UnusedBattleTowerDummySpecial2 = true, @@ -2380,6 +2394,7 @@ function Gen2ScriptVM.compile(data, entry) local state = { out = out, stds = pool.stds, + decorationPrograms = pool.decorationPrograms, has = function(label) return type(label) == "string" and scripts[label] ~= nil end, diff --git a/src/script/Gen2Specials.lua b/src/script/Gen2Specials.lua index b554b340e..4c3cd65df 100644 --- a/src/script/Gen2Specials.lua +++ b/src/script/Gen2Specials.lua @@ -716,12 +716,16 @@ function Commands.g2_pokemon_center_pc(ctx) answer(ctx, 0) end --- PlayerPC is a Menu of its own, so it closes on its LOG OFF row and on B. --- Neither reaches this script, so there is nothing to wait on: push it and --- answer, exactly as g2_card_flip does. +-- The house script branches on the accumulated decoration-change bit only +-- after LOG OFF/B. Returning before the menu closes silently skips its reload. function Commands.g2_players_house_pc(ctx) - require("src.ui.Screens").push(ctx.game, "PlayerPC") - answer(ctx, 0) + local runner=ctx.runner + local done,changed=false,false + require("src.ui.Screens").push(ctx.game,"PlayerPC",{house=true,onDone=function(value) + if done then return end;done=true;changed=value==true;runner:resume() + end}) + runner:yield() + answer(ctx,changed and 1 or 0) end -- --------------------------------------------------------------------------- diff --git a/src/ui/Gen2DecorationMenu.lua b/src/ui/Gen2DecorationMenu.lua new file mode 100644 index 000000000..a091734cb --- /dev/null +++ b/src/ui/Gen2DecorationMenu.lua @@ -0,0 +1,106 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- The PC changes placed bytes. Room callbacks render them after native reload. +local Native=require('src.world.Gen2Decorations') +local Menu=require('src.ui.Menu') +local Runner=require('src.script.ScriptRunner') +local Strings=require('src.core.Strings') +local M={} +local categories={ + {'BED','bed'},{'CARPET','carpet'},{'PLANT','plant'},{'POSTER','poster'}, + {'GAME CONSOLE','console'},{'ORNAMENT','ornament'},{'BIG DOLL','bigDoll'}, +} +function M.new(game,opts) + opts=opts or{} + local changed,finished=false,false + local programs=Native.pool(game.data)and Native.pool(game.data).decorationPrograms + local texts=programs and programs.texts or{} + local function finish() + if finished then return end;finished=true + if opts.onDone then opts.onDone(changed)end + end + local function say(role,onDone,extra) + local id=texts[role] + if not id then if onDone then onDone()end;return end + local r=Runner.new(game,game.overworld) + r:run({{'show_text',id,nil,extra}}, {onDone=onDone}) + return r + end + local function perform(id,side) + local row=Native.attributes(game.data,id);local action=row and Native.action(row.action) + local state=Native.state(game.data,game.save) + if not(state and action)or not action.put and not Native.owns(game.data,game.save,id)then return end + local slot=action.slot or(side=='left'and 'leftOrnament'or side=='right'and 'rightOrnament') + if not slot then return end + local old=state[slot]or 0 + game.stringBuffers=game.stringBuffers or{} + game.stringBuffers[3]=Native.name(game.data,old) + game.stringBuffers[4]=Native.name(game.data,id) + local role + if action.put then role=old==0 and 'empty'or 'put' + elseif old==id then role='same' + elseif old==0 then role='setup';game.stringBuffers[3]=Native.name(game.data,id) + else role='replace'end + say(role,function()changed=Native.choose(game.data,game.save,id,side)or changed end) + end + local function select(id) + local row=Native.attributes(game.data,id);local action=row and Native.action(row.action) + if not action then return end + if not action.ornament then perform(id);return end + local selected,question + local function close(side) + selected=side + if question then game.stack:pop();question:resume() + elseif side then perform(id,side)end + end + local function sides() + game.stack:push(Menu.new(game,{ + {label=Strings('RIGHT SIDE'),onSelect=function()close('right')end}, + {label=Strings('LEFT SIDE'),onSelect=function()close('left')end}, + {label=Strings('CANCEL'),onSelect=function()close()end}, + },{tx=6,ty=6,tw=14,th=8,noSound=true,onCancel=function()close()end})) + end + local role=action.put and 'sidePut'or 'sideSetup' + if texts[role]then + question=say(role,function()if selected then perform(id,selected)end end,{stay={onShown=sides}}) + else sides()end + end + local function category(key) + local items,headers,seen={},{},{} + for raw,row in pairs(Native.pool(game.data).decorations)do + local id=tonumber(raw);local action=Native.action(row.action) + local group=action and(action.slot or 'ornament') + if id and id~=0 and group==key and not seen[id]then + seen[id]=true + if action.put then headers[#headers+1]=id + elseif Native.owns(game.data,game.save,id)then items[#items+1]=id end + end + end + table.sort(items);table.sort(headers) + return items,headers + end + local rows={} + if Native.ready(game.data)and Native.state(game.data,game.save)then + for _,c in ipairs(categories)do + local key=c[2];local ids=category(key) + if #ids>0 then + rows[#rows+1]={label=Strings(c[1]),keepOpen=true,onSelect=function() + local owned,headers=category(key) + if #owned==0 then say('noChoice');return end + local items={} + for _,id in ipairs(owned)do + local value=id;items[#items+1]={label=Native.name(game.data,value),onSelect=function()select(value)end} + end + for _,id in ipairs(headers)do + local value=id;items[#items+1]={label=Native.name(game.data,value),onSelect=function()select(value)end} + end + items[#items+1]={label=Native.name(game.data,0)~=''and Native.name(game.data,0)or Strings('CANCEL')} + game.stack:push(Menu.new(game,items,{tx=0,ty=0,tw=20,maxVisible=8,noSound=true})) + end} + end + end + end + rows[#rows+1]={label=Strings('EXIT'),onSelect=finish} + return Menu.new(game,rows,{tx=5,ty=0,tw=15,maxVisible=8,noSound=true,onCancel=finish}) +end +return M diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index 8203e0800..704b885a1 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -169,19 +169,31 @@ PlayerPC.withdraw = withdraw PlayerPC.deposit = deposit PlayerPC.toss = toss -function PlayerPC.new(game) +function PlayerPC.new(game,opts) + opts=opts or{} + local changed,done=false,false + local function finish() + if done then return end;done=true + if opts.onDone then opts.onDone(changed)end + end game.save.pcItems = game.save.pcItems or {} - return Menu.new(game, { + local items={ -- keepOpen so B in the item lists returns here instead of dropping the -- whole PC session (players_pc.asm re-shows the PC menu); same pattern -- as BoxMenu's rows { label = Strings("WITHDRAW ITEM"), keepOpen = true, onSelect = function() withdraw(game) end }, { label = Strings("DEPOSIT ITEM"), keepOpen = true, onSelect = function() deposit(game) end }, { label = Strings("TOSS ITEM"), keepOpen = true, onSelect = function() toss(game) end }, - { label = Strings("LOG OFF") }, + { label = Strings("LOG OFF"),onSelect=finish }, -- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm -- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10 - }, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true }) + } + if opts.house and require("src.world.Gen2Decorations").ready(game.data)then + table.insert(items,#items,{label=Strings("DECORATION"),keepOpen=true,onSelect=function() + require("src.ui.Screens").push(game,"Gen2DecorationMenu",{onDone=function(value)changed=value or changed end}) + end}) + end + return Menu.new(game,items,{tx=0,ty=0,tw=16,maxVisible=8,noSound=true,onCancel=finish}) end return PlayerPC diff --git a/src/world/Gen2Decorations.lua b/src/world/Gen2Decorations.lua new file mode 100644 index 000000000..d7eab2a3b --- /dev/null +++ b/src/world/Gen2Decorations.lua @@ -0,0 +1,133 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Native ownership flags, placed bytes and callback presentation are distinct. +-- Attribute/name rows come from the current cartridge, never a retail table. +local V=require('src.core.GameVersion') +local Strings=require('src.core.Strings') +local M={} +M.slots={'bed','carpet','plant','poster','console','bigDoll','leftOrnament','rightOrnament'} +local kinds={'BED','CARPET','PLANT','POSTER','CONSOLE','BIG_DOLL','DOLL'} +local slotFor={BED='bed',CARPET='carpet',PLANT='plant',POSTER='poster',CONSOLE='console',BIG_DOLL='bigDoll'} +local actions={} +for _,kind in ipairs(kinds)do + actions[#actions+1]='SET_UP_'..kind;actions[#actions+1]='PUT_AWAY_'..kind +end +function M.actionName(n)return actions[n]end +function M.action(name) + if type(name)~='string'then return nil end + local kind=name:match('^SET_UP_(.+)$');local put=false + if not kind then kind=name:match('^PUT_AWAY_(.+)$');put=true end + if kind=='DOLL'then return{ornament=true,put=put}end + return slotFor[kind] and {slot=slotFor[kind],put=put}or nil +end +function M.pool(data)return data and data.map_scripts end +function M.ready(data) + local p=M.pool(data);local info=p and p.decorationInfo + return V.generation(V.get())==2 and type(p)=='table' and type(p.decorations)=='table' and + type(info)=='table' and info.complete==true or false +end +function M.attributes(data,id) + if not M.ready(data)then return nil end + local rows=M.pool(data).decorations + return rows[tonumber(id)]or rows[tostring(id)] +end +function M.state(data,save) + if not M.ready(data)or type(save)~='table'then return nil end + if save.decorations==nil then + local info=M.pool(data).decorationInfo + if not info.defaultsComplete then return nil end + save.decorations=require('src.mods.Merge').deepCopy(info.defaults or{}) + end + return type(save.decorations)=='table'and save.decorations or nil +end +local names={ + [2]=Strings.source('%s BED'),[3]=Strings.source('%s CARPET'), + [4]=Strings.source('%s POSTER'),[5]=Strings.source('%s DOLL'),[6]=Strings.source('BIG %s'), +} +function M.name(data,id) + local row=M.attributes(data,id) + if not row then return ''end + local name=row.name + if row.type>=4 then + local info=M.pool(data).decorationInfo + local key=info.sourceNames and info.sourceNames[id]==name and info.species and info.species[id]or name + local mon=data.pokemon and data.pokemon[key] + name=mon and mon.name or name + end + return names[row.type] and Strings(names[row.type],name)or name +end +function M.owns(data,save,id) + local row=M.attributes(data,id);local action=row and M.action(row.action) + return type(save)=='table' and type(save.flags)=='table' and id~=0 and action and not action.put and require('src.script.Flags').get(save, + require('src.script.Gen2Flags').eventFlag(row.flag))or false +end +function M.give(data,save,id) + local row=M.attributes(data,id) + if type(save)~='table' or id==0 or not row or not M.action(row.action)or M.action(row.action).put then return false end + save.flags=save.flags or{} + require('src.script.Flags').set(save,require('src.script.Gen2Flags').eventFlag(row.flag));return true +end +function M.giveFlag(data,save,n) + local info=M.pool(data)and M.pool(data).decorationInfo + local id=info and info.gifts and info.gifts[(tonumber(n)or -1)+1] + return id~=nil and M.give(data,save,id)or false +end +function M.choose(data,save,id,side) + local state=M.state(data,save);local row=M.attributes(data,id) + local action=row and M.action(row.action) + if id==0 or not(state and action)then return false end + if not action.put and not M.owns(data,save,id)then return false end + local slot=action.slot + if action.ornament then + if side~='left'and side~='right'then return false end + slot=side=='left'and 'leftOrnament'or 'rightOrnament' + end + local value=action.put and 0 or id + local changed=(state[slot]or 0)~=value + state[slot]=value + if changed and action.ornament and not action.put then + local other=side=='left'and 'rightOrnament'or 'leftOrnament' + if state[other]==id then state[other]=0 end + end + return changed +end +-- These callbacks deliberately preserve the VM's current script/check vars. +function M.visibility(ctx) + local data=ctx.game.data;local state=M.state(data,ctx.save) + if not state then return end + ctx.save.flags=ctx.save.flags or{} + local C=require('src.script.Commands');local Flags=require('src.script.Gen2Flags') + for _,binding in ipairs({{'console',0,1857},{'leftOrnament',1,1858},{'rightOrnament',2,1859},{'bigDoll',3,1860}})do + local id=state[binding[1]]or 0;local row=M.attributes(data,id) + if id~=0 and row then + C.g2_variablesprite(ctx,binding[2],row.sprite);C.clear_flag(ctx,Flags.eventFlag(binding[3])) + else C.set_flag(ctx,Flags.eventFlag(binding[3]))end + end +end +function M.tiles(ctx) + local data=ctx.game.data;local state=M.state(data,ctx.save) + if not state then return end + ctx.save.flags=ctx.save.flags or{} + local C=require('src.script.Commands') + local function paint(slot,x,y) + local id=state[slot]or 0;local row=id~=0 and M.attributes(data,id) + if row then C.replace_block(ctx,x,y,row.sprite);return row.sprite end + end + paint('bed',0,2);paint('plant',3,2);paint('poster',3,0) + local carpet=paint('carpet',0,0) + if carpet then + C.replace_block(ctx,0,1,(carpet+1)%256);C.replace_block(ctx,1,1,(carpet+2)%256) + C.replace_block(ctx,2,1,(carpet+1)%256) + end + local flag=require('src.script.Gen2Flags').eventFlag(716) + if (state.poster or 0)~=0 then C.set_flag(ctx,flag)else C.clear_flag(ctx,flag)end +end +function M.description(data,save,kind) + local state=M.state(data,save);local programs=M.pool(data)and M.pool(data).decorationPrograms + if not state or type(programs)~='table'then return nil end + if kind==0 then return programs.posters and programs.posters[state.poster or 0]or programs.null end + if kind==3 then return programs.big end + local slot=({[1]='leftOrnament',[2]='rightOrnament',[4]='console'})[kind] + if slot then return programs.ornament,M.name(data,state[slot]or 0)end +end +return M diff --git a/tests/engine/gen2_decoration_content.lua b/tests/engine/gen2_decoration_content.lua new file mode 100644 index 000000000..2b5162754 --- /dev/null +++ b/tests/engine/gen2_decoration_content.lua @@ -0,0 +1,194 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local old=V.get() +local Native=require('src.world.Gen2Decorations');local E=require('src.import.RomExtractorGen2') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner') +local Commands=require('src.script.Commands');local Flags=require('src.script.Gen2Flags') +local Stack=require('src.core.StateStack');local TextBox=require('src.render.TextBox') +local Merge=require('src.mods.Merge') +local function fixture() + local d=T.fixtures.fresh() + d.map_scripts={decorations={ + [0]={type=1,name='CANCEL',flag=0,sprite=0}, + [1]={type=1,name='STORE BED',action='PUT_AWAY_BED',flag=0,sprite=0}, + [2]={type=2,name='SYNTHETIC',action='SET_UP_BED',flag=500,sprite=0}, + [3]={type=2,name='OTHER',action='SET_UP_BED',flag=501,sprite=44}, + [4]={type=1,name='STORE DOLL',action='PUT_AWAY_DOLL',flag=0,sprite=0}, + [5]={type=5,name='MODEL',action='SET_UP_DOLL',flag=502,sprite=18}, + [6]={type=1,name='CONSOLE',action='SET_UP_CONSOLE',flag=503,sprite=20}, + [7]={type=1,name='MAP',action='SET_UP_POSTER',flag=504,sprite=22}, + [8]={type=1,name='RUG',action='SET_UP_CARPET',flag=505,sprite=255}, + [9]={type=1,name='PLANT',action='SET_UP_PLANT',flag=506,sprite=24}, + [10]={type=6,name='MODEL',action='SET_UP_BIG_DOLL',flag=507,sprite=26}, + },decorationInfo={complete=true,defaultsComplete=true,defaults={bed=2,poster=7}, + giftsComplete=true,gifts={2,3,5},sourceNames={[5]='MODEL'},species={[5]='SPECIES_001'}}, + decorationPrograms={posters={[7]='POSTER'},null='NULL',ornament='ORNAMENT',big='BIG', + texts={put='PUT',empty='EMPTY',setup='SET',replace='REPLACE',same='SAME',sidePut='SIDE_PUT',sideSetup='SIDE_SET'}}, + scripts={CALLBACK={{'special',0,'ToggleDecorationsVisibility'},{'special',0,'ToggleMaptileDecorations'},{'endcallback'}}, + DESC={{'describedecoration',1}},POSTER={{'setval',70},{'end'}},NULL={{'setval',71},{'end'}}, + ORNAMENT={{'setval',72},{'end'}},BIG={{'setval',73},{'end'}}, + PC={{'special',0,'PlayersHousePC'},{'iftrue','RELOAD'},{'end'}},RELOAD={{'warp','NONE',0,0,0},{'end'}}}} + d.text={PUT='{RAM:wStringBuffer3}',SET='{RAM:wStringBuffer3}',REPLACE='{RAM:wStringBuffer3}/{RAM:wStringBuffer4}', + SAME='SYNTHETIC SAME',EMPTY='SYNTHETIC EMPTY',SIDE_SET='SYNTHETIC SIDE SET',SIDE_PUT='SYNTHETIC SIDE PUT'} + d.pokemon.SPECIES_001={name='RENAMED'} + return d +end +local function load(d,code,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local files,paths={},{} + if code then + files['mods/deco/manifest.json']=Json.encode({id='deco',name='Synthetic decorations',version='1.0.0', + api=mode and mode[1]or 2,content_format=mode and mode[2]or 'native',entry='main.lua',affects_link=false}) + files['mods/deco/main.lua']='local mod=...;mod.exports.api=mod;'..code;paths={'mods/deco'} + end + return T.sdk.loadMods(paths,{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +local function game(d) + local stack=setmetatable({},{__index=Stack});stack:init();stack:push({name='map'}) + local g={data=d,save={flags={},inventory={}},stack=stack,input={wasPressed=function()return false end}} + g.overworld={map={id='ROOM'},player={cellX=4,cellY=5,facing='left'},paint={},reloaded=0} + function g.overworld:replaceBlock(x,y,n)self.paint[x..','..y]=n end + function g.overworld:reloadMap(id)assert(id=='ROOM');self.reloaded=self.reloaded+1;Native.visibility({game=g,save=g.save});Native.tiles({game=g,save=g.save,overworld=self})end + return g +end +local function run(g,label) + local r=Runner.new(g,g.overworld);r:run(assert(VM.compile(g.data,label)));return r +end +local function choose(g,label) + local menu=g.stack:top() + for i,row in ipairs(menu.items)do if row.label==label then menu.index=i;g.input={wasPressed=function(_,key)return key=='a'end};g.stack:update(0) + g.input={wasPressed=function()return false end};return end end + error('missing menu choice '..label) +end +local function dismiss(g) + local box=g.stack:top();assert(getmetatable(box)==TextBox);box.done=true;g.input={wasPressed=function(_,key)return key=='a'end};g.stack:update(0);g.input={wasPressed=function()return false end} +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture();local before=d.map_scripts.decorations + local r=load(d,[[ + local p=mod.content["decorations"] + assert(p:supports()) + local copy=p:get('deco:2');copy.sprite=199;assert(p:get('deco:2').sprite==0) + p:patch('deco:2',{sprite=88}) + p:register('deco:200',{type=1,name='ADDED',flag=900,action='SET_UP_PLANT',sprite=77}) + ]],mode) + local label=version..' API '..mode[1]..' '..mode[2] + T.eq(#r.errors,0,label..' actual Loader accepts native DECO registry') + T.eq(d.map_scripts.decorations[2].sprite,88,label..' patch reaches actual native table') + T.eq(d.map_scripts.decorations[200].flag,900,label..' new byte reaches actual native table') + local g=game(d);T.eq(Native.state(d,g.save).bed,2,label..' initializes cartridge default once') + T.check(Native.give(d,g.save,200),label..' give owns authored record through actual event') + T.eq(g.save.flags[Flags.eventFlag(900)],true,label..' ownership is the authored flag') + T.check(Native.choose(d,g.save,200),label..' owned authored placement succeeds') + run(g,'CALLBACK') + T.eq(g.overworld.paint['3,2'],77,label..' actual VM callback paints authored block') + T.eq(g.overworld.paint['0,2'],88,label..' actual VM callback paints patched bed') + T.eq(g.save.flags[Flags.eventFlag(1857)],true,label..' empty console object is hidden') + T.eq(g.save.flags[Flags.eventFlag(716)],true,label..' placed poster enables native event') + finish(r) + local clean=load(d) + T.eq(d.map_scripts.decorations,before,label..' disabling restores pristine table identity') + T.eq(d.map_scripts.decorations[200],nil,label..' disabling removes owned addition') + finish(clean) + end + local d=fixture();local g=game(d) + T.eq(Native.choose(d,g.save,3),false,version..' unowned placement fails') + T.eq(Native.give(d,g.save,0),false,version..' CANCEL cannot be awarded') + T.eq(Native.give(d,g.save,1),false,version..' PUT header cannot be awarded') + T.check(Native.giveFlag(d,g.save,1),version..' actual gift ordinal awards real DECO byte') + T.eq(Native.owns(d,g.save,3),true,version..' gift ownership reaches event bit') + T.eq(Native.name(d,5),'RENAMED DOLL',version..' imported species identity follows actual name') + d.map_scripts.decorations[5].name='AUTHORED';T.eq(Native.name(d,5),'AUTHORED DOLL',version..' authored name wins old imported source') + for id=2,10 do Native.give(d,g.save,id)end + local state=Native.state(d,g.save) + state.leftOrnament=5;state.rightOrnament=5 + T.eq(Native.choose(d,g.save,5,'left'),false,version..' already placed doll is not a change') + T.eq(state.rightOrnament,5,version..' already placed doll does not mutate opposite side') + state.leftOrnament=0 + T.eq(Native.choose(d,g.save,5,'left'),true,version..' owned doll moves sides') + T.eq(state.rightOrnament,0,version..' real move clears opposite duplicate') + Native.choose(d,g.save,6);Native.choose(d,g.save,8);Native.choose(d,g.save,10) + local callback=run(g,'CALLBACK') + T.eq(g.save.gen2VarSprites[0],20,version..' console writes actual native variable sprite') + T.eq(g.save.gen2VarSprites[1],18,version..' left doll writes actual native variable sprite') + T.eq(g.save.gen2VarSprites[3],26,version..' big doll writes actual native variable sprite') + T.eq(g.overworld.paint['0,2'],0,version..' zero is valid block for nonzero placed DECO') + T.eq(g.overworld.paint['0,1'],0,version..' carpet offsets wrap at byte boundary') + T.eq(g.overworld.paint['1,1'],1,version..' carpet second offset wraps at byte boundary') + local desc=run(g,'DESC') + T.eq(desc.ctx.g2Var,72,version..' native describedecoration jumps to imported ornament script') + T.eq(g.stringBuffers[3],'AUTHORED DOLL',version..' native description fills real buffer three') + -- Real menus, native VM, TextBox and StateStack; only render/audio are harness IO. + g=game(fixture());Native.give(g.data,g.save,3);Native.give(g.data,g.save,5) + local pc=run(g,'PC') + T.check(pc:isRunning(),version..' native house-PC script waits for menu closure') + T.eq(pc.ctx.g2Var,nil,version..' native result is not guessed before selection') + choose(g,'DECORATION');choose(g,'BED');choose(g,'OTHER BED') + T.eq(Native.state(g.data,g.save).bed,2,version..' selection waits for original feedback box') + T.eq(g.stringBuffers[3],'SYNTHETIC BED',version..' replacement feedback buffers old name') + T.eq(g.stringBuffers[4],'OTHER BED',version..' replacement feedback buffers new name') + dismiss(g) + T.eq(g.save.decorations.bed,3,version..' acknowledging feedback commits native placed byte') + T.eq(g.overworld.reloaded,0,version..' furniture remains unchanged before PC exit') + choose(g,'ORNAMENT');choose(g,'RENAMED DOLL') + local prompt=g.stack:top();prompt.done=true;prompt:update(0) + choose(g,'RIGHT SIDE');dismiss(g) + T.eq(g.save.decorations.rightOrnament,5,version..' actual native side menu commits right doll') + choose(g,'EXIT') + T.check(pc:isRunning(),version..' leaving decoration submenu does not resume PC script') + choose(g,'LOG OFF') + T.eq(pc:isRunning(),false,version..' LOG OFF completes actual native PC script') + T.eq(pc.ctx.g2Var,1,version..' changed bit returns after menu closes') + T.eq(g.overworld.reloaded,1,version..' native NONE warp reloads exactly once') + T.eq(g.overworld.player.cellX,4,version..' NONE reload retains current cell rather than zero coordinates') + T.eq(g.overworld.paint['0,2'],44,version..' reload callback uses final selected bed') + T.eq(g.save.gen2VarSprites[2],18,version..' reload callback uses selected right doll') + g.data.map_scripts.scripts.FACE={{'warpfacing',3,'NONE',0,0,0},{'end'}} + g.overworld.player.facing='down';run(g,'FACE') + T.eq(g.overworld.player.facing,'right',version..' native NONE warpfacing applies its explicit direction') + g=game(fixture());pc=run(g,'PC');g.input={wasPressed=function(_,key)return key=='b'end};g.stack:update(0) + T.eq(pc.ctx.g2Var,0,version..' PC cancellation returns unchanged') + T.eq(g.overworld.reloaded,0,version..' PC cancellation cannot reload') +end +V.set('crystal') +for _,code in ipairs({ + "p:patch('deco:2',{sprite=256})", "p:patch('deco:2',{flag=65535})", "p:patch('deco:2',{type=0})", + "p:patch('deco:2',{action='GUESS'})", "p:patch('deco:2',{sprite=0/0})", "p:patch('deco:2',{flag=math.huge})", + "p:patch('deco:02',{sprite=3})", "p:patch('deco:256',{sprite=3})", "p:patch('deco:2',{unknown=true})", + "p:patch('deco:2',{sprite=44});error('entry failed')", +})do + local d=fixture();local r=load(d,'local p=mod.content["decorations"];'..code) + T.check(#r.errors>0,'invalid or failed decoration contribution is rejected') + T.eq(d.map_scripts.decorations[2].sprite,0,'failed entry rolls back all native decoration writes') + finish(r) +end +do + local d=fixture();local original=d.map_scripts.decorations + original['2']=Merge.deepCopy(original[2]) + local r=load(d,[[local p=mod.content["decorations"];p:remove('deco:2');p:patch('deco:3',{sprite=66}); + p:register('deco:200',{type=1,name='EXTERNAL',flag=901,action='SET_UP_PLANT',sprite=77})]]) + T.eq(d.map_scripts.decorations[2],nil,'remove clears numeric DECO alias') + T.eq(d.map_scripts.decorations['2'],nil,'remove clears string DECO alias') + d.map_scripts.decorations[200].sprite=99 + finish(r);local clean=load(d) + T.eq(d.map_scripts.decorations[2].sprite,0,'reload restores removed native numeric record') + T.eq(d.map_scripts.decorations['2'].sprite,0,'reload restores removed native string alias') + T.eq(d.map_scripts.decorations[3].sprite,44,'reload retires an unchanged owned patch') + T.eq(d.map_scripts.decorations[200].sprite,99,'reload preserves externally edited authored record independently') + finish(clean) +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + V.set(version);local d=fixture();if version=='prism'or version=='polishedcrystal'then d.map_scripts.decorationInfo.complete=false end + local before=d.map_scripts.decorations + local r=load(d,[[local p=mod.content["decorations"];assert(not p:supports());local v,why=p:patch('deco:2',{sprite=99});assert(v==nil and why)]]) + T.eq(#r.errors,0,version..' unsupported native decoration target declines explicitly') + T.eq(d.map_scripts.decorations,before,version..' unsupported generation does not write native namespace') + finish(r) +end +V.set(old);T.finish('native Gen2 decoration content, room callbacks and house PC') diff --git a/tests/engine/gen2_decoration_import.lua b/tests/engine/gen2_decoration_import.lua new file mode 100644 index 000000000..fe2409fb1 --- /dev/null +++ b/tests/engine/gen2_decoration_import.lua @@ -0,0 +1,113 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local old=V.get() +local E=require('src.import.RomExtractorGen2');local Native=require('src.world.Gen2Decorations') +local S=require('src.save_convert.Gen2Save');local Json=require('src.link.Json') +local function fixture(version,edit) + local raw=string.rep('\0',0x8000) + local function put(at,bytes)raw=raw:sub(1,at)..bytes..raw:sub(at+#bytes+1)end + local symbols={DecorationAttributes={1,0x4000},DecorationNames={1,0x401e}, + PokemonNames={1,0x4300},InitDecorations={1,0x4100},DecorationIDs={1,0x4200}, + wDecoBed={1,0xd000},wDecoPoster={1,0xd003},DecorationDesc_PosterPointers={1,0x4400}, + ['DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript']={1,0x4500}, + ['DecorationDesc_GiantOrnament.BigDollScript']={1,0x4501},DecorationDesc_NullPoster={1,0x4502}, + PutAwayTheDecoText={1,0x4600},NothingToPutAwayText={1,0x4602},SetUpTheDecoText={1,0x4604}} + -- Authored test cartridge, not a retail attribute catalogue. + put(0x4000,string.char(1,0,0,0,0,0,1,1,2,0,0,0,2,2,1,0x34,0x12,0, + 1,1,8,0,0,0,4,1,7,0x35,0x12,77)) + put(0x401e,string.char(128,0x50,129,0x50,130,0x50)) + put(0x4100,string.char(0x3e,2,0xea,0,0xd0,0x3e,4,0xea,3,0xd0,0xc9)) + put(0x4200,string.char(2,4,255));put(0x4300,string.char(131,0x50)) + put(0x4400,string.char(4,3,0x45,255));put(0x4500,string.char(0x91,0x91,0x91,0x91)) + if edit then edit(symbols,put)end + local e=E.new(raw,version,{symbols=symbols}) + function e:readSourceTable(k)assert(k=='charmap');return{['128']='X',['129']='Y',['130']='Z',['131']='Q'}end + return e +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local e=fixture(version);local rows,info=e:gen2Decorations() + T.check(info.complete,version..' native attribute boundary defines valid table') + T.eq(info.rowsRead,5,version..' reader imports exact authored row count') + T.eq(rows[0].name,'X',version..' DECO zero remains in actual native table') + T.eq(rows[2].name,'Z',version..' names use actual character decoder') + T.eq(rows[2].flag,0x1234,version..' event identity is native little-endian word') + T.eq(rows[2].sprite,0,version..' block zero survives real import') + T.eq(rows[4].name,'Q',version..' species name comes from cartridge indexed table') + T.eq(info.species[4],'SPECIES_001',version..' raw species identity has no National Dex inference') + T.eq(info.defaults.bed,2,version..' bed default comes from native init operands') + T.eq(info.defaults.poster,4,version..' poster default comes from native init operands') + T.eq(#info.gifts,2,version..' gift ordinal table is ROM-derived') + T.eq(info.gifts[2],4,version..' gift ordinal retains actual DECO byte') + local q={scripts={},queue={},movements={},instructions=0,desyncs=0,failures=0} + local roles=e:gen2QueueDecorationPrograms(q) + T.eq(#q.queue,4,version..' actual queue includes three description roles and poster') + T.check(roles.texts.setup:match('^TEXT_S'),version..' native feedback enters shared text import') + T.check(e._gen2ScriptTexts[roles.texts.setup]~=nil,version..' feedback text is registered for actual extractor') + e:gen2QueueDecorationPrograms(q);T.eq(#q.queue,4,version..' duplicate queue calls are idempotent') + local n=1;while n<=#q.queue do local job=q.queue[n];e:gen2DecodeScript(job[1],job[2],job[3],q);n=n+1 end + T.eq(q.desyncs,0,version..' actual bytecode decoder accepts queued description programs') + for _,edit in ipairs({ + function(s)s.DecorationNames[2]=0x401f end, + function(s)s.DecorationNames[1]=2 end, + function(s)s.DecorationAttributes=nil end, + function(s,put)put(0x4000,string.char(0))end, + function(s,put)put(0x4002,string.char(15))end, + function(s,put)put(0x4003,string.char(255,255))end, + function(s,put)put(0x401e,string.rep(string.char(128),129))end, + function(s)s.PokemonNames=nil end, + })do + local bad,why=fixture(version,edit):gen2Decorations() + T.eq(why.complete,false,version..' malformed attribute/name input cannot become ready') + T.eq(next(bad),nil,version..' malformed attribute/name input publishes no prefix') + end + local _,missing=fixture(version,function(s)s.InitDecorations=nil end):gen2Decorations() + T.eq(missing.complete,true,version..' missing init does not erase valid attribute table') + T.eq(missing.defaultsComplete,false,version..' missing init cannot invent retail defaults') + local _,wrong=fixture(version,function(s)s.wDecoBed[2]=0xd100 end):gen2Decorations() + T.eq(wrong.defaultsComplete,false,version..' init stores must target actual WRAM symbols') + local _,badGifts=fixture(version,function(_,put)put(0x4200,string.rep(string.char(2),256))end):gen2Decorations() + T.eq(badGifts.giftsComplete,false,version..' unterminated gifts cannot be claimed complete') + T.eq(badGifts.gifts,nil,version..' malformed gifts publish no prefix') + q={scripts={},queue={}} + T.eq(fixture(version,function(s)s.DecorationDesc_NullPoster=nil end):gen2QueueDecorationPrograms(q),nil,version..' missing description role declines whole binding') + T.eq(#q.queue,0,version..' missing role is rejected before queue mutation') + q={scripts={},queue={}} + T.eq(fixture(version,function(_,put)put(0x4400,string.char(4,0,0))end):gen2QueueDecorationPrograms(q),nil,version..' invalid poster pointer declines binding') + T.eq(#q.queue,0,version..' invalid poster table leaves queue untouched') + -- Resolve the precise native NONE pair, without accepting an unknown map. + e=fixture(version);function e:gen2MapIndex()return{}end;function e:gen2Trainers()return{}end + local p={scripts={TEST={{'warp',0,0,0,0},{'warpfacing',2,0,0,0,0},{'warp',0,1,0,0},{'blackoutmod',0,0}}}} + e:gen2ResolveScriptMapIds({}, {},p) + T.eq(p.scripts.TEST[1][2],'NONE',version..' only actual NONE warp becomes reload') + T.eq(p.scripts.TEST[2][3],'NONE',version..' warpfacing uses its own map-pair slot') + T.eq(p.scripts.TEST[3][2],0,version..' unknown map is not turned into a reload') + T.eq(p.scripts.TEST[4][2],0,version..' non-warp zero pair is not reinterpreted') + -- Independent manifest WRAM addresses verify the save codec's physical order. + local f=assert(io.open('tools/rom_manifest_'..version..'.json','rb'));local manifest=assert(Json.decode(f:read('*a')));f:close() + S.setLayout(version);S.setCharmap({['128']='A'}) + local d=T.fixtures.fresh();local save={player={name='A'},decorations={bed=1,carpet=2,plant=3,poster=4, + console=5,leftOrnament=6,rightOrnament=7,bigDoll=255}} + local raw=S.encode(save,d) + local anchor=version=='crystal'and 0xd47b or 0xd1a1 + for _,key in ipairs(Native.slots)do + local symbol='wDeco'..key:sub(1,1):upper()..key:sub(2) + local address=manifest.symbols[symbol][2];local offset=0x2009+address-anchor + T.eq(raw:byte(offset+1),save.decorations[key],version..' export matches independent '..symbol..' address') + end + local decoded=S.decode(raw,d) + for _,key in ipairs(Native.slots)do T.eq(decoded.decorations[key],save.decorations[key],version..' import preserves exact '..key..' byte')end + local round=S.encode(decoded,d,raw);local warnings=S.decode(round,d).warnings + local checksumOk=true;for _,w in ipairs(warnings)do if w:match('checksum')then checksumOk=false end end + T.check(checksumOk,version..' decoration export retains valid native checksum') + local saved=decoded.decorations;decoded.decorations=nil + local untouched=S.encode(decoded,d,raw) + for i=0,7 do T.eq(untouched:byte(S.OFFSETS.decorations+i+1),raw:byte(S.OFFSETS.decorations+i+1),version..' absent legacy state preserves imported decoration template')end + decoded.decorations=saved;decoded.decorations.bed=256 + T.check(not pcall(S.encode,decoded,d,raw),version..' invalid placed byte cannot wrap and corrupt native export') +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + V.set(version);T.eq(fixture(version):gen2Decorations(),nil,version..' unreviewed layout imports no stock catalogue') + S.setLayout(version);T.eq(S.OFFSETS.decorations,nil,version..' unknown save layout has no guessed decoration offset') +end +V.set(old);S.setLayout(old);T.finish('native Gen2 decoration ROM producers and eight-byte SRAM codec') From e49446d9b9beba591e4210302fa9e9a9eb1132d5 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:22:40 +0100 Subject: [PATCH 091/138] Complete native bedroom acquisition through Mom savings and original shopping scripts --- docs/native-gen2-decorations.md | 3 +- docs/native-gen2-mom-shopping.md | 87 +++++++++++++ src/battle/BattleState.lua | 34 +++-- src/import/RomExtractorGen2.lua | 50 +++++++ src/import/RomImporter.lua | 2 +- src/save_convert/Gen2Save.lua | 39 ++++++ src/script/Gen2Commands.lua | 10 +- src/world/Gen2MomShopping.lua | 139 ++++++++++++++++++++ src/world/Gen2PhoneService.lua | 11 ++ src/world/OverworldController.lua | 41 ++++++ tests/engine/gen2_mom_import.lua | 98 ++++++++++++++ tests/engine/gen2_mom_shopping.lua | 201 +++++++++++++++++++++++++++++ 12 files changed, 691 insertions(+), 24 deletions(-) create mode 100644 docs/native-gen2-mom-shopping.md create mode 100644 src/world/Gen2MomShopping.lua create mode 100644 src/world/Gen2PhoneService.lua create mode 100644 tests/engine/gen2_mom_import.lua create mode 100644 tests/engine/gen2_mom_shopping.lua diff --git a/docs/native-gen2-decorations.md b/docs/native-gen2-decorations.md index 22c63ead3..e9d31d63e 100644 --- a/docs/native-gen2-decorations.md +++ b/docs/native-gen2-decorations.md @@ -72,7 +72,8 @@ executes the original house-PC/description scripts and room callbacks, and round-trips all eight placed bytes. Original ROM hashes are checked unchanged. The local font/audio/render harness is used; no visual acceptance is inferred. -Full legacy `Decorations` facade parity, the complete gift/purchase lifecycle, +Mom’s native shopping caller is covered separately in [native-gen2-mom-shopping.md](native-gen2-mom-shopping.md). +Full legacy `Decorations` facade parity, the remaining gift lifecycle, new graphical acceptance and a full unchanged VASC playthrough remain separate open work. Importing the gift-ordinal table alone does not implement those callers. The existing preview is not refreshed by this source change. diff --git a/docs/native-gen2-mom-shopping.md b/docs/native-gen2-mom-shopping.md new file mode 100644 index 000000000..9a3277427 --- /dev/null +++ b/docs/native-gen2-mom-shopping.md @@ -0,0 +1,87 @@ +# Native GSC Mom accounts and purchases + +The existing native banking screen changed a separate saving boolean, while +trainer rewards went entirely to the wallet. The cartridge's shopping tables +and notification scripts were not imported. This connects the banking choice, +native flags, trainer reward accounts, ROM purchase rows, original phone +programs, decoration ownership and raw-save state. + +## Cartridge data and account state + +`map_scripts.momShopping` contains the user's cartridge's random and ladder +lists. Each row is eight bytes: big-endian three-byte trigger and price, then +kind and item/DECO bytes. End symbols determine the count. The random threshold +increment is read from the native instruction operands that populate +`hMoneyTemp`; no retail catalogue, purchase dialogue or guessed hack layout is +bundled. Both original item/doll phone programs enter the shared script/text +importer. Invalid or incomplete bindings publish no partial lists and do not +partially enqueue programs. The new GSC cache marker requires reimport. + +The native account fields are `g2MomMoney`, `g2MomSavingMode` and +`g2MomBankOpen`, alongside the existing boolean `g2MomSaving`. Saving bit zero +and the active bit agree with the same engine flags original scripts read. +The bank UI uses those shared fields; an imported active account is recognized +instead of silently opening a new account. Explicit start/stop choices select +SOME/none. Imported HALF/ALL settings retain their native low-bit meanings. + +Trainer rewards deal four quarters between the accounts. SOME sends one, +HALF two and ALL four to Mom; an already maxed Mom account sends everything to +the wallet. Each account saturates independently, so near-cap overflow is not +redirected. Held-item reward modification happens before this split. Original +reward-text entry points select the current mode and receive the real player +name and full prize total. Other generations and unreviewed hacks retain their +existing reward paths, including Prism's separate bank. + +## Purchase and phone lifecycle + +`g2MomShopping` retains the native ladder index, selected random-row byte and +three-byte trigger balance. The current ladder row has priority whenever its +trigger is met. Otherwise the threshold advances to/past the balance and only +an exact landing selects a random row; that threshold is consumed even when a +full PC refuses delivery. A random purchase does not advance the ladder. + +Items resolve by the current dataset's native index and enter the existing PC +store with the native stack/capacity rule. Dolls set the actual imported DECO +ownership event; they do not place themselves in the room. Successful delivery +precedes deduction and ladder advancement. No reception or an occupied native +reentry queue prevents a purchase before selection state changes. Missing +original phone programs do not grant an unannounced purchase. + +The common native trainer-return path attempts the purchase once. Its original +phone program waits behind the trainer's queued post-battle script and UI +overlays, then enters the existing native FIFO with Mom's current contact ID. +Caller state, ring/hang-up audio, and receive-delay reset use the existing native call path. +Where the host exposes an incoming-call notification seam, it is also used. Repeated battle +callbacks cannot duplicate the purchase, and a pending call from another +save/dataset cannot execute in the current session. Link and Battle Tower +returns do not trigger shopping. + +Raw SRAM conversion uses each GSC edition's actual WRAM-derived account, +mode, ladder, row-selection and threshold offsets. The account/threshold use +big-endian triples; surrounding mode bits and absent legacy template values +are retained. Unsupported layouts receive no guessed new offsets. + +## Verification and limits + +ROM-free tests exercise actual table/instruction readers, banking VM/UI, +trainer battle win, native world return, script FIFO, flags, capacity refusal, +threshold selection and SRAM conversion. Save addresses are independently +compared with the shipped GSC manifests. Synthetic cartridges, texts and +records are authored fixtures rather than retail data. + +A private read-only Crystal probe independently compares every numeric byte +of both shopping lists, executes all ten original ladder phone notifications, +checks actual item and DECO ownership delivery, resolves native reward slots +and round-trips the account/progress fields. It uses controlled dataset and +font/render/audio IO, not a GPU acceptance or full natural-story playthrough. +Original ROM hashes are checked unchanged. No ROM/private mod/save data is +included in the contribution. + +The legacy `src.core.gen2.MomShopping` facade, full legacy phone parity, +new GPU acceptance and a complete unchanged VASC playthrough remain open. +This source change does not refresh public preview assets. + +Contract references: [native purchase routine](https://github.com/pret/pokecrystal/blob/master/engine/events/mom_phone.asm), +[native trainer reward split](https://github.com/pret/pokecrystal/blob/master/engine/battle/core.asm). +Original additions use the [Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md); +existing upstream and third-party rights remain unchanged. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 297e84c74..ecf65b90d 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -9093,20 +9093,8 @@ function BattleState:enemyMonFainted() end) return end - -- ComputeTrainerReward (0E:58D4) sets wBattleReward = the class's base - -- reward byte (TrainerClassAttributes+2) times wCurPartyLevel, the level - -- of the LAST mon the party reader loaded. That is only a QUARTER of the - -- payout though: WinTrainerBattle (0F:4F46) then runs `ld c, 4` and adds - -- wBattleReward once per iteration -- b of the four quarters through - -- .AddMoneyToMom, the remaining c through .AddMoneyToWallet -- so the - -- trainer is worth base * level * 4 in total. b comes from - -- wMomSavingMoney & 7 (0 = you keep it all, 3 = she banks all four), and - -- is forced to 0 when Mom's account is already maxed. Nothing here - -- models Mom's savings account, which is the `keep it all` branch, so all - -- four quarters land in the wallet -- Falkner pays 25 * 9 * 4 = 900 and a - -- Sprout Tower sage 8 * 3 * 4 = 96, instead of the 225 and 24 a single - -- quarter gives. Gen1's TrainerBattleVictory has no such split, so the - -- multiplier stays on the Gen2 side of the fence. + -- Native GSC rewards are four quarters. Their destination is selected + -- by Mom's saving bits; Gen2MomShopping applies the per-account cap. -- THE LEVEL THE PRIZE IS COUNTED FROM. -- -- Gen 1 and Gen 2 count from the mon that was out when the battle ended. @@ -9154,7 +9142,9 @@ function BattleState:enemyMonFainted() prize = P.prize(self.data, self.trainer, opts) if self.trainerB then prize = prize + P.prize(self.data, self.trainerB, opts) end end - self.game.save.money = self.game.save.money + prize + local momAward=require("src.world.Gen2MomShopping").reward(self.game.save,prize) + if not momAward then self.game.save.money=self.game.save.money+prize end + self.g2MomAward=momAward -- Prism's Spurge Bank ATM (event/bank.asm) offers DIRECT DEPOSIT: with it -- enabled, a quarter of what you win in battle is routed to the account -- instead of the wallet. The switch lives in the ATM special @@ -9215,9 +9205,17 @@ function BattleState:enemyMonFainted() -- Platinum's font, so a Sinnoh win read "got 円 480". This default is -- only reached when the dataset has no `_MoneyForWinningText` of its own, -- which is every Gen 3 and Gen 4 cartridge. - self:sayNext(self:romText("_MoneyForWinningText", - "%s got " .. require("src.core.GameVersion").moneySign() .. "%d\nfor winning!", - self.game.save.player.name, prize)) + local momPool=require("src.world.Gen2MomShopping").pool(self.data) + local moneyText=momAward and momPool and momPool.rewardTexts and momPool.rewardTexts[momAward.mode] + if moneyText and (self.data.text and self.data.text[moneyText])then + local text=self.data.text[moneyText] + text=text:gsub("{PLAYER}",function()return self.game.save.player.name end):gsub("{NUM}",tostring(prize)) + self:sayNext(text) + else + self:sayNext(self:romText("_MoneyForWinningText", + "%s got " .. require("src.core.GameVersion").moneySign() .. "%d\nfor winning!", + self.game.save.player.name, prize)) + end end self.result = "win" self.afterQueue = "finish" diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index c8f354e22..bc6d0ff51 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -3320,6 +3320,52 @@ end -- Six native bytes per attribute row: kind/name/action/event-word/sprite. -- Names immediately follow attributes in stock GSC; that boundary supplies -- the row count instead of a copied retail catalogue. +-- Native Mom tables have eight bytes: two big-endian money triples and +-- kind/item bytes. End symbols supply counts, never a copied retail list. +function RomExtractorGen2:gen2MomShopping(pool) + if self.version~="gold"and self.version~="silver"and self.version~="crystal"then return nil end + local result={complete=false} + local ok,why=pcall(function() + for role,name in pairs({random="MomItems_1",ladder="MomItems_2"})do + local first,last=self:symbol(name),self:symbol(name..".End") + assert(first and last and first.bank==last.bank,"missing Mom table bounds") + local count=(last.address-first.address)/8 + assert(count%1==0 and count>0 and count<=255 and first.address>=0x4000 and last.address<=0x8000,"invalid Mom table bounds") + local rows={} + for i=0,count-1 do + local b=self.rom:bytes(first.bank,first.address+i*8,8) + local trigger=b[1]*65536+b[2]*256+b[3];local cost=b[4]*65536+b[5]*256+b[6] + assert(trigger<=999999 and cost<=999999 and(b[7]==1 or b[7]==2)and b[8]>0 and b[8]<255,"invalid Mom row") + rows[#rows+1]={trigger=trigger,cost=cost,kind=b[7],item=b[8]} + end + result[role]=rows + end + local at,ram=self:symbol("CheckBalance_MomItem2.check_have_2300"),self:symbol("hMoneyTemp") + assert(at and ram,"missing Mom trigger instructions") + local b=self.rom:bytes(at.bank,at.address,11) + assert(b[1]==0x21 and b[2]+b[3]*256==ram.address and b[4]==0x36 and b[6]==0x23 and + b[7]==0x36 and b[9]==0x23 and b[10]==0x36,"unrecognized Mom trigger instructions") + result.step=b[5]*65536+b[8]*256+b[11] + assert(result.step>0 and result.step<=999999,"invalid Mom trigger amount") + end) + if not ok then return{complete=false,reason=tostring(why)}end + local found={} + for role,name in pairs({item="Mom_GetScriptPointer.ItemScript",doll="Mom_GetScriptPointer.DollScript"})do + local at=self:symbol(name) + if not at or at.address<0x4000 or not self:gen2InRom(at.bank,at.address)then return{complete=false,reason="missing Mom phone program"}end + found[role]=at + end + result.programs={} + for role,at in pairs(found)do result.programs[role]=self:gen2QueueScript(at.bank,at.address,pool)end + result.rewardTexts={} + self._gen2ScriptTexts=self._gen2ScriptTexts or{} + for mode,name in pairs({[0]="GotMoneyForWinningText",[1]="SentSomeToMomText",[2]="SentHalfToMomText",[3]="SentAllToMomText"})do + local at=self:symbol(name) + if at and self:gen2InRom(at.bank,at.address)then result.rewardTexts[mode]=self:gen2RegisterScriptText(at.bank,at.address)end + end + result.complete=true;return result +end + function RomExtractorGen2:gen2Decorations() if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end local info={complete=false,rowsRead=0,defaultsComplete=false,giftsComplete=false} @@ -5649,6 +5695,8 @@ function RomExtractorGen2:gen2MapIndex() -- PALETTE_DARK, the only value ReplaceTimeOfDayPals answers with -- .NeedsFlash (23:$4400) palette = field(paletteAt, 0) % 16, + paletteBits = field(paletteAt,0), + phoneService = math.floor(field(paletteAt,0)/16)%16==0, } byGroupNumber[group * 256 + number] = entry byLabel[label] = byLabel[label] or entry @@ -6648,6 +6696,7 @@ function RomExtractorGen2:extractMapsFromRom() -- is why a Pokemon Center does not go dark at night on hardware -- and -- the Ruins of Alph chambers carry it too. def.mapPalette = header and header.palette or def.mapPalette + if header then def.mapPaletteBits=header.paletteBits;def.phoneService=header.phoneService end -- Byte 8 is the FISHGROUP_* constant, and 0 is FISHGROUP_NONE -- NOT -- FISHGROUP_SHORE, which is 1 (constants/map_data_constants.asm). -- gen2Fishing keys its table by that same constant, so 0 finds nothing @@ -9021,6 +9070,7 @@ function RomExtractorGen2:extractMapScripts() self:beginStage("Gen2 map scripts end out.stds = self:gen2QueueStdScripts(pool) + out.momShopping=self:gen2MomShopping(pool) out.decorations,out.decorationInfo=self:gen2Decorations() out.decorationPrograms=self:gen2QueueDecorationPrograms(pool) out.marts = self:gen2Marts() diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index d0e8c48bf..55c4a44c7 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1310,7 +1310,7 @@ local function markerFor(version) -- Only these three caches need rebuilding; other markers remain stable. -- v8: field.gen2TradeAnim, GSC's own trade movie art (Gen2TradeArt). if version == "gold" or version == "silver" or version == "crystal" then - revision = "gen2-decorations-native-v1:gen2-field-encounters-v8:" + revision = "gen2-mom-shopping-native-v1:gen2-decorations-native-v1:gen2-field-encounters-v8:" end -- field.tradeArt: the cable-trade movie's tiles out of the ROM -- (Gen1TradeArt); without it the trade ran on drawn stand-ins. diff --git a/src/save_convert/Gen2Save.lua b/src/save_convert/Gen2Save.lua index 10ae779eb..d313bddef 100644 --- a/src/save_convert/Gen2Save.lua +++ b/src/save_convert/Gen2Save.lua @@ -141,6 +141,7 @@ local LAYOUT_GEN2 = { numBalls = 0xD5FC, balls = 0xD5FD, numPCItems = 0xD616, pcItems = 0xD617, pokegearFlags = 0xD67C, + momMoney=0xD576,momSaving=0xD579,momWhich=0xD961,momSet=0xD962,momTrigger=0xD963, decorations = 0xD959, -- wDecoBed; eight bytes, big doll is last eventFlags = 0xD7B7, curBox = 0xD8BC, visitedSpawns = 0xD9EE, mapGroup = 0xDA00, mapNumber = 0xDA01, yCoord = 0xDA02, xCoord = 0xDA03, @@ -269,6 +270,7 @@ local LAYOUT_CRYSTAL = { numBalls = 0xD8D7, balls = 0xD8D8, numPCItems = 0xD8F1, pcItems = 0xD8F2, pokegearFlags = 0xD957, + momMoney=0xD851,momSaving=0xD854,momWhich=0xDC17,momSet=0xDC18,momTrigger=0xDC19, decorations = 0xDC0F, -- wDecoBed, distinct from Gold/Silver eventFlags = 0xDA72, curBox = 0xDB72, visitedSpawns = 0xDCA5, mapGroup = 0xDCB5, mapNumber = 0xDCB6, yCoord = 0xDCB7, xCoord = 0xDCB8, @@ -371,6 +373,7 @@ function Gen2Save.setLayout(version) -- decoration bytes live. Explicit custom layouts may name their own run. if type(version)~="table" and version~="gold" and version~="silver" and version~="crystal" then O.decorations=nil + O.momMoney=nil;O.momSaving=nil;O.momWhich=nil;O.momSet=nil;O.momTrigger=nil end end @@ -1130,6 +1133,18 @@ function Gen2Save.decode(bytes, data) end end + if O.momMoney then + save.g2MomMoney=u24be(bytes,O.momMoney) + local mode=u8(bytes,O.momSaving) + save.g2MomSavingMode=mode%8 + save.g2MomSaving=save.g2MomSavingMode~=0 + save.g2MomBankOpen=hasBit(mode,7)or nil + save.flags.ENGINE_MOM_SAVING_MONEY=hasBit(mode,0) + save.flags.ENGINE_MOM_ACTIVE=hasBit(mode,7) + save.g2MomShopping={whichItem=u8(bytes,O.momWhich),whichSet=u8(bytes,O.momSet), + triggerBalance=u24be(bytes,O.momTrigger)} + end + -- wVisitedSpawns -> the FLY list. HasVisitedSpawn tests bit [spawn id]; -- this port spells the same set save.visited[mapId] (src/ui/TownMap.lua), -- so without this an imported save could not fly anywhere. @@ -1320,6 +1335,30 @@ function Gen2Save.encode(save, data, template) end end + if O.momMoney then + local function nativeNumber(value,limit,label) + assert(type(value)=="number"and value%1==0 and value>=0 and value<=limit,"invalid native Mom "..label) + return value + end + if save.g2MomMoney~=nil then setU24be(buf,O.momMoney,nativeNumber(save.g2MomMoney,999999,"balance"))end + local value=buf[O.momSaving+1]:byte() + if save.g2MomSavingMode~=nil or save.g2MomSaving~=nil then + local mode=save.g2MomSavingMode~=nil and nativeNumber(save.g2MomSavingMode,7,"saving mode")or(save.g2MomSaving and 1 or 0) + if save.flags and save.flags.ENGINE_MOM_SAVING_MONEY~=nil then + mode=mode-mode%2+(save.flags.ENGINE_MOM_SAVING_MONEY and 1 or 0) + end + value=value-value%8+mode + end + if save.g2MomBankOpen~=nil then value=withBit(value,7,save.g2MomBankOpen==true)end + setByte(buf,O.momSaving,value) + if type(save.g2MomShopping)=="table"then + local state=save.g2MomShopping + setByte(buf,O.momWhich,nativeNumber(state.whichItem,255,"ladder index")) + setByte(buf,O.momSet,nativeNumber(state.whichSet or 0,255,"random row")) + setU24be(buf,O.momTrigger,nativeNumber(state.triggerBalance,999999,"random threshold")) + end + end + -- wVisitedSpawns: the FLY destinations this save has reached local spawnFlags = data and data.field and data.field.spawnFlags if type(spawnFlags) == "table" then diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index 9a394bc36..d5e0d0f69 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2317,16 +2317,18 @@ end function Commands.g2_bank_of_mom(ctx) local save = ctx.save + local Mom=require("src.world.Gen2MomShopping") + Mom.prepareBank(save) -- CheckIfBankInitialized: the first visit opens the account and asks if save.g2MomBankOpen == nil then - save.g2MomBankOpen = true + Mom.openBank(save) Commands.ask(ctx, momLine(ctx, "saveMoney", "Should I save money for you?")) if ctx.lastCheck then - save.g2MomSaving = true + Mom.setSaving(save,true) return momSay(ctx, "startSavingMoney", "OK, I'll save your money.") end - save.g2MomSaving = false + Mom.setSaving(save,false) return momSay(ctx, "justDoWhatYouCan", "Just do what you can.") end @@ -2391,7 +2393,7 @@ function Commands.g2_bank_of_mom(ctx) momSay(ctx, "takenMoney", "Took ¥" .. amount .. ".") end) elseif choice == 3 then - save.g2MomSaving = not save.g2MomSaving + Mom.setSaving(save,not save.g2MomSaving) if save.g2MomSaving then momSay(ctx, "startSavingMoney", "OK, I'll save your money.") else diff --git a/src/world/Gen2MomShopping.lua b/src/world/Gen2MomShopping.lua new file mode 100644 index 000000000..ab075abdf --- /dev/null +++ b/src/world/Gen2MomShopping.lua @@ -0,0 +1,139 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Native accounts, ROM shopping rows and original phone programs share one save. +local Version=require('src.core.GameVersion') +local M={MAX_MONEY=999999} +local function integer(value,lo,hi) + return type(value)=='number'and value%1==0 and value>=lo and value<=hi and value or nil +end +local function money(value)return integer(value,0,M.MAX_MONEY)or 0 end +function M.stock() + local v=Version.get();return v=='gold'or v=='silver'or v=='crystal' +end +function M.mode(save) + local mode=integer(save.g2MomSavingMode,0,3) + local flag=save.flags and save.flags.ENGINE_MOM_SAVING_MONEY + if mode==nil then mode=flag~=nil and(flag and 1 or 0)or(save.g2MomSaving and 1 or 0) + elseif flag~=nil then mode=mode-mode%2+(flag and 1 or 0)end + return mode +end +function M.prepareBank(save) + if not M.stock()then return end + save.flags=save.flags or{} + local Flags=require('src.script.Flags') + local active=save.flags.ENGINE_MOM_ACTIVE + if active==nil and save.g2MomBankOpen==true then Flags.set(save,'ENGINE_MOM_ACTIVE') + elseif active==true then save.g2MomBankOpen=true + elseif active==false then save.g2MomBankOpen=nil end + if save.flags.ENGINE_MOM_SAVING_MONEY==nil and(save.g2MomSaving~=nil or save.g2MomSavingMode~=nil)then + if M.mode(save)%2==1 then Flags.set(save,'ENGINE_MOM_SAVING_MONEY')else Flags.clear(save,'ENGINE_MOM_SAVING_MONEY')end + end + save.g2MomSaving=M.mode(save)~=0 +end +function M.setSaving(save,on) + save.g2MomSaving=on==true + if not M.stock()then return end + save.g2MomSavingMode=on and 1 or 0 + save.flags=save.flags or{} + local Flags=require('src.script.Flags') + if on then Flags.set(save,'ENGINE_MOM_SAVING_MONEY')else Flags.clear(save,'ENGINE_MOM_SAVING_MONEY')end +end +function M.openBank(save) + save.g2MomBankOpen=true + if M.stock()then + save.flags=save.flags or{};require('src.script.Flags').set(save,'ENGINE_MOM_ACTIVE') + end +end +-- WinTrainerBattle deals four quarters; near-cap overflow is lost in the +-- selected account, not redirected to the other one. +function M.reward(save,total) + if not M.stock()or not integer(total,0,0xffffff*4)then return nil end + M.prepareBank(save) + local quarter=math.floor(total/4) + local mode=money(save.g2MomMoney)0 and type(p.ladder)=='table'and #p.ladder>0 +end +function M.caller(data) + local found + for id,row in pairs(data and data.map_scripts and data.map_scripts.phone or{})do + if integer(id,1,255)and type(row)=='table'and(row.class==nil or row.class==0)and(row.contactNumber or row.trainer)==1 then + if found then return nil end;found=id + end + end + return found +end +function M.state(data,save) + if not M.ready(data)then return nil end + if save.g2MomShopping==nil then save.g2MomShopping={whichItem=0,whichSet=0,triggerBalance=M.pool(data).step}end + local state=save.g2MomShopping + if type(state)~='table'or not integer(state.whichItem,0,255)or not integer(state.triggerBalance,0,M.MAX_MONEY)then return nil end + return state +end +function M.pick(data,save,random) + local state=M.state(data,save);if not state then return nil end + local p=M.pool(data);local saved=money(save.g2MomMoney) + state.whichSet=0 + local row=p.ladder[state.whichItem+1] + if row and saved>=row.trigger then return{row=row,set=2}end + while state.triggerBalancecap then return false end + save.pcItems=pc;pc[id]=held+1;return true +end +function M.tryBuy(data,save,opts) + opts=opts or{} + if not M.ready(data)or opts.phoneService~=true or opts.queued then return nil end + local caller=M.caller(data);if not caller then return nil end + local p=M.pool(data);local VM=require('src.script.Gen2ScriptVM') + local itemScript=p.programs and VM.compile(data,p.programs.item) + local dollScript=p.programs and VM.compile(data,p.programs.doll) + if not(itemScript and dollScript)then return nil end + local choice=M.pick(data,save,opts.random);if not choice then return nil end + if not receive(data,save,choice.row)then return nil end + save.g2MomMoney=math.max(0,money(save.g2MomMoney)-choice.row.cost) + local state=M.state(data,save) + if choice.set==2 then state.whichItem=(state.whichItem+1)%256 end + return{kind=choice.row.kind,item=choice.row.item,cost=choice.row.cost,set=choice.set, + saved=save.g2MomMoney,caller=caller,script=choice.row.kind==2 and dollScript or itemScript} +end +return M diff --git a/src/world/Gen2PhoneService.lua b/src/world/Gen2PhoneService.lua new file mode 100644 index 000000000..bd9d96c66 --- /dev/null +++ b/src/world/Gen2PhoneService.lua @@ -0,0 +1,11 @@ +local PhoneService = {} +function PhoneService.available(def) + if not def then return false end + if def.phoneService~=nil then return def.phoneService==true end + -- New imports retain the complete byte. Older authored datasets may have + -- stored it in mapPalette itself; old ROM caches retain their legacy rule + -- until reimport because their discarded bits cannot be reconstructed. + local raw=tonumber(def.mapPaletteBits) or tonumber(def.mapPalette) or 0 + return math.floor(raw/16)%16==0 +end +return PhoneService diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 54ac1c659..5c5f0b589 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3300,6 +3300,33 @@ function OverworldState:checkSpecialPhoneCall() onDone = function() Game.save.g2CurCaller = nil end }) end +-- A purchase is already committed at trainer reload. Its original phone +-- program waits for the trainer's queued dialogue and overlays to finish. +function OverworldState:checkMomShoppingCall() + local pending=self.g2MomCallPending + if not pending then return false end + if pending.save~=Game.save or pending.data~=Game.data then self.g2MomCallPending=nil;return false end + local top=Game.stack and Game.stack.top and Game.stack:top() + if top and top~=self then return false end + if self.player and(self.player.moving or self.player.targetX or self.player.stairExit or self.player.inputLocked)then return false end + if self.transitioning or self.runner:isRunning()or #(self.scriptMoves or{})>0 or self.engaging or self.emote or + self.teleportOut or self.whirlSpin or self.gen4Waterfall or self.flyAnim or self.fishing or + self.pendingScripts and self.pendingScripts[1]then return false end + self.g2MomCallPending=nil + local purchase=pending.purchase + Game.save.g2CurCaller=purchase.caller + pcall(function()require("src.core.Sound").play(Game.data,"Call")end) + self:queueScript(purchase.script,{phoneCaller=purchase.caller,onDone=function() + if Game.save~=pending.save or Game.data~=pending.data then return end + Game.save.g2CurCaller=nil + require("src.script.Gen2Commands").resetCallDelay(Game.save,math.floor(require("src.core.SaveData").playSeconds(Game.save)/60)) + pcall(function()require("src.core.Sound").play(Game.data,"Hang_Up")end) + end}) + local commands=require("src.script.Gen2Commands") + if commands.announceIncomingCall then commands.announceIncomingCall(Game.data,purchase.caller)end + return true +end + -- CheckPhoneCall (36:$4074): the random incoming call, rolled on every step -- the player finishes off a warp tile. Gen2Commands.rollIncomingCall carries -- the ROM's own gating -- the receive-call delay, the one-in-two coin flip, the @@ -3666,6 +3693,7 @@ function OverworldState:update(dt) -- not an event, and the check is two flag reads. self:applyForcedBike() self:checkSpecialPhoneCall() + self:checkMomShoppingCall() self:checkBugContestClock() -- world.tick: the per-frame seam for mods that simulate something in the -- overworld rather than draw it. @@ -14458,6 +14486,19 @@ function OverworldState:gen4FieldResumed() end function OverworldState:afterBattle(result, battle) + if result=="win" and battle and battle.kind=="trainer" and not battle.linkBattle and not battle.battleTower and + not battle.g2MomHandled and require("src.world.Gen2MomShopping").stock()then + battle.g2MomHandled=true + if not self.g2MomCallPending then + local purchase=require("src.world.Gen2MomShopping").tryBuy(Game.data,Game.save,{ + phoneService=require("src.world.Gen2PhoneService").available(self.map and self.map.def), + queued=self.pendingScripts and self.pendingScripts[1]~=nil, + }) + if purchase then self.g2MomCallPending={save=Game.save,data=Game.data,purchase=purchase}end + end + end + + self.gen4EncounterAttempts = 0 -- a battle a script did not start (a wild one, a trainer's sight) comes -- back to the field here; a scripted one already did in its onFinish diff --git a/tests/engine/gen2_mom_import.lua b/tests/engine/gen2_mom_import.lua new file mode 100644 index 000000000..036bc09e8 --- /dev/null +++ b/tests/engine/gen2_mom_import.lua @@ -0,0 +1,98 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local old=V.get() +local E=require('src.import.RomExtractorGen2');local Importer=require('src.import.RomImporter') +local markerFor +for i=1,100 do local name,value=debug.getupvalue(Importer.readyReport,i);if name=='markerFor'then markerFor=value;break end;if not name then break end end +assert(markerFor) +local function fixture(version,edit) + local raw=string.rep('\0',0x8000) + local function put(at,bytes)raw=raw:sub(1,at)..bytes..raw:sub(at+#bytes+1)end + local symbols={MomItems_1={1,0x4000},['MomItems_1.End']={1,0x4010},MomItems_2={1,0x4010},['MomItems_2.End']={1,0x4028}, + ['CheckBalance_MomItem2.check_have_2300']={1,0x4100},hMoneyTemp={0,0xff80}, + ['Mom_GetScriptPointer.ItemScript']={1,0x4200},['Mom_GetScriptPointer.DollScript']={1,0x4201}, + GotMoneyForWinningText={1,0x4300},SentSomeToMomText={1,0x4301},SentHalfToMomText={1,0x4302},SentAllToMomText={1,0x4303}} + put(0x4000,string.char(0,0,0,0,0,3,1,17,0,0,0,0,0,5,1,18, + 0,0,100,0,0,20,1,17,0,0,200,0,0,70,2,14,0,1,244,0,0,30,1,18)) + put(0x4100,string.char(0x21,0x80,0xff,0x36,0,0x23,0x36,0,0x23,0x36,17)) + put(0x4200,string.char(0x91,0x91)) + if edit then edit(symbols,put)end + local e=E.new(raw,version,{symbols=symbols});e._gen2ScriptTexts={} + return e +end +local function pool()return{scripts={},queue={},movements={},instructions=0,desyncs=0,failures=0}end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local e=fixture(version);local q=pool();local rows=e:gen2MomShopping(q) + T.eq(rows.complete,true,version..' actual Mom importer accepts bounded native tables') + T.eq(#rows.random,2,version..' random count comes from its end symbol') + T.eq(#rows.ladder,3,version..' ladder count comes from its end symbol') + T.eq(rows.ladder[3].trigger,500,version..' money trigger is a big-endian triple') + T.eq(rows.ladder[2].kind,2,version..' kind distinguishes real DECO byte from native item') + T.eq(rows.ladder[2].item,14,version..' row retains actual byte rather than a guessed species') + T.eq(rows.step,17,version..' random threshold is read from actual instructions, not a retail literal') + T.eq(#q.queue,2,version..' both original phone scripts enter native decode queue') + T.check(e._gen2ScriptTexts[rows.rewardTexts[1]]~=nil,version..' native reward prose reaches shared text importer') + e:gen2MomShopping(q);T.eq(#q.queue,2,version..' repeated import queues each script once') + for _,job in ipairs(q.queue)do e:gen2DecodeScript(job[1],job[2],job[3],q)end + T.eq(q.desyncs,0,version..' native queued scripts decode without desync') + T.check(markerFor(version):find('gen2%-mom%-shopping%-native%-v1:')~=nil,version..' new cache requires purchase data') + for _,edit in ipairs({ + function(s)s.MomItems_1=nil end, + function(s)s['MomItems_2.End']=nil end, + function(s)s['MomItems_1.End'][2]=0x4011 end, + function(s)s['MomItems_1.End'][1]=2 end, + function(s)s['MomItems_2.End'][2]=0x400f end, + function(s,put)put(0x4000,string.char(255,255,255))end, + function(s,put)put(0x4003,string.char(255,255,255))end, + function(s,put)put(0x4006,string.char(3))end, + function(s,put)put(0x4007,string.char(0))end, + function(s,put)put(0x4104,string.char(0))put(0x4107,string.char(0))put(0x410a,string.char(0))end, + function(s)s.hMoneyTemp[2]=0xff81 end, + function(s)s['CheckBalance_MomItem2.check_have_2300']=nil end, + function(s)s['Mom_GetScriptPointer.DollScript']=nil end, + function(s)s['Mom_GetScriptPointer.ItemScript'][2]=0x100 end, + })do + local bad=fixture(version,edit);local q=pool();local result=bad:gen2MomShopping(q) + T.eq(result.complete,false,version..' malformed or incomplete input cannot claim a native purchase binding') + T.eq(result.random,nil,version..' malformed binding publishes no partial retail table') + T.eq(#q.queue,0,version..' invalid binding does not partially mutate script queue') + end +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + local q=pool();T.eq(fixture(version):gen2MomShopping(q),nil,version..' unreviewed layout has no inferred retail shopping') + T.eq(#q.queue,0,version..' unrelated script queue stays unchanged') + T.check(not markerFor(version):find('gen2%-mom%-shopping%-native%-v1:'),version..' unrelated cache remains unchanged') +end +local Service=require('src.world.Gen2PhoneService');local Writer=require('src.import.LuaWriter') +local function extractor(version,palette,compact) + local raw=string.rep('\0',0x8000) + local function put(at,v)raw=raw:sub(1,at)..string.char(v)..raw:sub(at+2)end + put(0x4000,2);put(0x4001,0x40) + local header=compact and {1,2,0,0x50,1,1,palette}or{1,1,2,0,0x50,1,1,palette,0} + for i,v in ipairs(header)do put(0x4001+i,v)end + local layout=compact and {mapHeaderBytes=7,mapHeaderAttrAt=2,mapHeaderTilesetAt=0,mapHeaderEnvAt=1,mapHeaderLandmarkAt=4,mapHeaderMusicAt=5,mapHeaderPaletteAt=6,mapHeaderFishAt=-1,mapAttributesBank=2}or{} + return E.new(raw,version,{symbols={MapGroupPointers={1,0x4000},Native_MapAttributes={compact and 2 or 1,0x5000}},layout=layout}) +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for raw=0,255 do + local e=extractor(version,raw);local _,labels=e:gen2MapIndex();local header=assert(labels.Native) + T.eq(header.palette,raw%16,'native header retains the unchanged palette nibble') + T.eq(header.paletteBits,raw,'native header retains the complete real byte') + T.eq(header.phoneService,raw<16,'native header decodes the actual no-service nibble') + T.eq(Service.available({mapPalette=header.palette,mapPaletteBits=header.paletteBits,phoneService=header.phoneService}),raw<16,'runtime consumes the native imported no-service verdict') + end + for _,raw in ipairs({1,0x11,0xF4})do + local e=extractor(version,raw);local maps={NATIVE={id='NATIVE',label='Native',width=1,height=1,warps={}}};local encoded + -- Input table/file output are IO seams. Header lookup, map lowering and + -- Lua serialization are the actual importer, including false booleans. + function e:readSourceTable(name)return name=='maps'and maps or{}end + function e:write(name,value)if name=='maps'then encoded=Writer.encode(value)end end + e:extractMapsFromRom();local decoded=assert(loadstring(assert(encoded)))().NATIVE + T.eq(decoded.mapPalette,raw%16,'full map lowering preserves the palette rule') + T.eq(decoded.mapPaletteBits,raw,'full map lowering retains header bits') + T.eq(decoded.phoneService,raw<16,'serialization does not lose an explicit no-service false') + end +end +V.set(old);T.finish('native Mom table boundaries, threshold instruction operands, script queues and cache revision') diff --git a/tests/engine/gen2_mom_shopping.lua b/tests/engine/gen2_mom_shopping.lua new file mode 100644 index 000000000..a140cce25 --- /dev/null +++ b/tests/engine/gen2_mom_shopping.lua @@ -0,0 +1,201 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local Mom=require('src.world.Gen2MomShopping');local Native=require('src.world.Gen2Decorations') +local Flags=require('src.script.Gen2Flags');local Runner=require('src.script.ScriptRunner') +local W=require('src.world.OverworldController');local Stack=require('src.core.StateStack') +local Map=require('src.world.Map');local Pokemon=require('src.pokemon.Pokemon') +local Battle=require('src.battle.BattleState');local S=require('src.save_convert.Gen2Save') +local E=require('src.import.RomExtractorGen2');local J=require('src.link.Json') +local Sound=require('src.core.Sound');local oldSound=Sound.play;Sound.play=function()end -- audio device IO +local slot,oldGame +for i=1,100 do local name,value=debug.getupvalue(W.afterBattle,i);if name=='Game'then slot=i;oldGame=value;break end end +assert(slot) +local function data() + local d=T.fixtures.fresh() + d.items.ITEM_017={id='ITEM_017',index=17,name='SYNTHETIC A',pocket='ITEM'} + d.items.ITEM_018={id='ITEM_018',index=18,name='SYNTHETIC B',pocket='ITEM'} + d.map_scripts={momShopping={complete=true,step=17, + random={{trigger=0,cost=3,kind=1,item=17},{trigger=0,cost=5,kind=1,item=18}}, + ladder={{trigger=100,cost=20,kind=1,item=17},{trigger=200,cost=70,kind=2,item=14},{trigger=500,cost=30,kind=1,item=18}}, + programs={item='ITEM_PHONE',doll='DOLL_PHONE'},rewardTexts={[0]='REWARD0',[1]='REWARD1',[2]='REWARD2',[3]='REWARD3'}}, + phone={[3]={class=0,contactNumber=1,name='SYNTHETIC MOM'}}, + decorations={[14]={type=1,name='SYNTHETIC DOLL',action='SET_UP_DOLL',flag=900,sprite=18}}, + decorationInfo={complete=true},scripts={BANK={{'special',0,'BankOfMom'},{'end'}},ITEM_PHONE={{'setevent',901},{'end'}},DOLL_PHONE={{'setevent',902},{'end'}}}} + d.text={REWARD0='SYNTHETIC {PLAYER} TOTAL {NUM}',REWARD1='SYNTHETIC SOME {PLAYER} TOTAL {NUM}', + REWARD2='SYNTHETIC HALF {PLAYER} TOTAL {NUM}',REWARD3='SYNTHETIC ALL {PLAYER} TOTAL {NUM}'} + return d +end +local function save(balance) + return{flags={},money=0,g2MomMoney=balance or 0,inventory={},pcItems={},party={},player={name='QA'},pokedex={seen={},owned={}}} +end +local function buy(d,s,opts) + opts=opts or{};opts.phoneService=opts.phoneService~=false;return Mom.tryBuy(d,s,opts) +end +local function game(d,s) + local def={id='QA_MOM',generation=2,width=2,height=2,blocks={1,1,1,1},warps={},mapPalette=1,phoneService=true} + local ts={id='FLOOR',blockCells=2,collision={255,255,255,255,0,0,0,0},walkable={0},grassTiles={},waterTiles={}} + d.maps.QA_MOM=def + local w=setmetatable({map=Map.new(def,ts),player={cellX=0,cellY=0,facing='down'},scriptMoves={},pendingScripts={},npcs={}},{__index=W}) + local stack=setmetatable({states={w}},{__index=Stack}) -- world already entered in this fixture + local g={data=d,save=s,stack=stack,world=w,overworld=w,input={wasPressed=function()return false end}};w.runner=Runner.new(g,w) + debug.setupvalue(W.afterBattle,slot,g);return g,w +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for mode=0,3 do + for _,coin in ipairs({1,2})do + local s=save(100);s.money=10;s.g2MomSavingMode=mode + local quarters=({[0]=0,1,2,4})[mode];local award=Mom.reward(s,900*coin) + T.eq(s.g2MomMoney,100+225*coin*quarters,version..' native mode '..mode..' sends actual quarters to Mom') + T.eq(s.money,10+225*coin*(4-quarters),version..' wallet receives only its actual share') + T.eq(award.total,900*coin,version..' displayed total remains complete prize') + T.eq(award.mode,mode,version..' native text uses actual savings mode') + end + end + local s=save(999999);s.g2MomSaving=true + T.eq(Mom.reward(s,900).mode,0,version..' maxed savings disables skimming') + T.eq(s.money,900,version..' maxed savings sends all quarters to wallet') + s=save(999998);s.g2MomSaving=true;s.money=999998 + Mom.reward(s,900) + T.eq(s.g2MomMoney,999999,version..' near-cap Mom account saturates') + T.eq(s.money,999999,version..' wallet saturates separately') + s=save();s.g2MomSaving=true;s.flags.ENGINE_MOM_SAVING_MONEY=false + T.eq(Mom.mode(s),0,version..' explicit native flag overrides legacy saving boolean') + s.g2MomSavingMode=2;T.eq(Mom.mode(s),2,version..' HALF bit survives an unset SOME bit') + s.flags.ENGINE_MOM_SAVING_MONEY=true;T.eq(Mom.mode(s),3,version..' native setflag changes only bit zero') + Mom.setSaving(s,false);T.eq(Mom.mode(s),0,version..' bank stop clears saving mode') + Mom.openBank(s);Mom.setSaving(s,true) + T.eq(s.flags.ENGINE_MOM_ACTIVE,true,version..' bank opening reaches native active flag') + T.eq(s.flags.ENGINE_MOM_SAVING_MONEY,true,version..' bank start reaches native SOME flag') + local previous=save();previous.g2MomBankOpen=true;previous.g2MomSaving=true + Mom.prepareBank(previous) + T.eq(previous.flags.ENGINE_MOM_ACTIVE,true,version..' earlier native preview account migrates to active flag') + T.eq(previous.flags.ENGINE_MOM_SAVING_MONEY,true,version..' earlier native preview saving choice migrates to native flag') + previous.flags.ENGINE_MOM_ACTIVE=false;Mom.prepareBank(previous) + T.eq(previous.g2MomBankOpen,nil,version..' explicit native reset overrides legacy open boolean') + local legacy=data();legacy.map_scripts.phone[3].contactNumber=nil;legacy.map_scripts.phone[3].trainer=1 + T.eq(Mom.caller(legacy),3,version..' original non-trainer contact byte remains supported') + local d=data();s=save() + local bankGame,bankWorld=game(d,s) + local bank=Runner.new(bankGame,bankWorld);bank:run(assert(require('src.script.Gen2ScriptVM').compile(d,'BANK'))) + local box=bankGame.stack:top();box.done=true;box:update(0) + local yes=bankGame.stack:top();bankGame.input={wasPressed=function(_,key)return key=='a'end};yes.index=1;yes:update(0);bankGame.input={wasPressed=function()return false end};for _=1,20 do if bankGame.stack:top()==yes then yes:update(0)end end + local feedback=bankGame.stack:top();feedback.done=true;bankGame.input={wasPressed=function(_,key)return key=='a'end};feedback:update(0) + T.eq(bank:isRunning(),false,version..' actual BankOfMom UI completes first saving choice') + T.eq(s.flags.ENGINE_MOM_SAVING_MONEY,true,version..' actual BankOfMom UI writes native SOME flag') + T.eq(s.flags.ENGINE_MOM_ACTIVE,true,version..' actual BankOfMom UI opens native account') + T.eq(Mom.mode(s),1,version..' actual BankOfMom UI affects reward allocation mode') + local d=data();s=save(100) + local purchase=buy(d,s) + T.eq(purchase.kind,1,version..' first ladder purchase is native item') + T.eq(s.pcItems.ITEM_017,1,version..' native item byte resolves current dataset') + T.eq(s.g2MomMoney,80,version..' price deducted once') + T.eq(s.g2MomShopping.whichItem,1,version..' ladder advances only on delivery') + T.eq(s.g2MomShopping.whichSet,0,version..' ladder uses native zero set') + T.eq(purchase.caller,3,version..' Mom caller is current native contact identity') + s.g2MomMoney=200;purchase=buy(d,s) + T.eq(s.flags[Flags.eventFlag(900)],true,version..' doll purchase grants actual ownership flag') + T.eq(s.g2MomMoney,130,version..' doll price is deducted') + T.eq(s.g2MomShopping.whichItem,2,version..' doll advances ladder once') + T.eq(s.decorations,nil,version..' doll ownership does not place it in the room') + local g,w=game(d,s);local r=Runner.new(g,w);r:run(purchase.script) + T.eq(s.flags[Flags.eventFlag(902)],true,version..' doll notification runs native compiled role') + s=save(101);purchase=buy(d,s) + T.check(purchase~=nil,version..' ladder takes precedence over random threshold') + s=save(34);s.g2MomShopping={whichItem=255,triggerBalance=17} + local called=0;purchase=buy(d,s,{random=function(n)called=called+1;return n-1 end}) + T.eq(purchase.item,18,version..' exact multiple selects current random table row') + T.eq(called,1,version..' random purchase consumes one bounded roll') + T.eq(s.g2MomShopping.triggerBalance,51,version..' consumed exact threshold advances once') + T.eq(s.g2MomShopping.whichSet,2,version..' native random set is selected row plus one') + T.eq(s.g2MomShopping.whichItem,255,version..' random buy cannot consume ladder index') + T.eq(s.g2MomMoney,29,version..' random purchase deducts its own price') + s=save(35);s.g2MomShopping={whichItem=255,triggerBalance=17} + T.eq(buy(d,s),nil,version..' overshot multiple cannot buy') + T.eq(s.g2MomShopping.triggerBalance,51,version..' overshot threshold advances above current balance') + s=save(100);d.field.pcItemCap=1;s.pcItems.ITEM_018=99 + T.eq(buy(d,s),nil,version..' full PC rejects new stack') + T.eq(s.g2MomMoney,100,version..' refusal does not spend money') + T.eq(s.g2MomShopping.whichItem,0,version..' refusal does not advance ladder') + s.pcItems={ITEM_017=98};T.check(buy(d,s)~=nil,version..' existing stack with room accepts purchase') + T.eq(s.pcItems.ITEM_017,99,version..' item reaches last slot in actual stack') + s=save(100);s.pcItems.ITEM_017=99 + T.eq(buy(d,s),nil,version..' a full same-item stack needs another native PC stack') + s=save(200);s.g2MomShopping={whichItem=1,triggerBalance=17};s.pcItems.ITEM_018=99 + T.check(buy(d,s)~=nil,version..' doll ownership is independent of PC capacity') + s=save(100);T.eq(buy(d,s,{phoneService=false}),nil,version..' no reception suppresses purchase') + T.eq(s.g2MomShopping,nil,version..' no reception cannot consume threshold or initialize purchase state') + T.eq(buy(d,s,{queued=true}),nil,version..' occupied native map-reentry queue suppresses purchase') + T.eq(s.g2MomShopping,nil,version..' occupied queue cannot mutate purchase state') + d.map_scripts.scripts.ITEM_PHONE=nil + T.eq(buy(d,s),nil,version..' missing original phone program cannot grant a silent purchase') + T.eq(s.pcItems.ITEM_017,nil,version..' missing native program leaves inventory unchanged') + -- Actual battle win uses this account split and the cartridge-selected text. + d=data();require('src.battle.TypeChart').load(d);s=save(10) + s.party={Pokemon.new(d,'FIXMON_A',10)};s.g2MomSavingMode=1 + g,w=game(d,s) + local battle=Battle.newTrainer(g,'OPP_FIX_YOUNGSTER',1);battle.participants={} + for _,mon in ipairs(battle.enemyParty)do mon.hp=0 end + battle.enemyIndex=#battle.enemyParty;battle.enemy.mon=battle.enemyParty[battle.enemyIndex] + battle:enemyMonFainted() + T.eq(battle.result,'win',version..' real battle native win branch completes') + T.check(s.g2MomMoney>10,version..' real battle win actually increases Mom account') + T.eq(battle.g2MomAward.mode,1,version..' real battle records applied savings mode') + local found=false + for _,row in ipairs(battle.queue)do if row.text and row.text:find('SYNTHETIC SOME QA TOTAL ',1,true)then found=true end end + T.check(found,version..' real battle presents actual imported SOME reward text') + -- Actual world post-battle path commits once and waits behind native dialogue. + d=data();s=save(200);s.party={Pokemon.new(d,'FIXMON_A',10)} + s.g2MomShopping={whichItem=1,triggerBalance=17};g,w=game(d,s) + battle={kind='trainer',leveledUp={}} + w:afterBattle('win',battle) + T.eq(s.g2MomMoney,130,version..' actual trainer return purchases eligible doll') + T.check(w.g2MomCallPending~=nil,version..' actual trainer return defers original notification') + w:afterBattle('win',battle);T.eq(s.g2MomMoney,130,version..' repeated callback cannot buy twice') + w.pendingScripts={{script={{'end'}}}} + T.eq(w:checkMomShoppingCall(),false,version..' trainer after-dialogue queue keeps phone waiting') + w.pendingScripts={};w.player.moving=true + T.eq(w:checkMomShoppingCall(),false,version..' native phone does not interrupt a partial movement') + w.player.moving=false;g.stack:push({name='dialogue'}) + T.eq(w:checkMomShoppingCall(),false,version..' actual overlay owner keeps phone waiting') + g.stack:pop();T.eq(w:checkMomShoppingCall(),true,version..' idle native world queues original doll script') + T.eq(s.g2CurCaller,3,version..' deferred native call sets actual caller') + T.eq(w:checkMomShoppingCall(),false,version..' notification queues once') + local entry=w.pendingScripts[1];T.check(entry~=nil,version..' actual native FIFO receives notification') + if entry then w.runner:run(entry.script,entry.extra)end + T.eq(s.flags[Flags.eventFlag(902)],true,version..' actual queued original role executes') + T.eq(s.g2CurCaller,nil,version..' completed native call releases caller') + T.eq(s.g2CallCycles,0,version..' completed native call resets receive timer') + -- Physical SRAM offsets checked against the independent shipped symbols. + S.setLayout(version);S.setCharmap({['128']='A'}) + s.g2MomMoney=123456;s.g2MomSavingMode=2;s.g2MomSaving=true;s.g2MomBankOpen=true + s.flags.ENGINE_MOM_SAVING_MONEY=false;s.flags.ENGINE_MOM_ACTIVE=true + s.g2MomShopping={whichItem=2,whichSet=1,triggerBalance=98765} + local bytes=S.encode(s,d);local f=assert(io.open('tools/rom_manifest_'..version..'.json','rb'));local manifest=assert(J.decode(f:read('*a')));f:close() + local anchor=version=='crystal'and 0xd47b or 0xd1a1 + local function offset(key)return 0x2009+manifest.symbols[key][2]-anchor end + local function three(at)local a,b,c=bytes:byte(at+1,at+3);return a*65536+b*256+c end + T.eq(three(offset('wMomsMoney')),123456,version..' Mom account uses actual three-byte SRAM field') + T.eq(bytes:byte(offset('wMomSavingMoney')+1),130,version..' saving mode and active bit preserve native byte') + T.eq(bytes:byte(offset('wWhichMomItem')+1),2,version..' native ladder index exports at independent address') + T.eq(three(offset('wMomItemTriggerBalance')),98765,version..' native threshold exports at independent address') + local back=S.decode(bytes,d) + T.eq(back.g2MomMoney,123456,version..' native balance round-trips') + T.eq(Mom.mode(back),2,version..' HALF mode round-trips without becoming SOME') + T.eq(back.g2MomBankOpen,true,version..' bank active state round-trips') + T.eq(back.g2MomShopping.whichItem,2,version..' ladder progress round-trips') + T.eq(back.g2MomShopping.whichSet,1,version..' random row round-trips') + T.eq(back.g2MomShopping.triggerBalance,98765,version..' threshold progress round-trips') + local again=S.encode(back,d,bytes);local decoded=S.decode(again,d) + local good=true;for _,warning in ipairs(decoded.warnings)do if warning:find('checksum')then good=false end end + T.check(good,version..' Mom fields remain covered by valid native checksum') +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + V.set(version);local s=save(10);T.eq(Mom.reward(s,900),nil,version..' native GSC account split is isolated') + T.eq(Mom.tryBuy(data(),s,{phoneService=true}),nil,version..' foreign edition cannot buy retail GSC gifts') + S.setLayout(version);T.eq(S.OFFSETS.momMoney,nil,version..' unreviewed SRAM layout has no guessed Mom fields') +end +Sound.play=oldSound;debug.setupvalue(W.afterBattle,slot,oldGame);V.set(original);S.setLayout(original) +T.finish('native GSC Mom accounts, purchases, trainer return, phone queue and SRAM') From 87f69596012dafa8d5feb216b87f8025a0f42736 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:27:10 +0100 Subject: [PATCH 092/138] Retain native non-trainer phone identity for original Mom purchase calls --- src/import/RomExtractorGen2.lua | 4 +++- tests/engine/gen2_mom_import.lua | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index bc6d0ff51..7e1867848 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -8669,7 +8669,9 @@ function RomExtractorGen2:gen2PhoneContacts(pool) entry = { class = class, trainer = trainer } elseif trainer ~= 0 and names[trainer] then local name, role = names[trainer]:match("^([^\n\f\v]+)[\n\f\v]%s*(.*)$") - entry = { name = (name or names[trainer]):gsub(":$", ""), role = role } + -- Keep the non-trainer's native identity; names are localizable. + entry = { name = (name or names[trainer]):gsub(":$", ""), role = role, + class = 0, contactNumber = trainer } end -- The contact's OWN map, bytes 3 and 4. GetAvailableCallers (36:$40DE) -- compares it against wMapGroup/wMapNumber and drops the contact from the diff --git a/tests/engine/gen2_mom_import.lua b/tests/engine/gen2_mom_import.lua index 036bc09e8..5824fb69b 100644 --- a/tests/engine/gen2_mom_import.lua +++ b/tests/engine/gen2_mom_import.lua @@ -12,19 +12,25 @@ local function fixture(version,edit) local symbols={MomItems_1={1,0x4000},['MomItems_1.End']={1,0x4010},MomItems_2={1,0x4010},['MomItems_2.End']={1,0x4028}, ['CheckBalance_MomItem2.check_have_2300']={1,0x4100},hMoneyTemp={0,0xff80}, ['Mom_GetScriptPointer.ItemScript']={1,0x4200},['Mom_GetScriptPointer.DollScript']={1,0x4201}, + PhoneContacts={1,0x4400},SpecialPhoneCallList={1,0x4418},NonTrainerCallerNames={1,0x4500}, GotMoneyForWinningText={1,0x4300},SentSomeToMomText={1,0x4301},SentHalfToMomText={1,0x4302},SentAllToMomText={1,0x4303}} put(0x4000,string.char(0,0,0,0,0,3,1,17,0,0,0,0,0,5,1,18, 0,0,100,0,0,20,1,17,0,0,200,0,0,70,2,14,0,1,244,0,0,30,1,18)) put(0x4100,string.char(0x21,0x80,0xff,0x36,0,0x23,0x36,0,0x23,0x36,17)) put(0x4200,string.char(0x91,0x91)) + put(0x440c,string.char(0,1));put(0x4502,string.char(0x10,0x45));put(0x4510,string.char(128,0x50)) if edit then edit(symbols,put)end local e=E.new(raw,version,{symbols=symbols});e._gen2ScriptTexts={} + function e:readSourceTable(key)assert(key=='charmap');return{['128']='Q'}end return e end local function pool()return{scripts={},queue={},movements={},instructions=0,desyncs=0,failures=0}end for _,version in ipairs({'gold','silver','crystal'})do V.set(version);local e=fixture(version);local q=pool();local rows=e:gen2MomShopping(q) T.eq(rows.complete,true,version..' actual Mom importer accepts bounded native tables') + local phone=e:gen2PhoneContacts(nil) + T.eq(phone[1].contactNumber,1,version..' actual non-trainer contact identity survives localized name extraction') + T.eq(phone[1].class,0,version..' actual imported Mom contact retains native non-trainer class') T.eq(#rows.random,2,version..' random count comes from its end symbol') T.eq(#rows.ladder,3,version..' ladder count comes from its end symbol') T.eq(rows.ladder[3].trigger,500,version..' money trigger is a big-endian triple') From d0024cdb2ccf056bc87a740146094f8752deb72e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:51:44 +0100 Subject: [PATCH 093/138] Expose legacy decoration contracts over native GSC records and room state --- docs/native-gen2-decorations.md | 45 ++++++- src/compat/gen2/Decorations.lua | 138 ++++++++++++++++++++ src/core/Data.lua | 1 + src/core/GameVersion.lua | 5 + src/core/ModCompat.lua | 2 + src/mods/Loader.lua | 2 + src/ui/Gen2DecorationMenu.lua | 41 ++---- src/world/Gen2Decorations.lua | 124 +++++++++++++++--- tests/engine/gen2_decoration_facade.lua | 160 ++++++++++++++++++++++++ 9 files changed, 471 insertions(+), 47 deletions(-) create mode 100644 src/compat/gen2/Decorations.lua create mode 100644 tests/engine/gen2_decoration_facade.lua diff --git a/docs/native-gen2-decorations.md b/docs/native-gen2-decorations.md index e9d31d63e..702920fc3 100644 --- a/docs/native-gen2-decorations.md +++ b/docs/native-gen2-decorations.md @@ -57,6 +57,44 @@ the imported template intact; invalid placed bytes fail instead of wrapping. Unreviewed hack layouts receive neither guessed retail tables nor guessed save offsets. +## Legacy module contract + +`require("src.core.gen2.Decorations")` resolves to a signature adapter, not +directly to `src.world.Gen2Decorations`. It exposes `state(save)`, +`attributes(id)`, `name(id, monName)`, `idFor(id)`, `idForFlag(ordinal)`, +`registerInto(registry, data, owner)`, ownership/gift methods, +`ownedCategories(events)`, `rows(events, category)`, `apply(state, id, side, +monName)`, `clearOtherSide`, `visibility`, `tiles` and `posterVisible`. +The action/slot/description constants retain the legacy shapes. `ATTRIBUTES`, +`CATEGORIES` and `NOTHING_TO_CHOOSE` are read projections of the current +native dataset. Attribute rows remain the actual native records; registering +them into another registry passes copies. + +Data initialization and Loader entry bind the current native dataset. Reads +follow subsequent registry folding and rollback. Teardown or an edition +change retires the binding. Before a complete import, the facade returns no +catalogue/defaults instead of fabricating retail content. Removed records do +not fall back to a second catalogue. Both numeric bytes and numeric-string +bytes resolve and placements store numbers. + +Ownership accepts the legacy numeric `events:get/set` protocol or a native +save directly. `events(save)` supplies a numeric view backed by the same +canonical flags used by scripts and SRAM, with no parallel bitfield. That +view expires on rebinding/unload. Cancel/header rows cannot grant event zero. +Categories are rebuilt from native action identities; added rows participate +and removed headers are not invented. Existing empty placement is preserved. + +`apply` follows the legacy two-result contract `(changed, pages)` and does +not check ownership; the menu owns that check. Ornament side cancellation +does nothing, and `clearOtherSide` remains a separate operation for these +callers. The pages use the actual imported feedback text and buffers 3/4, +including authored replacements and explicit script-text removal. The native +PC and facade share placement planning and tile/object layout routines; the +PC still commits after feedback closes. A valid block zero and wrapped +carpet offsets retain native behavior rather than the old facade's filter. +This adapter does not turn a detached external event bitfield into the live +world, nor does wrapping a facade method patch every native module method. + ## Evidence and remaining scope ROM-free tests exercise actual importers, Loader, native script compiler and @@ -72,8 +110,13 @@ executes the original house-PC/description scripts and room callbacks, and round-trips all eight placed bytes. Original ROM hashes are checked unchanged. The local font/audio/render harness is used; no visual acceptance is inferred. +The public-module tests load a synthetic mod through the actual Loader, +change and remove records, grant ownership, place decorations and execute +the native room callback VM. They verify feedback pages, byte identity, +retirement on reload/edition changes, and rollback without a second catalogue. + Mom’s native shopping caller is covered separately in [native-gen2-mom-shopping.md](native-gen2-mom-shopping.md). -Full legacy `Decorations` facade parity, the remaining gift lifecycle, +The remaining gift lifecycle, legacy MomShopping/phone contracts, new graphical acceptance and a full unchanged VASC playthrough remain separate open work. Importing the gift-ordinal table alone does not implement those callers. The existing preview is not refreshed by this source change. diff --git a/src/compat/gen2/Decorations.lua b/src/compat/gen2/Decorations.lua new file mode 100644 index 000000000..952a801a6 --- /dev/null +++ b/src/compat/gen2/Decorations.lua @@ -0,0 +1,138 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Legacy argument/result contracts over the native cartridge and placed bytes. +-- No fallback catalogue, ownership bitfield or second decoration save exists. +local Native=require('src.world.Gen2Decorations') +local Version=require('src.core.GameVersion') +local Flags=require('src.script.Flags') +local Gen2Flags=require('src.script.Gen2Flags') +local Copy=require('src.mods.Merge').deepCopy +local M={SLOTS=Copy(Native.slots),OBJECT_SLOTS=Copy(Native.objectSlots),ACTIONS={}, + EVENT_PLAYERS_ROOM_POSTER=716,DECOFLAG_GOLD_TROPHY_DOLL=43,DECOFLAG_SILVER_TROPHY_DOLL=44, + DESC_SLOTS={DECODESC_POSTER={slot='poster'},DECODESC_LEFT_DOLL={slot='leftOrnament',named=true}, + DECODESC_RIGHT_DOLL={slot='rightOrnament',named=true},DECODESC_BIG_DOLL={slot='bigDoll'}, + DECODESC_CONSOLE={slot='console',named=true}}} +for n=1,14 do + local name=Native.actionName(n);local action=Native.action(name) + if not action.put then action.put=nil end + M.ACTIONS[name]=action +end +local bound,edition,epoch=nil,nil,0 +local function data() + return edition==Version.get()and Native.ready(bound)and bound or nil +end +function M.useRegistry(value) + epoch=epoch+1;bound=nil;edition=nil + if Native.ready(value)then bound=value;edition=Version.get()end + return bound~=nil +end +function M.idFor(id)return 'deco:'..tostring(id)end +function M.idForFlag(n) + n=n==nil and 0 or tonumber(n) + local d=data();local info=d and Native.pool(d).decorationInfo + if not(n and n%1==0 and n>=0 and n<=255 and info and info.giftsComplete)then return nil end + return info.gifts and info.gifts[n+1] +end +function M.attributes(id)return Native.attributes(data(),id)end +function M.state(save)return Native.state(data(),save)end +function M.name(id,monName)return Native.name(data(),id,monName)end +function M.registerInto(registry,source,owner) + local d=Native.ready(source)and source or data();local count=0 + if not d then return count end + local seen={} + for raw in pairs(Native.pool(d).decorations)do + local id=Native.byte(raw) + if id and not seen[id]then + seen[id]=true;registry:register(M.idFor(id),Copy(Native.attributes(d,id)),owner);count=count+1 + end + end + return count +end +-- Optional native adapter for callers of the numeric Events protocol. Its +-- get/set operations reach the same canonical flags as scripts and SRAM. +-- A retained adapter expires on rebind/unload; it never captures a new game. +function M.events(save) + if not data()or type(save)~='table'then return nil end + local generation=epoch + local function current(id) + return generation==epoch and data()and type(id)=='number'and id%1==0 and id>=0 and id<65535 + end + return{get=function(_,id) + return current(id)and type(save.flags)=='table'and Flags.get(save,Gen2Flags.eventFlag(id))or false + end,set=function(_,id,value) + if not current(id)then return false end + save.flags=save.flags or{} + if value then Flags.set(save,Gen2Flags.eventFlag(id))else Flags.clear(save,Gen2Flags.eventFlag(id))end + return true + end} +end +local function usable(id) + id=Native.byte(id) + local row=M.attributes(id);local action=row and Native.action(row.action) + return id and id~=0 and action and not action.put and row or nil +end +function M.owns(events,id) + local row=usable(id);if not row or type(events)~='table'then return false end + if type(events.get)=='function'then return events:get(row.flag)and true or false end + return Native.owns(data(),events,id) +end +function M.give(events,id) + local row=usable(id);if not row or type(events)~='table'then return false end + if type(events.set)=='function'then return events:set(row.flag,true)~=false end + return Native.give(data(),events,id) +end +function M.giveFlag(events,n)return M.give(events,M.idForFlag(n))end +function M.ownedCategories(events) + local out={} + for _,category in ipairs(Native.categories(data()))do + for _,id in ipairs(category.members)do + if M.owns(events,id)then out[#out+1]=category;break end + end + end + return out +end +function M.rows(events,category) + local out={} + -- Rebuild membership rather than trusting a category retained across reload. + for _,current in ipairs(Native.categories(data()))do + if category and current.id==category.id and(current.slot==category.slot or + category.slot==nil and category.id~=nil)then + for _,id in ipairs(current.members)do if M.owns(events,id)then out[#out+1]=id end end + if #out>0 then + for _,id in ipairs(current.headers)do out[#out+1]=id end + out[#out+1]=0 + end + break + end + end + return out +end +function M.apply(state,id,side,monName) + local placement=Native.placement(data(),state,id,side,monName) + if not placement then return false,{}end + state[placement.slot]=placement.value + return placement.changed,Native.feedbackPages(data(),placement) +end +function M.clearOtherSide(state,id,side) + id=Native.byte(id) + if not data()or type(state)~='table'or not id or id==0 or(side~='left'and side~='right')then return end + local other=side=='right'and 'leftOrnament'or 'rightOrnament' + if state[other]==id then state[other]=0 end +end +function M.visibility(state)return data()and Native.layoutVisibility(data(),state)or{}end +function M.tiles(state)return data()and Native.layoutTiles(data(),state)or{}end +function M.posterVisible(state)return data()~=nil and Native.posterVisible(state)end +return setmetatable(M,{__index=function(_,key) + if key=='CATEGORIES'then return Native.categories(data())end + if key=='ATTRIBUTES'then + local out={};local d=data() + for raw in pairs(d and Native.pool(d).decorations or{})do + local id=Native.byte(raw);if id then out[id]=Native.attributes(d,id)end + end + return out + end + if key=='NOTHING_TO_CHOOSE'then + local pages=Native.feedbackPages(data(),{role='noChoice',buffers={}}) + return pages[1] + end +end}) diff --git a/src/core/Data.lua b/src/core/Data.lua index ca41e93a0..6267ecb52 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -1887,6 +1887,7 @@ function Data:seedDefaults() end seedMissingGen2Tilesets(self) self:publishGen2Palettes() + require("src.compat.gen2.Decorations").useRegistry(self) ensureGen2WarpFallbacks(self) ensureGen2HomeTextFallbacks(self) end diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 330f4f3e9..7ebabc4fd 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1082,7 +1082,12 @@ GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver", "crystal", GameVersion.current = "red" function GameVersion.set(id) + local previous = GameVersion.current GameVersion.current = GameVersion.VERSIONS[id] and id or "red" + if previous ~= GameVersion.current then + local decorations = package.loaded["src.compat.gen2.Decorations"] + if decorations then decorations.useRegistry(nil) end + end return GameVersion.current end diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..de815b24f 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -67,6 +67,8 @@ ModCompat.ALIASES = { -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", + -- Explicit signature adapter: native operations take (data, save). + ["src.core.gen2.Decorations"] = "src.compat.gen2.Decorations", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 22977dbfc..f7c8474b9 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1321,6 +1321,7 @@ function Loader:load(data) -- wraps and flags the previous load put on engine tables come off first. ModSeams.unwindAll() self.baseData = data + require("src.compat.gen2.Decorations").useRegistry(data) -- every registry folds against the pristine view of its Data target; -- resolution is lazy so optional namespaces may appear later for _, registry in pairs(self.content) do @@ -1660,6 +1661,7 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + require("src.compat.gen2.Decorations").useRegistry(nil) local undone = select(1, ModSeams.unwindAll()) pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) diff --git a/src/ui/Gen2DecorationMenu.lua b/src/ui/Gen2DecorationMenu.lua index a091734cb..381b63b04 100644 --- a/src/ui/Gen2DecorationMenu.lua +++ b/src/ui/Gen2DecorationMenu.lua @@ -6,10 +6,6 @@ local Menu=require('src.ui.Menu') local Runner=require('src.script.ScriptRunner') local Strings=require('src.core.Strings') local M={} -local categories={ - {'BED','bed'},{'CARPET','carpet'},{'PLANT','plant'},{'POSTER','poster'}, - {'GAME CONSOLE','console'},{'ORNAMENT','ornament'},{'BIG DOLL','bigDoll'}, -} function M.new(game,opts) opts=opts or{} local changed,finished=false,false @@ -30,18 +26,11 @@ function M.new(game,opts) local row=Native.attributes(game.data,id);local action=row and Native.action(row.action) local state=Native.state(game.data,game.save) if not(state and action)or not action.put and not Native.owns(game.data,game.save,id)then return end - local slot=action.slot or(side=='left'and 'leftOrnament'or side=='right'and 'rightOrnament') - if not slot then return end - local old=state[slot]or 0 + local placement=Native.placement(game.data,state,id,side) + if not placement then return end game.stringBuffers=game.stringBuffers or{} - game.stringBuffers[3]=Native.name(game.data,old) - game.stringBuffers[4]=Native.name(game.data,id) - local role - if action.put then role=old==0 and 'empty'or 'put' - elseif old==id then role='same' - elseif old==0 then role='setup';game.stringBuffers[3]=Native.name(game.data,id) - else role='replace'end - say(role,function()changed=Native.choose(game.data,game.save,id,side)or changed end) + for n,name in pairs(placement.buffers)do game.stringBuffers[n]=name end + say(placement.role,function()changed=Native.choose(game.data,game.save,id,side)or changed end) end local function select(id) local row=Native.attributes(game.data,id);local action=row and Native.action(row.action) @@ -66,25 +55,21 @@ function M.new(game,opts) else sides()end end local function category(key) - local items,headers,seen={},{},{} - for raw,row in pairs(Native.pool(game.data).decorations)do - local id=tonumber(raw);local action=Native.action(row.action) - local group=action and(action.slot or 'ornament') - if id and id~=0 and group==key and not seen[id]then - seen[id]=true - if action.put then headers[#headers+1]=id - elseif Native.owns(game.data,game.save,id)then items[#items+1]=id end + local items={} + for _,c in ipairs(Native.categories(game.data))do + if c.slot==key then + for _,id in ipairs(c.members)do if Native.owns(game.data,game.save,id)then items[#items+1]=id end end + return items,c.headers end end - table.sort(items);table.sort(headers) - return items,headers + return items,{} end local rows={} if Native.ready(game.data)and Native.state(game.data,game.save)then - for _,c in ipairs(categories)do - local key=c[2];local ids=category(key) + for _,c in ipairs(Native.categories(game.data))do + local key=c.slot;local ids=category(key) if #ids>0 then - rows[#rows+1]={label=Strings(c[1]),keepOpen=true,onSelect=function() + rows[#rows+1]={label=Strings(c.label),keepOpen=true,onSelect=function() local owned,headers=category(key) if #owned==0 then say('noChoice');return end local items={} diff --git a/src/world/Gen2Decorations.lua b/src/world/Gen2Decorations.lua index d7eab2a3b..3f87b4942 100644 --- a/src/world/Gen2Decorations.lua +++ b/src/world/Gen2Decorations.lua @@ -6,6 +6,18 @@ local V=require('src.core.GameVersion') local Strings=require('src.core.Strings') local M={} M.slots={'bed','carpet','plant','poster','console','bigDoll','leftOrnament','rightOrnament'} +M.objectSlots={ + {slot='console',sprite=0,flag=1857},{slot='leftOrnament',sprite=1,flag=1858}, + {slot='rightOrnament',sprite=2,flag=1859},{slot='bigDoll',sprite=3,flag=1860}, +} +local groups={ + {'BED','bed'},{'CARPET','carpet'},{'PLANT','plant'},{'POSTER','poster'}, + {'GAME CONSOLE','console'},{'ORNAMENT','ornament'},{'BIG DOLL','bigDoll'}, +} +function M.byte(id) + local n=(type(id)=='number'or type(id)=='string')and tonumber(id) + return n and n%1==0 and n>=0 and n<=255 and n or nil +end local kinds={'BED','CARPET','PLANT','POSTER','CONSOLE','BIG_DOLL','DOLL'} local slotFor={BED='bed',CARPET='carpet',PLANT='plant',POSTER='poster',CONSOLE='console',BIG_DOLL='bigDoll'} local actions={} @@ -28,8 +40,9 @@ function M.ready(data) end function M.attributes(data,id) if not M.ready(data)then return nil end + id=M.byte(id);if not id then return nil end local rows=M.pool(data).decorations - return rows[tonumber(id)]or rows[tostring(id)] + return rows[id]or rows[tostring(id)] end function M.state(data,save) if not M.ready(data)or type(save)~='table'then return nil end @@ -44,24 +57,30 @@ local names={ [2]=Strings.source('%s BED'),[3]=Strings.source('%s CARPET'), [4]=Strings.source('%s POSTER'),[5]=Strings.source('%s DOLL'),[6]=Strings.source('BIG %s'), } -function M.name(data,id) +function M.name(data,id,monName) + id=M.byte(id) local row=M.attributes(data,id) if not row then return ''end local name=row.name if row.type>=4 then + if monName then name=monName(name)or name + else local info=M.pool(data).decorationInfo local key=info.sourceNames and info.sourceNames[id]==name and info.species and info.species[id]or name local mon=data.pokemon and data.pokemon[key] name=mon and mon.name or name + end end return names[row.type] and Strings(names[row.type],name)or name end function M.owns(data,save,id) + id=M.byte(id) local row=M.attributes(data,id);local action=row and M.action(row.action) return type(save)=='table' and type(save.flags)=='table' and id~=0 and action and not action.put and require('src.script.Flags').get(save, require('src.script.Gen2Flags').eventFlag(row.flag))or false end function M.give(data,save,id) + id=M.byte(id) local row=M.attributes(data,id) if type(save)~='table' or id==0 or not row or not M.action(row.action)or M.action(row.action).put then return false end save.flags=save.flags or{} @@ -73,6 +92,7 @@ function M.giveFlag(data,save,n) return id~=nil and M.give(data,save,id)or false end function M.choose(data,save,id,side) + id=M.byte(id) local state=M.state(data,save);local row=M.attributes(data,id) local action=row and M.action(row.action) if id==0 or not(state and action)then return false end @@ -91,17 +111,94 @@ function M.choose(data,save,id,side) end return changed end +-- Public menu metadata is derived from the current native rows, including +-- registry additions/removals. No independent retail catalogue is seeded. +function M.categories(data) + if not M.ready(data)then return{}end + local indexed={} + for raw in pairs(M.pool(data).decorations)do + local id=M.byte(raw);if id and id~=0 then indexed[id]=true end + end + local out={} + for _,group in ipairs(groups)do + local category={label=group[1],slot=group[2],members={},headers={}} + for id in pairs(indexed)do + local row=M.attributes(data,id);local action=row and M.action(row.action) + if action and(action.slot or 'ornament')==category.slot then + local list=action.put and category.headers or category.members;list[#list+1]=id + end + end + table.sort(category.members);table.sort(category.headers) + category.id=category.headers[1];out[#out+1]=category + end + return out +end +function M.placement(data,state,id,side,monName) + id=M.byte(id) + local row=M.attributes(data,id);local action=row and M.action(row.action) + if type(state)~='table'or not action or id==0 then return nil end + local slot=action.slot + if action.ornament then + if side~='left'and side~='right'then return nil end + slot=side=='left'and 'leftOrnament'or 'rightOrnament' + end + local old=M.byte(state[slot])or 0 + local value=action.put and 0 or id + local role=action.put and(old==0 and 'empty'or 'put')or + old==id and 'same'or old==0 and 'setup'or 'replace' + return{slot=slot,value=value,changed=old~=value,role=role, + buffers={[3]=M.name(data,role=='setup'and id or old,monName),[4]=M.name(data,id,monName)}} +end +function M.feedbackPages(data,placement) + if not placement then return{}end + local programs=M.pool(data)and M.pool(data).decorationPrograms + local id=programs and programs.texts and programs.texts[placement.role] + if not id then return{}end + local private=data._portableScriptText and data._portableScriptText[id] + local text=private and private.value or not private and data.text and data.text[id] + if type(text)~='string'then return{}end + text=text:gsub('{RAM:wStringBuffer([34])}',function(n)return placement.buffers[tonumber(n)]end) + text=text:gsub('{DONE}',''):gsub('{PROMPT}',''):gsub('\v','\n') + local pages={} + for page in(text..'\f'):gmatch('(.-)\f')do if page~=''then pages[#pages+1]=page end end + return pages +end +function M.layoutVisibility(data,state) + local out={} + for _,binding in ipairs(M.objectSlots)do + local id=type(state)=='table'and M.byte(state[binding.slot])or 0 + local row=id and id~=0 and M.attributes(data,id) + out[#out+1]={sprite=binding.sprite,flag=binding.flag,hidden=not row,byte=row and row.sprite or nil} + end + return out +end +function M.layoutTiles(data,state) + local out={} + local function paint(slot,x,y) + local id=type(state)=='table'and M.byte(state[slot])or 0 + local row=id and id~=0 and M.attributes(data,id) + if row then out[#out+1]={x=x,y=y,block=row.sprite};return row.sprite end + end + paint('bed',0,2);paint('plant',3,2);paint('poster',3,0) + local carpet=paint('carpet',0,0) + if carpet then + out[#out+1]={x=0,y=1,block=(carpet+1)%256} + out[#out+1]={x=1,y=1,block=(carpet+2)%256} + out[#out+1]={x=2,y=1,block=(carpet+1)%256} + end + return out +end +function M.posterVisible(state)return((state and state.poster)or 0)~=0 end -- These callbacks deliberately preserve the VM's current script/check vars. function M.visibility(ctx) local data=ctx.game.data;local state=M.state(data,ctx.save) if not state then return end ctx.save.flags=ctx.save.flags or{} local C=require('src.script.Commands');local Flags=require('src.script.Gen2Flags') - for _,binding in ipairs({{'console',0,1857},{'leftOrnament',1,1858},{'rightOrnament',2,1859},{'bigDoll',3,1860}})do - local id=state[binding[1]]or 0;local row=M.attributes(data,id) - if id~=0 and row then - C.g2_variablesprite(ctx,binding[2],row.sprite);C.clear_flag(ctx,Flags.eventFlag(binding[3])) - else C.set_flag(ctx,Flags.eventFlag(binding[3]))end + for _,row in ipairs(M.layoutVisibility(data,state))do + if not row.hidden then + C.g2_variablesprite(ctx,row.sprite,row.byte);C.clear_flag(ctx,Flags.eventFlag(row.flag)) + else C.set_flag(ctx,Flags.eventFlag(row.flag))end end end function M.tiles(ctx) @@ -109,18 +206,9 @@ function M.tiles(ctx) if not state then return end ctx.save.flags=ctx.save.flags or{} local C=require('src.script.Commands') - local function paint(slot,x,y) - local id=state[slot]or 0;local row=id~=0 and M.attributes(data,id) - if row then C.replace_block(ctx,x,y,row.sprite);return row.sprite end - end - paint('bed',0,2);paint('plant',3,2);paint('poster',3,0) - local carpet=paint('carpet',0,0) - if carpet then - C.replace_block(ctx,0,1,(carpet+1)%256);C.replace_block(ctx,1,1,(carpet+2)%256) - C.replace_block(ctx,2,1,(carpet+1)%256) - end + for _,row in ipairs(M.layoutTiles(data,state))do C.replace_block(ctx,row.x,row.y,row.block)end local flag=require('src.script.Gen2Flags').eventFlag(716) - if (state.poster or 0)~=0 then C.set_flag(ctx,flag)else C.clear_flag(ctx,flag)end + if M.posterVisible(state)then C.set_flag(ctx,flag)else C.clear_flag(ctx,flag)end end function M.description(data,save,kind) local state=M.state(data,save);local programs=M.pool(data)and M.pool(data).decorationPrograms diff --git a/tests/engine/gen2_decoration_facade.lua b/tests/engine/gen2_decoration_facade.lua new file mode 100644 index 000000000..108caedad --- /dev/null +++ b/tests/engine/gen2_decoration_facade.lua @@ -0,0 +1,160 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +require('src.core.ModCompat').install() +local D=require('src.core.gen2.Decorations') +local N=require('src.world.Gen2Decorations') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner') +local F=require('src.script.Gen2Flags') +local function fixture() + local d=T.fixtures.fresh() + d.map_scripts={decorations={ + [0]={type=1,name='BACK',flag=0,sprite=0}, + [40]={type=1,name='STORE',flag=0,action='PUT_AWAY_BED',sprite=0}, + [41]={type=2,name='ONE',flag=620,action='SET_UP_BED',sprite=0}, + [42]={type=2,name='TWO',flag=621,action='SET_UP_BED',sprite=22}, + [80]={type=1,name='STORE DOLL',flag=0,action='PUT_AWAY_DOLL',sprite=0}, + [81]={type=5,name='MODEL',flag=622,action='SET_UP_DOLL',sprite=31}, + [90]={type=1,name='RUG',flag=623,action='SET_UP_CARPET',sprite=255}, + [91]={type=1,name='SCREEN',flag=624,action='SET_UP_CONSOLE',sprite=33}, + [92]={type=4,name='MODEL',flag=625,action='SET_UP_POSTER',sprite=34}, + [93]={type=6,name='MODEL',flag=626,action='SET_UP_BIG_DOLL',sprite=35}, + },decorationInfo={complete=true,defaultsComplete=true,defaults={bed=41,poster=92}, + giftsComplete=true,gifts={42,81},species={[81]='SPECIES_001',[92]='SPECIES_001',[93]='SPECIES_001'}, + sourceNames={[81]='MODEL',[92]='MODEL',[93]='MODEL'}}, + decorationPrograms={texts={setup='SET',put='PUT',empty='EMPTY',same='SAME',replace='REPLACE',noChoice='NONE'}}, + scripts={ROOM={{'special',0,'ToggleDecorationsVisibility'},{'special',0,'ToggleMaptileDecorations'},{'end'}}}} + d.pokemon.SPECIES_001={name='LIVE'} + d.text={SET='A:{RAM:wStringBuffer3}{DONE}',PUT='B:{RAM:wStringBuffer3}{PROMPT}', + EMPTY='C',SAME='D',REPLACE='E:{RAM:wStringBuffer3}\fF:{RAM:wStringBuffer4}{DONE}',NONE='G'} + return d +end +local function load(d,code) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local files,paths={},{} + if code then + files['mods/test/manifest.json']=Json.encode({id='test',name='Synthetic',version='1.0.0',api=2,entry='main.lua',affects_link=false}) + files['mods/test/main.lua']='local mod=...;mod.exports.deco=require("src.core.gen2.Decorations");'..code + paths={'mods/test'} + end + return T.sdk.loadMods(paths,{data=d,fs=T.sdk.memfs(files)}) +end +local function done(r)Loader.teardown();r.release()end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + local d=fixture();local pristine=d.map_scripts.decorations + local r=load(d,[[ + assert(mod.exports.deco.ATTRIBUTES[41].sprite==0) + mod.content.decorations:patch('deco:42',{name='PATCHED',sprite=72}) + mod.content.decorations:register('deco:201',{type=1,name='AUTHORED',flag=900,action='SET_UP_BED',sprite=73}) + mod.content.decorations:remove('deco:41') + ]]) + T.eq(#r.errors,0,version..' real Loader imports public contract before entry executes') + T.eq(D,require('src.compat.gen2.Decorations'),version..' legacy module identity is the adapter') + T.check(D~=N,version..' incompatible signature is not a native alias') + T.eq(D.attributes(41),nil,version..' removed record never resurrects from retail fallback') + T.eq(D.ATTRIBUTES[41],nil,version..' current catalogue agrees with registry removal') + T.eq(D.attributes('42'),d.map_scripts.decorations[42],version..' string byte reads the one actual native record') + T.eq(D.name(42),'PATCHED BED',version..' merged name reaches public function') + T.eq(D.name('81'),'LIVE DOLL',version..' string identity still resolves imported species') + T.eq(D.name(81,function(base)return base=='MODEL'and 'CUSTOM'end),'CUSTOM DOLL',version..' legacy resolver is honored') + T.eq(D.name(92),'LIVE POSTER',version..' poster grammar') + T.eq(D.name(93),'BIG LIVE',version..' large doll grammar') + T.eq(D.name(999),'',version..' invalid byte has no invented name') + local save={flags={}};local state=D.state(save) + T.eq(state,save.decorations,version..' public state is the actual native placed-byte table') + T.same(state,{bed=41,poster=92},version..' defaults come from ROM despite non-retail identities') + T.same(D.state({decorations={}}),{},version..' explicitly empty native placement stays empty') + local events=D.events(save) + T.eq(D.owns(events,201),false,version..' new decoration starts unowned') + T.check(D.give(events,201),version..' numeric public event protocol awards authored record') + T.eq(save.flags[F.eventFlag(900)],true,version..' award reaches canonical native save flag') + T.eq(N.owns(d,save,201),true,version..' native ownership sees legacy award') + T.eq(D.give(events,0),false,version..' cancel cannot award flag zero') + T.eq(D.give(events,40),false,version..' put-away header cannot award flag zero') + T.eq(save.flags[F.eventFlag(0)],nil,version..' rejected award does not mutate map buffer') + T.eq(D.idForFlag(0),42,version..' gift ordinal follows native non-retail mapping') + T.check(D.giveFlag(events,1),version..' native ordinal awards doll') + T.eq(D.idForFlag(-1),nil,version..' invalid ordinal does not guess') + T.check(D.give(save,'42'),version..' direct native-save argument also grants ownership') + T.eq(N.owns(d,save,42),true,version..' direct award reaches actual native ownership') + local category=D.ownedCategories(events)[1] + T.same(category.members,{42,201},version..' categories contain actual merged IDs without removed member') + T.same(D.rows(events,category),{42,201,40,0},version..' owned members then actual header then cancel') + T.same(D.rows(events,{id=40,members={2}}),{42,201,40,0},version..' legacy plain category shape resolves native members') + T.eq(D.idFor(201),'deco:201',version..' canonical registry identity') + T.eq(D.NOTHING_TO_CHOOSE,'G',version..' feedback derives from actual ROM text ID') + local seen={};local owner={} + local count=D.registerInto({register=function(_,id,row,who)seen[id]=row;T.eq(who,owner,version..' seed keeps ownership')end},nil,owner) + T.eq(count,10,version..' seed uses current rows including additions and removals') + seen['deco:42'].name='DETACHED' + T.eq(D.name(42),'PATCHED BED',version..' seeding another registry cannot mutate native catalogue') + local changed,pages=D.apply(state,'42') + T.eq(changed,true,version..' public apply replaces placed native byte') + T.eq(state.bed,42,version..' numeric string never writes a string into SRAM state') + T.same(pages,{'E:','F:PATCHED BED'},version..' replacement uses both actual pages; removed old name is empty') + changed,pages=D.apply(state,42) + T.eq(changed,false,version..' same placement is not a change') + T.same(pages,{'D'},version..' same-placement feedback uses ROM role') + changed,pages=D.apply(state,40) + T.check(changed,version..' put-away changes filled slot') + T.same(pages,{'B:PATCHED BED'},version..' put-away names old item and removes end marker') + changed,pages=D.apply(state,40) + T.eq(changed,false,version..' empty put-away is not a change') + T.same(pages,{'C'},version..' empty feedback') + changed,pages=D.apply(state,201) + T.check(changed,version..' setup in empty slot') + T.same(pages,{'A:AUTHORED'},version..' setup uses original role with new item in buffer3') + changed,pages=D.apply(state,81,nil) + T.eq(changed,false,version..' cancelled side prompt has no placement') + T.same(pages,{},version..' cancelled side prompt has no feedback') + state.rightOrnament=81 + changed,pages=D.apply(state,81,'left') + T.check(changed,version..' chosen doll side changes') + T.eq(state.rightOrnament,81,version..' apply keeps separate legacy clearOtherSide contract') + D.clearOtherSide(state,81,'left');T.eq(state.rightOrnament,0,version..' explicit clear removes opposite duplicate') + state.console=91;state.bigDoll=93;state.carpet=90 + local paint={};local game={data=d,save=save};local world={replaceBlock=function(_,x,y,block)paint[x..','..y]=block end} + local runner=Runner.new(game,world);runner:run(assert(VM.compile(d,'ROOM'))) + for _,row in ipairs(D.tiles(state))do T.eq(paint[row.x..','..row.y],row.block,version..' actual VM tiles agree with public layout')end + T.eq(paint['0,2'],73,version..' legacy placement is visible to actual room callback') + T.eq(paint['0,1'],0,version..' wrapped carpet block remains a valid zero byte') + for _,row in ipairs(D.visibility(state))do + T.eq(save.flags[F.eventFlag(row.flag)],row.hidden,version..' actual room visibility matches public plan') + if not row.hidden then T.eq(save.gen2VarSprites[row.sprite],row.byte,version..' public sprite byte matches native callback')end + end + T.eq(D.posterVisible(state),true,version..' poster visibility agrees with native event') + T.eq(save.flags[F.eventFlag(716)],true,version..' native poster callback reads same placed state') + d.text.SET='H:{RAM:wStringBuffer3}{DONE}' + state.bed=0;local _,fresh=D.apply(state,42) + T.same(fresh,{'H:PATCHED BED'},version..' text edits after binding are live') + d._portableScriptText={SET={}};state.bed=0 + local _,masked=D.apply(state,42) + T.same(masked,{},version..' explicitly removed script text does not fall back') + done(r) + T.eq(D.attributes(42),nil,version..' teardown retires data binding') + T.eq(events:set(900,false),false,version..' retained numeric adapter cannot mutate after unload') + T.eq(save.flags[F.eventFlag(900)],true,version..' stale adapter leaves prior save intact') + local nextData=fixture();nextData.map_scripts.decorationInfo.defaults={bed=42} + local clean=load(nextData) + T.same(D.state({}),{bed=42},version..' next native dataset supplies its own defaults') + T.eq(D.attributes(201),nil,version..' old mod addition does not leak into new dataset') + T.eq(events:set(900,false),false,version..' old adapter cannot become live after rebinding') + V.set('red');V.set(version) + T.eq(D.attributes(42),nil,version..' edition switch and return cannot revive stale binding') + done(clean) + local reset=load(d) + T.eq(d.map_scripts.decorations,pristine,version..' reload preserves real registry rollback identity') + T.eq(D.attributes(41),pristine[41],version..' removed record returns only when native registry restores it') + done(reset) +end +V.set('crystal') +local unsupported={map_scripts={decorations={[2]={name='NOT IMPORTED'}}}} +T.eq(D.useRegistry(unsupported),false,'partial cache does not enable legacy native facade') +T.eq(D.state({}),nil,'missing native defaults do not invent retail bedroom') +T.same(D.CATEGORIES,{},'unbound catalogue has no copied retail categories') +V.set(original) +T.finish('native Gen2 decoration facade') From c5a997a8acc1c83a8cee29a984a6f35622e4924d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:13:28 +0100 Subject: [PATCH 094/138] compat(M5): add per-mod decoded script callback profiles --- docs/decoded-script-callbacks.md | 81 ++++++ src/mods/DecodedCallbacks.lua | 312 +++++++++++++++++++++++ src/mods/Loader.lua | 13 +- src/script/Commands.lua | 3 +- src/script/DecodedScript.lua | 142 +++++++++++ src/script/Gen2ScriptVM.lua | 31 ++- src/script/Gen3ScriptVM.lua | 33 ++- src/script/ScriptRunner.lua | 61 ++++- tests/engine/decoded_script_profile.lua | 315 ++++++++++++++++++++++++ tests/fixtures/upstream_mods/worker.lua | 30 ++- tools/check_decoded_profiles.py | 59 +++++ tools/decoded-profile-check/conf.lua | 7 + tools/decoded-profile-check/main.lua | 100 ++++++++ 13 files changed, 1168 insertions(+), 19 deletions(-) create mode 100644 docs/decoded-script-callbacks.md create mode 100644 src/mods/DecodedCallbacks.lua create mode 100644 src/script/DecodedScript.lua create mode 100644 tests/engine/decoded_script_profile.lua create mode 100644 tools/check_decoded_profiles.py create mode 100644 tools/decoded-profile-check/conf.lua create mode 100644 tools/decoded-profile-check/main.lua diff --git a/docs/decoded-script-callbacks.md b/docs/decoded-script-callbacks.md new file mode 100644 index 000000000..a0a026ff7 --- /dev/null +++ b/docs/decoded-script-callbacks.md @@ -0,0 +1,81 @@ +# Per-mod decoded script callbacks (M5) + +The host can select a callback profile without editing a mod's manifest or Lua +files. Native callbacks remain the default. For example, the normal options +record can include: + +```lua +modCallbackProfiles = { + example_mew_starter = "decoded", +} +``` + +`Loader.new({ callbackProfiles = { example_mew_starter = "decoded" } })` is +an alternative for an embedding host. Select `"native"` or remove the entry to +roll back. The profile applies to Gen 2/3 and requires the mod's native callback +format. Unified callback records remain a separate existing choice. + +The selected mod registers `script.command` on a private, loader-owned bus. +Native subscribers retain their existing bus, names, operand arrays, ordering +and context. The original compiled script table remains identical until a mod +actually changes a decoded operation. Compiler provenance and tracing stay in +private side tables; no scratch records are placed in public script rows. + +The profile presents `(next, ctx, op, row)` for Gen 3 and +`(next, ctx, op, args, cmd)` for Gen 2. Numeric operand slots and the supported +named aliases describe the source operation, before native lowering. A single +source operation may have zero, one or several native rows. `next` runs that +whole native group; code after `next` observes its native effects. Rewriting an +opcode or operand recompiles a private overlay using the same native compiler. +Calls keep their active return addresses. Each loop visit offers the original +source operation again, so a previous rewrite or veto cannot rewrite the +imported source. Vetoes also remove compile-time text state. A retained `next` +cannot replay a completed operation. + +Gen-2 numeric `b/w/m` layouts in the stock editions support lossless raw byte +edits. Normalized pointers, rich menu/queue records and hack-specific operand +layouts do not have a lossless raw byte reconstruction here. Use supported +decoded fields; unsupported byte edits fail explicitly. This does not add +missing native opcode implementations or a separate foreign VM. Manually +replaced compiled row lists cannot be paired with stale source provenance and +are explicitly declined by the decoded profile. Hand-authored ordinary runner +lists keep their native callbacks. + +`pokemon.before_give` uses its existing event call site once. Only the selected +listener receives a separate view with symbolic species, `speciesId` when the +native species order provides it, and its profile context. Species changes +(including numeric IDs), bounded level changes and nicknames commit to the +native gift. A variable-backed Gen-3 gift also retains the edited species in +that native variable. Existing native listeners receive their original record. +Priorities, unsubscribe, once and failed-entry cleanup use the existing buses. + +The current context translation has one explicit map alias: the verified +FireRed/LeafGreen native Oak's Lab definition (group 4, number 3) appears as +`OAKS_LAB`. The definition must belong to that live map registry; conflicting +slot definitions decline translation. Other maps retain their existing IDs. +Shared map objects, storage keys and saves are never renamed. Broader map-name +compatibility is a separate existing integration. + +## Qualification + +The authenticated, unchanged starter package at pinned reference `535a5ec` runs +through the production Loader, compiler, runner, gift event and Pokémon +constructor over owned synthetic data. Its Gen-3 hook changes variable `0x4002` +to 151 and creates exactly one level-20 Mew named `HOGHEAD`; native map identity +remains `MAP_G04_N03`. This is a runtime contract check, not cartridge gameplay +acceptance. The fixture authentication manifest and mod package bytes are +unchanged. + +The focused suite also covers native command order and script identity, +post-next effects, multi-row calls, opcode replacements, return remapping, +zero-row commands, vetoed text state, repeated visits, Gen-2 raw/named operands, +retained callbacks, once/rollback cleanup and native gift record identity. + +`tools/check_decoded_profiles.py` runs an owned LÖVE window, the actual Loader +profile and the real ScriptRunner. It compares a pre-change runner, the current +native path and the selected decoded path over 64 writes plus an end per frame, +with 120 warm-up and 120 measured frames per phase. F11 reports +`script.exec (64 writes + end)` and each hook's own time. This deliberately +stresses script dispatch every frame; it does not measure a world/battle scene, +GPU improvements or handheld performance. The enabled translation has a cost; +no general FPS improvement is claimed. See the PR for fingerprinted results. diff --git a/src/mods/DecodedCallbacks.lua b/src/mods/DecodedCallbacks.lua new file mode 100644 index 000000000..c2b56f8bd --- /dev/null +++ b/src/mods/DecodedCallbacks.lua @@ -0,0 +1,312 @@ +-- Original contribution by Roxas2712. See LICENSE-ROXAS2712.md. +-- Selected by the host per mod; imported mod bytes and native buses stay intact. +local M = {} +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local fields = { + setvar = { "var", "value" }, + addvar = { "var", "value" }, + subvar = { "var", "value" }, + copyvar = { "var", "src" }, + setorcopyvar = { "var", "value" }, + compare_var_to_value = { "var", "value" }, + loadword = { "dest", "value" }, + setflag = { "flag" }, + clearflag = { "flag" }, + checkflag = { "flag" }, + callstd = { "id" }, + jumpstd = { "id" }, + gotostd = { "std" }, + ["goto"] = { "target" }, + call = { "target" }, + goto_if = { "cond", "target" }, + call_if = { "cond", "target" }, + setval = { "value" }, + addval = { "value" }, + writebyte = { "value" }, + setevent = { "event" }, + clearevent = { "event" }, + checkevent = { "event" }, + scall = { "script" }, + farscall = { "script" }, + sjump = { "script" }, + farsjump = { "script" }, + iftrue = { "script" }, + iffalse = { "script" }, + ifequal = { "value", "script" }, + ifnotequal = { "value", "script" }, + ifgreater = { "value", "script" }, + ifless = { "value", "script" }, + writetext = { "text" }, + jumptext = { "text" }, + farwritetext = { "text" }, + givepoke = { "species", "level", "item", "trainer", "name", "otName" }, + giveegg = { "species", "level" }, + givemon = { "species", "level", "item" }, + sdefer = { "script" }, + stopandsjump = { "script" }, + jumptextfaceplayer = { "text" }, + farjumptext = { "text" }, + pause = { "frames" }, + setscene = { "scene" }, + setmapscene = { "group", "map", "scene" }, + turnobject = { "object", "facing" }, + applymovement = { "object", "movement" }, + applymovementlasttalked = { "movement" }, + special = { "id" }, + playmusic = { "id" }, + playsound = { "id" }, + cry = { "id" }, + getmonname = { "species", "buffer" }, + getitemname = { "item", "buffer" }, + gettrainername = { "group", "trainer", "buffer" }, + loadtrainer = { "class", "member" }, + loadwildmon = { "species", "level" }, + winlosstext = { "winText", "lossText" }, + trainertext = { "index" }, + trainerflagaction = { "action" }, + setlasttalked = { "object" }, + showemote = { "emote", "object", "frames" }, + giveitem = { "item", "quantity" }, + verbosegiveitem = { "item", "quantity" }, + addcellnum = { "phone" }, + delcellnum = { "phone" }, + checkcellnum = { "phone" }, + readvar = { "var" }, + writevar = { "var" }, + follow = { "a", "b" }, + faceobject = { "a", "b" }, + follownotexact = { "a", "b" }, + trade = { "trade" }, + describedecoration = { "decoration" }, +} +local function copy(t) + local out = {} + for k, v in pairs(t) do + out[k] = v + end + return out +end +-- Only lossless numeric byte layouts can be reconstructed from native IR. +-- Decoded pointers/text use named fields; normalized hack layouts are not bytes. +local function rawOperands(op, ir) + local version = require("src.core.GameVersion").get() + if version ~= "gold" and version ~= "silver" and version ~= "crystal" then + return {}, nil + end + local spec + for _, command in ipairs(require("src.import.Gen2ScriptOps").commandsFor(version)) do + if command[1] == op then + spec = command[2] + break + end + end + if not spec or spec:find("[^bwm]") or #spec ~= #ir - 1 then + return {}, nil + end + local out = {} + for i = 1, #spec do + local value, width = ir[i + 1], ({ b = 1, w = 2, m = 3 })[spec:sub(i, i)] + if type(value) ~= "number" or value % 1 ~= 0 or value < 0 or value >= 256 ^ width then + return {}, nil + end + for _ = 1, width do + out[#out + 1] = value % 256 + value = math.floor(value / 256) + end + end + return out, spec +end +local function context(ctx) + local world, map = ctx.overworld, ctx.overworld and ctx.overworld.map + if not map then + return ctx + end + local data = ctx.game and ctx.game.data + local maps = data and data.maps + local version = require("src.core.GameVersion").get() + local id = map.id + if maps and (version == "firered" or version == "leafgreen") then + local def = maps[map.id] + local verified = def and map.def == def and def.group == 4 and def.number == 3 + if verified then + for _, other in pairs(maps) do + if type(other) == "table" and other ~= def and other.group == 4 and other.number == 3 then + verified = false + break + end + end + end + -- One explicit callback alias, qualified against this world's native + -- definition. Similar spellings or an unbound slot are not evidence. + if verified then + id = "OAKS_LAB" + end + end + local publicMap = setmetatable({ id = id }, { __index = map }) + local publicWorld = setmetatable({ map = publicMap }, { + __index = world, + __newindex = function(_, k, v) + world[k] = v + end, + }) + return setmetatable({ overworld = publicWorld }, { + __index = ctx, + __newindex = function(_, k, v) + ctx[k] = v + end, + }) +end +function M.install(loader, api, owner, generation) + if generation ~= 2 and generation ~= 3 then + return + end + local aliases = copy(fields) + if generation == 3 then + aliases.callstd = { "std" } + aliases.special = { "id" } + aliases.applymovement = { "localId", "movement" } + end + local wrap, on, once = api.hooks.wrap, api.events.on, api.events.once + local ids = require("src.mods.UnifiedIds").new(loader) + ids:entries("pokemon") + api.hooks.wrap = function(self, name, callback, priority) + if name ~= "script.command" then + return wrap(self, name, callback, priority) + end + return loader._decodedHooks:wrap(name, function(nextFn, ctx, op, ir) + local row = { op = op } + for i = 2, #ir do + row[i - 1] = ir[i] + end + for i, field in ipairs(aliases[op] or {}) do + row[field] = row[i] + end + local original = copy(row) + local public = context(ctx) + local args, byteSpec = {}, nil + if generation == 2 then + args, byteSpec = rawOperands(op, ir) + row.args = args + end + local originalArgs = copy(args) + local active = true + local function nextPublic(hctx, hname, hrow, hcmd) + assert(active, "decoded next is only valid during its callback") + assert(hctx == nil or hctx == public, "decoded next must retain its script context") + local edited = generation == 2 and (hcmd or row) or (hrow or row) + local raw = copy(ir) + for i = 1, math.max(#row, #edited) do + raw[i + 1] = edited[i] + end + for i, field in ipairs(aliases[op] or {}) do + if edited[field] ~= original[field] then + raw[i + 1] = edited[field] + end + end + if generation == 2 then + local bytes = hrow or edited.args or args + local changed = #bytes ~= #originalArgs + for k, v in pairs(bytes) do + if originalArgs[k] ~= v then + changed = true + end + end + if changed then + assert( + byteSpec, + "raw Gen-2 byte rewrite is unavailable for this normalized command; use decoded fields" + ) + assert(#bytes == #originalArgs, "Gen-2 operand byte width changed") + local at = 1 + for i = 1, #byteSpec do + local value, width = 0, ({ b = 1, w = 2, m = 3 })[byteSpec:sub(i, i)] + for n = 0, width - 1 do + local byte = bytes[at] + at = at + 1 + assert( + type(byte) == "number" and byte % 1 == 0 and byte >= 0 and byte < 256, + "invalid Gen-2 operand byte" + ) + value = value + byte * 256 ^ n + end + raw[i + 1] = value + end + end + end + raw[1] = hname or edited.op or op + return nextFn(ctx, raw[1], raw) + end + local result + if generation == 2 then + result = pack(pcall(callback, nextPublic, public, op, args, row)) + else + result = pack(pcall(callback, nextPublic, public, op, row)) + end + active = false + if not result[1] then + error(result[2], 0) + end + return unpack(result, 2, result.n) + end, priority, owner) + end + local function subscribe(register, self, name, callback, priority) + if name ~= "pokemon.before_give" then + return register(self, name, callback, priority) + end + return register(self, name, function(gift) + local view = copy(gift) + view.ctx = context(gift.ctx) + view.species = ids:read("pokemon", gift.species) + local order = gift.ctx.game and gift.ctx.game.data.constants and gift.ctx.game.data.constants.speciesOrder + or {} + local function numberFor(species) + for number, id in pairs(order) do + if id == species then + return number + end + end + end + view.speciesId = numberFor(gift.species) + local before = view.species + callback(view) + local species = gift.species + if view.species ~= before then + if type(view.species) == "number" then + species = assert(order[view.species], "unknown decoded gift species number") + else + species = assert(ids:resolve("pokemon", view.species)).key + end + end + local level = tonumber(view.level) + level = level and math.max(1, math.min(100, math.floor(level))) or gift.level + local nickname = type(view.nickname) == "string" and view.nickname ~= "" and view.nickname or gift.nickname + -- A variable-backed Gen-3 gift keeps the requested species in that same + -- native variable for the rest of the starter/story script. + local ctx, number = gift.ctx, numberFor(species) + local command = ctx.script and ctx.script[ctx.pc] + if + generation == 3 + and species ~= gift.species + and number + and command + and command[1] == "g3_give_pokemon" + and type(command[2]) == "number" + and command[2] >= 0x4000 + then + ctx.save.gen3Vars = ctx.save.gen3Vars or {} + ctx.save.gen3Vars[command[2]] = number + end + gift.species, gift.level, gift.nickname = species, level, nickname + end, priority) + end + api.events.on = function(self, ...) + return subscribe(on, self, ...) + end + api.events.once = function(self, ...) + return subscribe(once, self, ...) + end +end +return M diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index ba46218d0..87cbe1c41 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -156,7 +156,8 @@ function Loader.new(opts) end local self = setmetatable({ mods = {}, loaded = {}, errors = {}, initialized = false, - events = Events.new(), hooks = Hooks.new(), content = {}, assets = {}, + events = Events.new(), hooks = Hooks.new(), _decodedHooks = Hooks.new(), content = {}, assets = {}, + _callbackProfiles = opts and opts.callbackProfiles, exports = {}, apis = {}, migrations = {}, order = {}, modSave = {}, modOptions = {}, optionSchemas = {}, optionStatus = {}, imageCache = {}, @@ -200,6 +201,7 @@ function Loader:_loadState() end -- mod.options reads through this; M11 owns writing it back self.modOptions = options.modOptions or {} + self._callbackProfiles = self._callbackProfiles or options.modCallbackProfiles or {} -- Migrate the original prototype manager's separate state file into the -- normal persistent options file once. New Game never resets options. if next(options.mods or {}) == nil and self.fs.getInfo @@ -1010,6 +1012,13 @@ function Loader:_api(mod) }, } self.exports[modId] = api.exports + local callbackProfile = self._callbackProfiles and self._callbackProfiles[modId] + assert(callbackProfile == nil or callbackProfile == "native" or callbackProfile == "decoded", + "unknown callback profile for " .. modId) + if callbackProfile == "decoded" then + assert(api.callbackFormat == "native", "decoded profile cannot be combined with unified callbacks") + engineRequire("src.mods.DecodedCallbacks").install(loader, api, modId, hostGen) + end if api.callbackFormat == "unified" then engineRequire("src.mods.UnifiedCallbacks").install(loader, api, modId, hostGen) end @@ -1268,6 +1277,7 @@ function Loader:_rollback(modId) end self.events:removeOwner(modId) self.hooks:removeOwner(modId) + self._decodedHooks:removeOwner(modId) self.exports[modId] = nil self.optionSchemas[modId] = nil self.optionStatus[modId] = nil @@ -1518,6 +1528,7 @@ function Loader:load(data) end) -- engine call sites reach these buses -- and this error feed, for failures -- that only surface at play time -- through Runtime from here on + self.hooks._decodedHooks = self._decodedHooks Runtime.install(self.events, self.hooks, self.errors) self:_validate() local ordered = self:_resolve() diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 53cef91fa..6fc66e4ef 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -1285,7 +1285,8 @@ function Commands.give_pokemon(ctx, species, level, skipNickname, opts) -- 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. local gift = { ctx = ctx, species = species, level = level } - if ctx.game.mods then + local giftEvents = ctx.game.mods and ctx.game.mods.events + if giftEvents and (not giftEvents.listeners or giftEvents.listeners["pokemon.before_give"]) then ctx.game.mods.events:emit("pokemon.before_give", gift) species, level = gift.species, gift.level end diff --git a/src/script/DecodedScript.lua b/src/script/DecodedScript.lua new file mode 100644 index 000000000..b9ef825e8 --- /dev/null +++ b/src/script/DecodedScript.lua @@ -0,0 +1,142 @@ +-- Original contribution by Roxas2712. See LICENSE-ROXAS2712.md. +-- Compiler provenance lives outside public command rows and engine contexts. +local M = {} +local origins = setmetatable({}, { __mode = "k" }) + +function M.origin(rows, data, entry, compile, generation) + origins[rows] = { data = data, entry = entry, compile = compile, generation = generation } +end +function M.trace() + return { commands = {}, byKey = {}, owners = {}, labels = {}, returns = {}, returnKeys = {} } +end +function M.begin(t, label, index, ir) + local c = { key = label .. ":" .. index, label = label, index = index, ir = ir, returns = 0 } + t.commands[#t.commands + 1], t.byKey[c.key], t.current = c, c, c +end +function M.emit(t, row) + t.owners[row] = t.current +end +function M.returnLabel(t, label) + local c = t.current + if not c then + return + end + c.returns = c.returns + 1 + local key = c.key .. ":return:" .. c.returns + t.returns[label], t.returnKeys[key] = key, label +end +function M.finish(rows, t) + local positions = {} + for pc, row in ipairs(rows) do + positions[row] = pc + local c = t.owners[row] + if c and not c.pc then + c.pc = pc + end + end + t.starts = {} + local nextPC + for i = #t.commands, 1, -1 do + local c = t.commands[i] + local finish = assert(positions[t.labels[c.label]], "missing decoded script boundary") + if not nextPC or c.label ~= t.commands[i + 1].label then + nextPC = finish + end + c.after = nextPC + c.pc = c.pc or nextPC + nextPC = c.pc + end + for _, c in ipairs(t.commands) do + t.starts[c.pc] = t.starts[c.pc] or {} + t.starts[c.pc][#t.starts[c.pc] + 1] = c + end +end +function M.prepare(rows) + local origin = origins[rows] + if not origin then + return + end + local trace = M.trace() + local result = origin.compile(origin.data, origin.entry, { trace = trace }) + assert(M.same(result, rows), "decoded profile requires an unmodified native compiled script") + return rows, trace, origin +end +-- Recompile a private overlay; never change imported IR or the ordinary cache. +function M.rewrite(origin, oldTrace, overrides, command, ir, ctx) + overrides[command.label] = overrides[command.label] or {} + overrides[command.label][command.index] = ir + local scripts = {} + for label, rows in pairs(origin.data.map_scripts.scripts) do + if overrides[label] then + local copy = {} + for i, row in ipairs(rows) do + copy[i] = overrides[label][i] or row + end + scripts[label] = copy + else + scripts[label] = rows + end + end + local data, pool = {}, {} + for k, v in pairs(origin.data) do + data[k] = v + end + for k, v in pairs(origin.data.map_scripts) do + pool[k] = v + end + pool.scripts, data.map_scripts = scripts, pool + local trace = M.trace() + local rows = origin.compile(data, origin.entry, { trace = trace, keepLabels = oldTrace.labels }) + for _, field in ipairs({ "g2Stack", "g3Stack" }) do + for i, label in ipairs(ctx[field] or {}) do + local key = oldTrace.returns[label] + if key then + ctx[field][i] = assert(trace.returnKeys[key], "rewritten command removed an active return") + end + end + end + return rows, trace, assert(trace.byKey[command.key]) +end +function M.copy(value, seen) + if type(value) ~= "table" then + return value + end + seen = seen or {} + if seen[value] then + return seen[value] + end + local out = {} + seen[value] = out + for k, v in pairs(value) do + out[k] = M.copy(v, seen) + end + return out +end +function M.same(a, b, seen) + if a == b then + return true + end + if type(a) ~= "table" or type(b) ~= "table" then + return false + end + seen = seen or {} + if seen[a] then + return seen[a] == b + end + seen[a] = b + for k, v in pairs(a) do + if not M.same(v, b[k], seen) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end +function M.source(origin, command) + return origin.data.map_scripts.scripts[command.label][command.index] +end +return M diff --git a/src/script/Gen2ScriptVM.lua b/src/script/Gen2ScriptVM.lua index 0d5c51cfe..5c514f6f5 100644 --- a/src/script/Gen2ScriptVM.lua +++ b/src/script/Gen2ScriptVM.lua @@ -23,6 +23,7 @@ -- behind the hand-ported data/scripts/ modules, which still win outright. local Logger = require("src.core.Logger") +local Decoded = require("src.script.DecodedScript") local MapScripts = require("src.script.MapScripts") local Gen2Flags = require("src.script.Gen2Flags") require("src.script.Gen2Commands") @@ -75,7 +76,10 @@ local function itemId(n) return string.format("ITEM_%03d", n) end local L = {} -local function emit(s, row) s.out[#s.out + 1] = row end +local function emit(s, row) + s.out[#s.out + 1] = row + if s._decoded then Decoded.emit(s._decoded, row) end +end -- A pointer that fell outside the ROM, or a far script that failed to -- disassemble, is stored as "" by the extractor. Branches to one lower to @@ -2365,7 +2369,7 @@ function Gen2ScriptVM.lowered(op) return L[op] ~= nil end -function Gen2ScriptVM.compile(data, entry) +function Gen2ScriptVM.compile(data, entry, options) local pool = store(data) local scripts = pool and pool.scripts if not (scripts and type(entry) == "string" and scripts[entry]) then return nil end @@ -2373,12 +2377,13 @@ function Gen2ScriptVM.compile(data, entry) local key = entry compiled[scripts] = compiled[scripts] or {} local hit = compiled[scripts][key] - if hit ~= nil then return hit or nil end + if not options and hit ~= nil then return hit or nil end local out, queued, order = {}, { [entry] = true }, { entry } local counter = 0 local state = { out = out, + _decoded = options and options.trace, stds = pool.stds, has = function(label) return type(label) == "string" and scripts[label] ~= nil @@ -2391,14 +2396,20 @@ function Gen2ScriptVM.compile(data, entry) end, newLabel = function() counter = counter + 1 - return string.format("%s_r%d", entry, counter) + local label = string.format("%s_r%d", entry, counter) + if options then Decoded.returnLabel(options.trace, label) end + return label end, } + if options and options.keepLabels then + for label in pairs(options.keepLabels) do state.want(label) end + end local index = 1 while index <= #order do local label = order[index] index = index + 1 + if options then options.trace.current = nil end emit(state, { "label", label }) state.lastText = nil state.lastTextRow = nil @@ -2423,6 +2434,7 @@ function Gen2ScriptVM.compile(data, entry) local sifStack, guardLabel = {}, nil local rows = scripts[label] or {} for irIndex, ir in ipairs(rows) do + if options then Decoded.begin(options.trace, label, irIndex, ir) end local op = ir[1] if SIF_OPS[op] then local after = state.newLabel() @@ -2488,7 +2500,9 @@ function Gen2ScriptVM.compile(data, entry) for i = #sifStack, 1, -1 do emit(state, { "label", sifStack[i] }) end if guardLabel then emit(state, { "label", guardLabel }) end -- a script that ran off the end of its own bytecode still has to unwind - emit(state, { "g2_return" }) + local finish = { "g2_return" } + if options then options.trace.current = nil; options.trace.labels[label] = finish end + emit(state, finish) end -- A script that lowers to nothing is why an NPC can be walked up to, @@ -2498,7 +2512,12 @@ function Gen2ScriptVM.compile(data, entry) if #out == 0 then Logger.warn("gen2 script vm: '%s' compiled to zero rows (silent NPC)", key) end - compiled[scripts][key] = #out > 0 and out or false + if options then + Decoded.finish(out, options.trace) + else + Decoded.origin(out, data, entry, Gen2ScriptVM.compile, 2) + compiled[scripts][key] = #out > 0 and out or false + end return #out > 0 and out or nil end diff --git a/src/script/Gen3ScriptVM.lua b/src/script/Gen3ScriptVM.lua index d5c709496..efc956bd9 100644 --- a/src/script/Gen3ScriptVM.lua +++ b/src/script/Gen3ScriptVM.lua @@ -36,6 +36,7 @@ -- mis-frame most of the dialogue in Hoenn. local Logger = require("src.core.Logger") +local Decoded = require("src.script.DecodedScript") local MapScripts = require("src.script.MapScripts") require("src.script.Gen3Commands") @@ -80,7 +81,10 @@ Gen3ScriptVM.flagName = flagName local L = {} -local function emit(s, row) s.out[#s.out + 1] = row end +local function emit(s, row) + s.out[#s.out + 1] = row + if s._decoded then Decoded.emit(s._decoded, row) end +end local droppedBranch = {} @@ -803,19 +807,20 @@ end Gen3ScriptVM.store = store -function Gen3ScriptVM.compile(data, entry) +function Gen3ScriptVM.compile(data, entry, options) local pool = store(data) local scripts = pool and pool.scripts if not (scripts and type(entry) == "string" and scripts[entry]) then return nil end compiled[scripts] = compiled[scripts] or {} local hit = compiled[scripts][entry] - if hit ~= nil then return hit or nil end + if not options and hit ~= nil then return hit or nil end local out, queued, order = {}, { [entry] = true }, { entry } local counter = 0 local state = { out = out, + _decoded = options and options.trace, has = function(label) return type(label) == "string" and scripts[label] ~= nil end, @@ -827,30 +832,44 @@ function Gen3ScriptVM.compile(data, entry) end, newLabel = function() counter = counter + 1 - return ("%s_r%d"):format(entry, counter) + local label = ("%s_r%d"):format(entry, counter) + if options then Decoded.returnLabel(options.trace, label) end + return label end, } + if options and options.keepLabels then + for label in pairs(options.keepLabels) do state.want(label) end + end local index = 1 while index <= #order do local label = order[index] index = index + 1 + if options then options.trace.current = nil end emit(state, { "label", label }) -- lastText does NOT survive across script boundaries: a callstd at the -- top of one script must not print the line the previous one loaded state.lastText = nil - for _, ir in ipairs(scripts[label] or {}) do + for irIndex, ir in ipairs(scripts[label] or {}) do + if options then Decoded.begin(options.trace, label, irIndex, ir) end local lower = L[ir[1]] if lower then lower(ir, state) end end -- a script that ran off the end of its own bytecode still has to unwind - emit(state, { "g3_return" }) + local finish = { "g3_return" } + if options then options.trace.current = nil; options.trace.labels[label] = finish end + emit(state, finish) end if #out == 0 then Logger.warn("gen3 script vm: '%s' compiled to zero rows (silent NPC)", entry) end - compiled[scripts][entry] = #out > 0 and out or false + if options then + Decoded.finish(out, options.trace) + else + Decoded.origin(out, data, entry, Gen3ScriptVM.compile, 3) + compiled[scripts][entry] = #out > 0 and out or false + end return #out > 0 and out or nil end diff --git a/src/script/ScriptRunner.lua b/src/script/ScriptRunner.lua index 595ca1fa9..810c159b2 100644 --- a/src/script/ScriptRunner.lua +++ b/src/script/ScriptRunner.lua @@ -175,10 +175,20 @@ end -- an array of rows; control commands return a new program counter, as a -- row number or a label name. function ScriptRunner:exec(script, ctx) + local Decoded, trace, origin + local decodedHooks = Runtime.hooks._decodedHooks + if decodedHooks and decodedHooks.chains["script.command"] then + Decoded = require("src.script.DecodedScript") + local prepared + prepared, trace, origin = Decoded.prepare(script) + script = prepared or script + end local labels = ScriptRunner.scanLabels(script) local data = self.game and self.game.data local pc = 1 - while pc <= #script do + local function step() + if pc > #script then return end + if trace and ctx.scriptCancelled then pc = math.huge; return end local row = script[pc] local name = row[1] -- THE ROW THE RUNNER IS STANDING ON, kept so a script that parks and @@ -273,6 +283,55 @@ function ScriptRunner:exec(script, ctx) end end end + if not trace then + while pc <= #script do step() end + return + end + local overrides, visited = {}, {} + while pc <= #script do + local candidates = trace and trace.starts[pc] + local command + for _, candidate in ipairs(candidates or {}) do + if not visited[candidate.key] then command = candidate; break end + end + if not command then + step() + visited = {} + else + visited[command.key] = true + local positive = command.after > command.pc + local ran, active = false, true + local function vanilla(hctx, op, ir) + assert(active, "decoded next is only valid during its callback") + assert(not ran, "decoded command already executed") + assert(hctx == ctx, "decoded next must retain its native script context") + ran = true + if op ~= command.ir[1] or not Decoded.same(ir, command.ir) then + local copy = {}; for k, v in pairs(ir) do copy[k] = v end; copy[1] = op + script, trace, command = Decoded.rewrite(origin, trace, overrides, command, copy, ctx) + labels = ScriptRunner.scanLabels(script) + pc = command.pc + end + local first, after = command.pc, command.after + positive = after > first + while pc >= first and pc < after and pc <= #script do step() end + end + local copy = Decoded.copy(Decoded.source(origin, command)) + local ok, err = pcall(decodedHooks.call, decodedHooks, "script.command", vanilla, ctx, copy[1], copy) + active = false + if not ok then error(err, 0) end + if not ran then + -- Suppress compile-time state as well (loadword/text fusion), once for + -- this visit. The next visit still offers the original source opcode. + script, trace, command = Decoded.rewrite(origin, trace, overrides, command, { "_decoded_skip" }, ctx) + labels = ScriptRunner.scanLabels(script) + pc = command.after + positive = command.after > command.pc + end + if positive then visited = {} end + end + if ctx.scriptCancelled then return end + end end -- Called by blocking commands from inside the coroutine. diff --git a/tests/engine/decoded_script_profile.lua b/tests/engine/decoded_script_profile.lua new file mode 100644 index 000000000..f12aeab07 --- /dev/null +++ b/tests/engine/decoded_script_profile.lua @@ -0,0 +1,315 @@ +local T = require("tests.harness") +love = require("tests.love_stub") +local Version = require("src.core.GameVersion") +local Hooks = require("src.mods.Hooks") +local Events = require("src.mods.Events") +local Runtime = require("src.mods.Runtime") +local Runner = require("src.script.ScriptRunner") +local Decoded = require("src.script.DecodedScript") +local C2 = require("src.script.Gen2ScriptVM") +local C3 = require("src.script.Gen3ScriptVM") +local function fixture(gen, scripts) + Version.set(gen == 2 and "gold" or "firered") + local data = require("tests.fixtures.upstream_mods.data").fresh() + data.map_scripts = { source = gen == 3 and "RomExtractorGen3" or "RomExtractorGen2", scripts = scripts, maps = {} } + local game = { data = data, save = { flags = {}, party = {}, gen3Vars = {} } } + local world = { map = { id = "NATIVE" } } + local runner = Runner.new(game, world) + local hooks, decoded = Hooks.new(), Hooks.new() + hooks._decodedHooks = decoded + Runtime.install(Events.new(), hooks, {}) + return game, runner, (gen == 2 and C2 or C3).compile(data, "ENTRY"), hooks, decoded +end +for _, gen in ipairs({ 2, 3 }) do + local set, finish = gen == 2 and "setval" or "setvar", gen == 2 and "endall" or "end" + local row = gen == 2 and { set, 4 } or { set, 0x4002, 4 } + local game, runner, rows, native, decoded = fixture(gen, { ENTRY = { row, { finish } } }) + local calls, order, expected = 0, {}, 4 + native:wrap("script.command", function(next, ctx, op, args) + order[#order + 1] = op + T.eq(ctx.script, rows, "native callback retains the original compiled script identity") + return next(ctx, op, args) + end) + runner:exec(rows, runner:makeContext()) + T.eq(calls, 0, "native-only runs never dispatch decoded callbacks") + local baseline = table.concat(order, ",") + order = {} + decoded:wrap("script.command", function(next, ctx, op, ir) + calls = calls + 1 + local result = next(ctx, op, ir) + if op == set then + T.eq( + gen == 2 and ctx.g2Var or ctx.save.gen3Vars[0x4002], + expected, + "post-next observes the native command effects" + ) + end + return result + end) + runner:exec(rows, runner:makeContext()) + T.eq(calls, 2, "one decoded callback per source command") + T.eq(table.concat(order, ","), baseline, "native command order is unchanged with a transparent profile") + local prepared = Decoded.prepare(rows) + T.same(prepared, rows, "transparent compiler trace preserves every native row and argument") + native:removeOwner(nil) -- no changes: test-owned native callback remains installed + local replacement = Hooks.new() + replacement._decodedHooks = decoded + Runtime.install(Events.new(), replacement, {}) + decoded:wrap("script.command", function(next, ctx, op, ir) + if op == set then + local copy = {} + for k, v in pairs(ir) do + copy[k] = v + end + copy[gen == 2 and 2 or 3] = 151 + return next(ctx, op, copy) + end + return next(ctx, op, ir) + end, 100) + expected = 151 + local editedContext = runner:makeContext() + runner:exec(rows, editedContext) + T.eq( + gen == 2 and editedContext.g2Var or game.save.gen3Vars[0x4002], + 151, + "edited decoded operands reach native execution" + ) + T.eq(row[gen == 2 and 2 or 3], 4, "hook rewrites never mutate imported source rows") +end +-- Calls lower to several rows; a wrapper surrounds the complete command once. +local game, runner, rows, native, decoded = fixture(3, { + ENTRY = { { "call", "SUB" }, { "setvar", 0x4010, 7 }, { "end" } }, + SUB = { { "setvar", 0x4011, 8 }, { "return" } }, +}) +local seen = {} +decoded:wrap("script.command", function(next, ctx, op, ir) + seen[#seen + 1] = op + if op == "setvar" and ir[2] == 0x4011 then + return next(ctx, "addvar", { "addvar", 0x4011, 9 }) + end + return next(ctx, op, ir) +end) +runner:exec(rows, runner:makeContext()) +T.same( + seen, + { "call", "setvar", "return", "setvar", "end" }, + "multi-row call, edited opcode and return keep decoded order" +) +T.eq(game.save.gen3Vars[0x4011], 9, "opcode replacements execute their own lowering") +T.eq(game.save.gen3Vars[0x4010], 7, "recompilation retains active call return addresses") +-- Zero-row loadword supplies a later std. A veto skips the entire grouped command. +local game2, run2, rows2, _, dec2 = + fixture(3, { ENTRY = { + { "nop" }, + { "loadword", 0, "TEXT_A" }, + { "setvar", 0x4020, 3 }, + { "end" }, + } }) +local count = 0 +dec2:wrap("script.command", function(next, ctx, op, ir) + count = count + 1 + if op == "setvar" then + return + end + if op == "loadword" then + ir[3] = "TEXT_B" + end + return next(ctx, op, ir) +end) +run2:exec(rows2, run2:makeContext()) +T.eq(count, 4, "zero-row operations are visible exactly once") +T.eq(game2.save.gen3Vars[0x4020], nil, "veto skips native effects") +-- The actual Loader routes only the explicitly selected mod onto the decoded bus. +local SDK = require("tests.modkit.sdk") +local Json = require("src.link.Json") +local Loader = require("src.mods.Loader") +Version.set("firered") +local fs = SDK.memfs({ + ["options.lua"] = 'return {mods={probe=true},modCallbackProfiles={probe="decoded"}}', + ["mods/probe/manifest.json"] = Json.encode({ id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua" }), + ["mods/probe/main.lua"] = [[return function(mod) + mod.hooks:wrap('script.command',function(next,ctx,op,row) + if op=='setvar' then row.value=99 end + return next(ctx,op,row) + end) + mod.events:on('pokemon.before_give',function(gift) + mod.exports.map=gift.ctx.overworld.map.id + gift.level=20;gift.nickname='PROFILE' + end) + end]], +}) +local data = require("tests.fixtures.upstream_mods.data").fresh() +data.maps.MAP_G04_N03 = { id = "MAP_G04_N03", group = 4, number = 3 } +local loader = Loader.new({ fs = fs, dev = false }) +T.eq(loader:load(data), true, "actual Loader accepts an external per-mod profile") +T.eq(loader.hooks.chains["script.command"], nil, "selected hook does not alter the native chain") +T.check(loader._decodedHooks.chains["script.command"] ~= nil, "selected hook registers on its private bus") +data.map_scripts = + { source = "RomExtractorGen3", scripts = { ENTRY = { { "setvar", 0x4002, 4 }, { "end" } } }, maps = {} } +local g = { data = data, save = { flags = {}, party = {}, gen3Vars = {} }, mods = loader } +local world = { map = { id = "MAP_G04_N03", def = data.maps.MAP_G04_N03 } } +local r = Runner.new(g, world) +r:exec(C3.compile(data, "ENTRY"), r:makeContext()) +T.eq(g.save.gen3Vars[0x4002], 99, "named row edit travels through Loader and compiler to native state") +local gift = { ctx = r:makeContext(), species = "CHARMANDER", level = 5 } +loader.events:emit("pokemon.before_give", gift) +T.eq(loader.exports.probe.map, "OAKS_LAB", "profile translates the verified native lab slot") +T.eq(world.map.id, "MAP_G04_N03", "native map identity is never renamed") +T.eq(gift.level, 20, "profile gift edits reach the existing gift event") +T.eq(gift.nickname, "PROFILE", "profile nickname edits commit") +loader:_rollback("probe") +T.eq(loader._decodedHooks.chains["script.command"], nil, "rollback retires the per-mod decoded registration") + +-- Every visit starts from original IR, even when an earlier visit rewrote it. +do + local g, r, program, _, bus = fixture(3, { + ENTRY = { { "setvar", 0x4030, 0 }, { "goto", "LOOP" } }, + LOOP = { + { "addvar", 0x4030, 1 }, + { "setvar", 0x4031, 4 }, + { "compare_var_to_value", 0x4030, 3 }, + { "goto_if", 0, "LOOP" }, + { "end" }, + }, + }) + local values = {} + bus:wrap("script.command", function(next, ctx, op, ir) + if op == "setvar" and ir[2] == 0x4031 then + values[#values + 1] = ir[3] + if #values == 1 then + ir[3] = 151 + elseif #values == 2 then + return + end + end + return next(ctx, op, ir) + end) + r:exec(program, r:makeContext()) + T.same(values, { 4, 4, 4 }, "loops offer original source operands after both rewrites and vetoes") + T.eq(g.save.gen3Vars[0x4030], 3, "rewritten loop retains its branch and completion") + T.eq(g.save.gen3Vars[0x4031], 4, "transparent later visit restores the original command") +end +-- A vetoed compile-time text load must not leak into a later callstd. +do + local g, r, program, _, bus = fixture(3, { ENTRY = { { "loadword", 0, "TEXT_A" }, { "callstd", 4 }, { "end" } } }) + local shown = 0 + local Commands = require("src.script.Commands") + local old = Commands.g3_show_text + Commands.g3_show_text = function() + shown = shown + 1 + end + bus:wrap("script.command", function(next, ctx, op, ir) + if op == "loadword" then + return + end + return next(ctx, op, ir) + end) + r:exec(program, r:makeContext()) + Commands.g3_show_text = old + T.eq(shown, 0, "veto removes compile-time text state from subsequent lowering") +end +-- Gen-2 selected profile keeps the fourth decoded row and lossless byte edits. +do + Version.set("gold") + local files = { + ["options.lua"] = "return {mods={probe=true}}", + ["mods/probe/manifest.json"] = Json.encode({ id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua" }), + ["mods/probe/main.lua"] = [=[return function(mod) + mod.hooks:wrap('script.command',function(next,ctx,op,args,cmd) + if op=='setval' then + assert(cmd.op=='setval' and cmd.value==4 and args[1]==4) + args[1]=77 + mod.exports.retained=next + end + return next(ctx,op,args,cmd) + end) + mod.events:once('pokemon.before_give',function(gift)mod.exports.gifts=(mod.exports.gifts or 0)+1 end) + end]=], + } + local d = require("tests.fixtures.upstream_mods.data").fresh() + local l = Loader.new({ fs = SDK.memfs(files), dev = false, callbackProfiles = { probe = "decoded" } }) + T.eq(l:load(d), true, "constructor profile works without editing a manifest or options file") + d.map_scripts = { source = "RomExtractorGen2", scripts = { ENTRY = { { "setval", 4 }, { "endall" } } }, maps = {} } + local g = { data = d, save = { flags = {}, party = {} } } + local r = Runner.new(g, {}) + local ctx = r:makeContext() + r:exec(C2.compile(d, "ENTRY"), ctx) + T.eq(ctx.g2Var, 77, "Gen-2 raw byte edits are decoded before native lowering") + T.raises(function() + l.exports.probe.retained() + end, "only valid during", "retained next cannot replay a completed command") + l.events:emit("pokemon.before_give", { ctx = ctx, species = "CHARMANDER", level = 5 }) + l.events:emit("pokemon.before_give", { ctx = ctx, species = "CHARMANDER", level = 5 }) + T.eq(l.exports.probe.gifts, 1, "profiled once subscription retires on the original native event bus") +end +-- Deep source operands never escape into a retained callback record. +do + local g, r, program, _, bus = fixture(3, { ENTRY = { { "nop", { nested = 1 } }, { "end" } } }) + bus:wrap("script.command", function(next, ctx, op, ir) + if op == "nop" then + ir[2].nested = 2 + end + return next(ctx, op, ir) + end) + r:exec(program, r:makeContext()) + T.eq(g.data.map_scripts.scripts.ENTRY[1][2].nested, 1, "nested source operands remain unchanged") +end + +-- Gift records include the cartridge species number without changing native +-- event records; numeric edits and variable-backed gifts resolve through data. +do + Version.set("firered") + local d = require("tests.fixtures.upstream_mods.data").fresh() + d.constants.speciesOrder = { [4] = "CHARMANDER", [151] = "MEW" } + local files = { + ["options.lua"] = 'return {mods={probe=true},modCallbackProfiles={probe="decoded"}}', + ["mods/probe/manifest.json"] = Json.encode({ id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua" }), + ["mods/probe/main.lua"] = [=[return function(mod) + mod.events:on('pokemon.before_give',function(gift) + assert(gift.speciesId==4) + gift.species=151;gift.level=120;gift.nickname='NUMERIC' + end) + end]=], + } + local l = Loader.new({ fs = SDK.memfs(files), dev = false }) + assert(l:load(d)) + local g = { data = d, save = { gen3Vars = { [0x4002] = 4 } } } + local ctx = { game = g, save = g.save, script = { { "g3_give_pokemon", 0x4002, 5, 0 } }, pc = 1 } + local gift = { ctx = ctx, species = "CHARMANDER", level = 5 } + local nativeSeen + l.events:on("pokemon.before_give", function(row) + nativeSeen = row + end, -100, "native") + l.events:emit("pokemon.before_give", gift) + T.eq(gift.species, "MEW", "numeric profiled gift species resolves through this dataset") + T.eq(gift.level, 100, "decoded gift level stays in the supported range") + T.eq(g.save.gen3Vars[0x4002], 151, "variable-backed native gift retains the edited species number") + T.eq(nativeSeen, gift, "native listeners retain the exact original gift record") + T.eq(gift.speciesId, nil, "profile-only species metadata never leaks into native event records") +end + +-- Hook and group scopes survive a real runner suspension across updates. +do + local g, r, program, _, bus = fixture(3, { ENTRY = { { "delay", 2 }, { "setvar", 0x4050, 9 }, { "end" } } }) + local phases = {} + bus:wrap("script.command", function(next, ctx, op, ir) + if op == "delay" then + phases[#phases + 1] = "before" + end + local value = next(ctx, op, ir) + if op == "delay" then + phases[#phases + 1] = "after" + end + return value + end) + r:run(program) + T.eq(r:isRunning(), true, "profiled native delay suspends the actual runner") + T.same(phases, { "before" }, "hook remains before next while native command is suspended") + T.eq(g.save.gen3Vars[0x4050], nil, "later operation cannot run during the delay") + r:update() + r:update() + T.eq(r:isRunning(), false, "native updates finish the profiled delay") + T.same(phases, { "before", "after" }, "post-next executes exactly once after resumption") + T.eq(g.save.gen3Vars[0x4050], 9, "native continuation executes after resumption") +end +T.finish() diff --git a/tests/fixtures/upstream_mods/worker.lua b/tests/fixtures/upstream_mods/worker.lua index 8ad274e51..e99379d1d 100644 --- a/tests/fixtures/upstream_mods/worker.lua +++ b/tests/fixtures/upstream_mods/worker.lua @@ -12,7 +12,7 @@ local I={ local raw=assert(Files.read('mods/'..fixture..'/manifest.json')) local manifest=assert(Json.decode(raw));local id=manifest.id local mount='mods/'..id -local overlay={['options.lua']=Writer.encode({mods={[id]=true}})} +local overlay={['options.lua']=Writer.encode({mods={[id]=true},modCallbackProfiles={[id]=id=='example_mew_starter' and 'decoded' or nil}})} local function pack(importer,kind,name,entry) local p={format=1,importer=importer,pack=kind,kind=kind=='tiles' and 'tileset' or 'sprite',version='1.1.0',source={name='Synthetic fixture',md5=string.rep('0',32)},entries={[name]=entry}} overlay[I.manifestPath(importer,kind)]=Writer.encode(p) @@ -57,7 +57,9 @@ local names,known={},{} local handle=assert(io.open('tests/fixtures/upstream_mods/index.json','rb')) local index=assert(Json.decode(handle:read('*a')));handle:close() for _,name in ipairs(index.hooks)do known[name]=true end -for name in pairs(loader.hooks.chains)do assert(known[name],'unknown hook '..name);names[#names+1]=name end +for _,bus in ipairs({loader.hooks,loader._decodedHooks})do + for name in pairs(bus.chains)do assert(known[name],'unknown hook '..name);names[#names+1]=name end +end table.sort(names) local loaded=ok and result==true and loader.mods[id] and loader.mods[id].state=='loaded' local errorText=ok and table.concat(loader.errors,'; ') or tostring(result) @@ -67,5 +69,27 @@ if loaded and id=='example_mew_starter' and version=='red' then loader.events:emit('pokemon.before_give',gift) assert(gift.species=='MEW' and gift.level==20,'legacy starter event did not run') end -print('FIXTURE_RESULT '..Json.encode({fixture=fixture,version=version,loaded=loaded and true or false,hooks=names,error=errorText})) +-- Authentic unchanged starter code now runs through the actual Gen-3 compiler, +-- runner, existing gift event and Pokémon constructor on test-owned data. +local functional +if loaded and id=='example_mew_starter' and version=='firered' then + data.maps.MAP_G04_N03={id='MAP_G04_N03',group=4,number=3} + data.constants.speciesOrder={[4]='CHARMANDER',[151]='MEW'} + data.map_scripts={source='RomExtractorGen3',scripts={ENTRY={ + {'setvar',0x4002,4},{'givemon',0x4002,5,0},{'end'}, + }},maps={}} + local save={flags={},party={},gen3Vars={},player={name='TEST',id=12345}, + pokedex={seen={},owned={}}} + local game={data=data,save=save,mods=loader} + local world={map={id='MAP_G04_N03',def=data.maps.MAP_G04_N03}} + local runner=require('src.script.ScriptRunner').new(game,world) + runner:exec(require('src.script.Gen3ScriptVM').compile(data,'ENTRY'),runner:makeContext()) + assert(save.gen3Vars[0x4002]==151,'unchanged starter hook did not rewrite the native variable') + local mon=assert(save.party[1],'native gift did not create a Pokémon') + assert(#save.party==1 and mon.species=='MEW' and mon.level==20 and mon.nickname=='HOGHEAD', + 'unchanged starter event did not produce the requested native gift') + assert(world.map.id=='MAP_G04_N03','profile renamed the native map') + functional={species=mon.species,level=mon.level,nickname=mon.nickname,variable=save.gen3Vars[0x4002]} +end +print('FIXTURE_RESULT '..Json.encode({fixture=fixture,version=version,loaded=loaded and true or false,hooks=names,error=errorText,functional=functional})) if not loaded then os.exit(1)end diff --git a/tools/check_decoded_profiles.py b/tools/check_decoded_profiles.py new file mode 100644 index 000000000..c97276dac --- /dev/null +++ b/tools/check_decoded_profiles.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Measure native script execution with an owned LÖVE window and F11 sections.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--love', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + parser.add_argument('--baseline', required=True, help='local pre-M5 Git commit') + parser.add_argument('--report', type=Path, required=True) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(args.qa_tools or root / 'tools')) + from qa_storage import qa_source, qa_cache, qa_profile, qa_run + qa_source(root) + qa_source(args.love.resolve()) + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True, exist_ok=True) + files = ('src/script/ScriptRunner.lua', 'src/script/DecodedScript.lua', + 'src/script/Gen2ScriptVM.lua', 'src/script/Gen3ScriptVM.lua', + 'src/script/Commands.lua', 'src/mods/Loader.lua', 'src/mods/DecodedCallbacks.lua', + 'tools/decoded-profile-check/main.lua', 'tools/decoded-profile-check/conf.lua') + hashes = {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in files} + before = qa_run(['git', 'rev-parse', '--verify', args.baseline + '^{commit}'], cwd=root, + capture_output=True, text=True, check=True).stdout.strip() + blob = qa_run(['git', 'show', before + ':src/script/ScriptRunner.lua'], cwd=root, + capture_output=True, check=True).stdout + baseline = args.report.parent / 'baseline-runner.lua' + baseline.write_bytes(blob) + identity = 'oasc-gen2recomp-qa-decoded-profile-' + str(time.time_ns()) + qa_profile(Path.home() / 'Library/Application Support/LOVE' / identity) + result = qa_run([str(args.love.resolve()), str(root / 'tools/decoded-profile-check')], + cwd=root, env=dict(os.environ, POKEPORT_IDENTITY=identity, + DECODED_PROFILE_REPO=str(root), DECODED_PROFILE_QA_GUARDED='1', + DECODED_PROFILE_BASELINE=str(baseline.resolve())), + capture_output=True, text=True, timeout=90) + args.report.with_suffix('.log').write_text(result.stdout + result.stderr) + records = [line[len('M5_NATIVE_RESULT '):] for line in result.stdout.splitlines() + if line.startswith('M5_NATIVE_RESULT ')] + if result.returncode or len(records) != 1: + raise RuntimeError('native profile probe failed; see ' + str(args.report.with_suffix('.log'))) + if any(hashlib.sha256((root / name).read_bytes()).hexdigest() != digest for name, digest in hashes.items()): + raise RuntimeError('measured source changed during the probe') + report = json.loads(records[0]) + report.update(baseline=before, baselineRunnerSha256=hashlib.sha256(blob).hexdigest(), + sha256=hashes, minimumFreeGiB=20, playerProfilesUsed=False) + args.report.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/tools/decoded-profile-check/conf.lua b/tools/decoded-profile-check/conf.lua new file mode 100644 index 000000000..eff68e98f --- /dev/null +++ b/tools/decoded-profile-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv("DECODED_PROFILE_QA_GUARDED") == "1", "use tools/check_decoded_profiles.py") + t.identity = assert(os.getenv("POKEPORT_IDENTITY")) + t.window.title = "Decoded script profile QA" + t.window.width, t.window.height, t.window.vsync = 320, 180, 1 + t.modules.audio = false +end diff --git a/tools/decoded-profile-check/main.lua b/tools/decoded-profile-check/main.lua new file mode 100644 index 000000000..b4288d9a8 --- /dev/null +++ b/tools/decoded-profile-check/main.lua @@ -0,0 +1,100 @@ +local root = assert(os.getenv("DECODED_PROFILE_REPO")) +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +function love.errorhandler(message) + print(debug.traceback(tostring(message), 2)) + return function() + return 1 + end +end +local Version = require("src.core.GameVersion") +Version.set("firered") +local Native = require("src.script.ScriptRunner") +local baseline = assert(io.open(assert(os.getenv("DECODED_PROFILE_BASELINE")), "rb")) +local original = assert(loadstring(baseline:read("*a"), "@baseline/ScriptRunner.lua"))() +baseline:close() +local Profile = require("src.core.FrameProfile") +local Runtime = require("src.mods.Runtime") +local Loader = require("src.mods.Loader") +local SDK = require("tests.modkit.sdk") +local Json = require("src.link.Json") +local Compiler = require("src.script.Gen3ScriptVM") +local source = [[return function(mod) + mod.hooks:wrap('script.command',function(next,ctx,op,row)return next(ctx,op,row)end) +end]] +local phase, frame, runner, rows, game, started, total = 1, 0, nil, nil, nil, 0, 0 +local results, WINDOW, WARMUP = {}, 120, 120 +local function start() + Profile.setEnabled(false) + local data = require("tests.fixtures.upstream_mods.data").fresh() + local files = { + ["mods/probe/manifest.json"] = Json.encode({ id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua" }), + ["mods/probe/main.lua"] = source, + ["options.lua"] = "return {mods={probe=true}}", + } + local loader = + Loader.new({ fs = SDK.memfs(files), dev = false, callbackProfiles = { probe = phase == 3 and "decoded" + or "native" } }) + assert(loader:load(data)) + local ir = {} + for i = 1, 64 do + ir[i] = { "setvar", 0x4000 + i, i } + end + ir[#ir + 1] = { "end" } + data.map_scripts = { source = "RomExtractorGen3", scripts = { ENTRY = ir }, maps = {} } + game = { data = data, save = { gen3Vars = {}, flags = {}, party = {} }, mods = loader } + runner = (phase == 1 and original or Native).new(game, {}) + rows = Compiler.compile(data, "ENTRY") + frame, total = 0, 0 +end +function love.run() + start() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + Profile.setEnabled(true) + started = love.timer.getTime() + end + local close = Profile.section("script.exec (64 writes + end)") + local t = love.timer.getTime() + runner:exec(rows, runner:makeContext()) + if frame >= WARMUP then + total = total + love.timer.getTime() - t + end + close() + assert(game.save.gen3Vars[0x4040] == 64) + love.graphics.clear(0.03, 0.04, 0.06) + love.graphics.present() + if frame >= WARMUP then + Profile.frame() + end + frame = frame + 1 + if frame == WINDOW + WARMUP then + results[phase] = { + mode = ({ "before", "native", "decoded" })[phase], + frames = WINDOW, + scriptMeanMs = total / WINDOW * 1000, + seconds = love.timer.getTime() - started, + } + if phase == 3 then + print( + "M5_NATIVE_RESULT " + .. Json.encode({ + phases = results, + renderer = love.graphics.getRendererInfo(), + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + scope = "Native LÖVE ScriptRunner and actual per-mod Loader profile over 65 test-owned commands per frame; no world scene or cartridge gameplay", + }) + ) + return 0 + end + phase = phase + 1 + start() + end + end +end From 15436043950f45f66d2630cd38c3d74d4618d275 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:24:06 +0100 Subject: [PATCH 095/138] Release retired decoded-script sources and prevent failed-group retries --- docs/decoded-script-callbacks.md | 2 + src/script/DecodedScript.lua | 19 ++++++-- src/script/ScriptRunner.lua | 4 +- tests/engine/decoded_script_profile.lua | 60 ++++++++++++++++++++++++- 4 files changed, 79 insertions(+), 6 deletions(-) diff --git a/docs/decoded-script-callbacks.md b/docs/decoded-script-callbacks.md index a0a026ff7..ef4178d91 100644 --- a/docs/decoded-script-callbacks.md +++ b/docs/decoded-script-callbacks.md @@ -20,6 +20,8 @@ Native subscribers retain their existing bus, names, operand arrays, ordering and context. The original compiled script table remains identical until a mod actually changes a decoded operation. Compiler provenance and tracing stay in private side tables; no scratch records are placed in public script rows. +Provenance holds weak source references: retained rows cannot keep a retired +dataset alive, and replaced source pools explicitly retire their old provenance. The profile presents `(next, ctx, op, row)` for Gen 3 and `(next, ctx, op, args, cmd)` for Gen 2. Numeric operand slots and the supported diff --git a/src/script/DecodedScript.lua b/src/script/DecodedScript.lua index b9ef825e8..12f3f190d 100644 --- a/src/script/DecodedScript.lua +++ b/src/script/DecodedScript.lua @@ -4,7 +4,12 @@ local M = {} local origins = setmetatable({}, { __mode = "k" }) function M.origin(rows, data, entry, compile, generation) - origins[rows] = { data = data, entry = entry, compile = compile, generation = generation } + origins[rows] = { + data = setmetatable({ data, data.map_scripts.scripts }, { __mode = "v" }), + entry = entry, + compile = compile, + generation = generation, + } end function M.trace() return { commands = {}, byKey = {}, owners = {}, labels = {}, returns = {}, returnKeys = {} } @@ -52,10 +57,18 @@ function M.finish(rows, t) end end function M.prepare(rows) - local origin = origins[rows] - if not origin then + local stored = origins[rows] + if not stored then return end + local data = assert(stored.data[1], "decoded script source dataset has been retired") + assert( + data.map_scripts and data.map_scripts.scripts == stored.data[2], + "decoded script source cohort has been retired" + ) + -- Only an executing script borrows a strong source reference. The weak-key + -- compiler cache and this provenance table must not keep each other alive. + local origin = { data = data, entry = stored.entry, compile = stored.compile, generation = stored.generation } local trace = M.trace() local result = origin.compile(origin.data, origin.entry, { trace = trace }) assert(M.same(result, rows), "decoded profile requires an unmodified native compiled script") diff --git a/src/script/ScriptRunner.lua b/src/script/ScriptRunner.lua index 810c159b2..51e2621bd 100644 --- a/src/script/ScriptRunner.lua +++ b/src/script/ScriptRunner.lua @@ -300,7 +300,7 @@ function ScriptRunner:exec(script, ctx) else visited[command.key] = true local positive = command.after > command.pc - local ran, active = false, true + local ran, active, completed = false, true, false local function vanilla(hctx, op, ir) assert(active, "decoded next is only valid during its callback") assert(not ran, "decoded command already executed") @@ -315,11 +315,13 @@ function ScriptRunner:exec(script, ctx) local first, after = command.pc, command.after positive = after > first while pc >= first and pc < after and pc <= #script do step() end + completed = true end local copy = Decoded.copy(Decoded.source(origin, command)) local ok, err = pcall(decodedHooks.call, decodedHooks, "script.command", vanilla, ctx, copy[1], copy) active = false if not ok then error(err, 0) end + assert(not ran or completed, "decoded native command did not complete; downstream failure cannot be swallowed") if not ran then -- Suppress compile-time state as well (loadword/text fusion), once for -- this visit. The next visit still offers the original source opcode. diff --git a/tests/engine/decoded_script_profile.lua b/tests/engine/decoded_script_profile.lua index f12aeab07..95ed309ab 100644 --- a/tests/engine/decoded_script_profile.lua +++ b/tests/engine/decoded_script_profile.lua @@ -213,7 +213,12 @@ do Version.set("gold") local files = { ["options.lua"] = "return {mods={probe=true}}", - ["mods/probe/manifest.json"] = Json.encode({ id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua" }), + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + entry = "main.lua", + }), ["mods/probe/main.lua"] = [=[return function(mod) mod.hooks:wrap('script.command',function(next,ctx,op,args,cmd) if op=='setval' then @@ -263,7 +268,12 @@ do d.constants.speciesOrder = { [4] = "CHARMANDER", [151] = "MEW" } local files = { ["options.lua"] = 'return {mods={probe=true},modCallbackProfiles={probe="decoded"}}', - ["mods/probe/manifest.json"] = Json.encode({ id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua" }), + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + entry = "main.lua", + }), ["mods/probe/main.lua"] = [=[return function(mod) mod.events:on('pokemon.before_give',function(gift) assert(gift.speciesId==4) @@ -312,4 +322,50 @@ do T.same(phases, { "before", "after" }, "post-next executes exactly once after resumption") T.eq(g.save.gen3Vars[0x4050], 9, "native continuation executes after resumption") end + +-- Provenance must not keep a retired dataset alive through the compiler cache. +local weak, retained +local function retired(keepRows) + local g, r, program = fixture(3, { ENTRY = { { "setvar", 0x4060, 1 }, { "end" } } }) + local refs = setmetatable({ g.data, program }, { __mode = "v" }) + return refs, keepRows and program or nil +end +weak = retired(false) +collectgarbage("collect") +collectgarbage("collect") +T.eq(weak[1], nil, "private provenance releases an unowned dataset") +T.eq(weak[2], nil, "private provenance releases an unowned compiled program") +weak, retained = retired(true) +collectgarbage("collect") +collectgarbage("collect") +T.eq(weak[1], nil, "a retained native row list cannot retain the entire source dataset") +T.raises(function() + Decoded.prepare(retained) +end, "retired", "decoded execution refuses a retired source cohort") + +do + local g, r, program = fixture(3, { ENTRY = { { "setvar", 0x4060, 1 }, { "end" } } }) + g.data.map_scripts = { source = "RomExtractorGen3", scripts = { ENTRY = { { "end" } } }, maps = {} } + T.raises(function() + Decoded.prepare(program) + end, "retired", "a replaced source pool retires old compiled provenance") +end +-- Swallowing an error below next cannot retry partial native side effects. +do + local g, r, program, _, bus = fixture(3, { ENTRY = { { "setvar", 0x4060, 1 }, { "end" } } }) + local Commands = require("src.script.Commands") + local original, count = Commands.g3_setvar, 0 + Commands.g3_setvar = function() + count = count + 1 + error("owned native failure") + end + bus:wrap("script.command", function(next, ctx, op, ir) + pcall(next, ctx, op, ir) + end) + T.raises(function() + r:exec(program, r:makeContext({ source = { strict = true } })) + end, "did not complete", "a mod cannot swallow a failing native group and restart it") + Commands.g3_setvar = original + T.eq(count, 1, "native side effects occur at most once across a swallowed failure") +end T.finish() From e9395bb1d41a792977e86c4acc90417f6e2de7cc Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:29:20 +0100 Subject: [PATCH 096/138] Keep decoded tracing behind the original public compiler contract --- src/script/Gen2ScriptVM.lua | 10 ++++++-- src/script/Gen3ScriptVM.lua | 10 ++++++-- tests/engine/decoded_script_profile.lua | 32 +++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/script/Gen2ScriptVM.lua b/src/script/Gen2ScriptVM.lua index 5c514f6f5..fccd33e3b 100644 --- a/src/script/Gen2ScriptVM.lua +++ b/src/script/Gen2ScriptVM.lua @@ -2369,7 +2369,7 @@ function Gen2ScriptVM.lowered(op) return L[op] ~= nil end -function Gen2ScriptVM.compile(data, entry, options) +local function compile(data, entry, options) local pool = store(data) local scripts = pool and pool.scripts if not (scripts and type(entry) == "string" and scripts[entry]) then return nil end @@ -2515,12 +2515,18 @@ function Gen2ScriptVM.compile(data, entry, options) if options then Decoded.finish(out, options.trace) else - Decoded.origin(out, data, entry, Gen2ScriptVM.compile, 2) + Decoded.origin(out, data, entry, compile, 2) compiled[scripts][key] = #out > 0 and out or false end return #out > 0 and out or nil end +-- Keep the public compiler's original two-argument contract. Profile tracing +-- uses its private compiler reference, including when a mod wraps compile(). +function Gen2ScriptVM.compile(data, entry) + return compile(data, entry) +end + -- Prism renamed a good deal of Crystal's script vocabulary without changing -- what the commands DO -- `jump` for `sjump`, `if_equal` for `ifequal`, -- `end_all` for `endall`. An unlowered command is not an error, it just diff --git a/src/script/Gen3ScriptVM.lua b/src/script/Gen3ScriptVM.lua index efc956bd9..7de34bef5 100644 --- a/src/script/Gen3ScriptVM.lua +++ b/src/script/Gen3ScriptVM.lua @@ -807,7 +807,7 @@ end Gen3ScriptVM.store = store -function Gen3ScriptVM.compile(data, entry, options) +local function compile(data, entry, options) local pool = store(data) local scripts = pool and pool.scripts if not (scripts and type(entry) == "string" and scripts[entry]) then return nil end @@ -867,12 +867,18 @@ function Gen3ScriptVM.compile(data, entry, options) if options then Decoded.finish(out, options.trace) else - Decoded.origin(out, data, entry, Gen3ScriptVM.compile, 3) + Decoded.origin(out, data, entry, compile, 3) compiled[scripts][entry] = #out > 0 and out or false end return #out > 0 and out or nil end +-- Keep the public compiler's original two-argument contract. Profile tracing +-- uses its private compiler reference, including when a mod wraps compile(). +function Gen3ScriptVM.compile(data, entry) + return compile(data, entry) +end + -- --------------------------------------------------------------------------- -- registration -- --------------------------------------------------------------------------- diff --git a/tests/engine/decoded_script_profile.lua b/tests/engine/decoded_script_profile.lua index 95ed309ab..3a828060f 100644 --- a/tests/engine/decoded_script_profile.lua +++ b/tests/engine/decoded_script_profile.lua @@ -368,4 +368,36 @@ do Commands.g3_setvar = original T.eq(count, 1, "native side effects occur at most once across a swallowed failure") end + +for _, gen in ipairs({ 2, 3 }) do + local compiler = gen == 2 and C2 or C3 + local g, r, program, _, bus = fixture( + gen, + { ENTRY = gen == 2 and { { "setval", 4 }, { "endall" } } or { { "setvar", 0x4070, 4 }, { "end" } } } + ) + T.eq(compiler.compile(g.data, "ENTRY", true), program, "public compiler continues to ignore surplus arguments") + local original, calls = compiler.compile, 0 + compiler.compile = function(data, entry) + calls = calls + 1 + return original(data, entry) + end + program = compiler.compile(g.data, "ENTRY") + bus:wrap("script.command", function(next, ctx, op, ir) + if op == "setval" then + ir[2] = 7 + elseif op == "setvar" then + ir[3] = 7 + end + return next(ctx, op, ir) + end) + local ctx = r:makeContext() + r:exec(program, ctx) + T.eq( + gen == 2 and ctx.g2Var or g.save.gen3Vars[0x4070], + 7, + "a transparent public compiler wrapper cannot suppress decoded callbacks" + ) + T.eq(calls, 1, "private tracing does not replay public compiler wrappers") + compiler.compile = original +end T.finish() From 8a7500d868a0195c7b54a22e652c776cc60128a0 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:36:22 +0100 Subject: [PATCH 097/138] Keep decoded Gen2 call hooks open through native subroutine returns --- docs/decoded-script-callbacks.md | 3 ++- src/script/ScriptRunner.lua | 13 ++++++++++++- tests/engine/decoded_script_profile.lua | 24 ++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/decoded-script-callbacks.md b/docs/decoded-script-callbacks.md index ef4178d91..7941a0580 100644 --- a/docs/decoded-script-callbacks.md +++ b/docs/decoded-script-callbacks.md @@ -29,7 +29,8 @@ named aliases describe the source operation, before native lowering. A single source operation may have zero, one or several native rows. `next` runs that whole native group; code after `next` observes its native effects. Rewriting an opcode or operand recompiles a private overlay using the same native compiler. -Calls keep their active return addresses. Each loop visit offers the original +Calls keep their active return addresses. Gen-2 call hooks remain open until +the called subroutine returns, including across native waits. Each loop visit offers the original source operation again, so a previous rewrite or veto cannot rewrite the imported source. Vetoes also remove compile-time text state. A retained `next` cannot replay a completed operation. diff --git a/src/script/ScriptRunner.lua b/src/script/ScriptRunner.lua index 51e2621bd..3088c5ee0 100644 --- a/src/script/ScriptRunner.lua +++ b/src/script/ScriptRunner.lua @@ -288,7 +288,8 @@ function ScriptRunner:exec(script, ctx) return end local overrides, visited = {}, {} - while pc <= #script do + local dispatch + dispatch = function() local candidates = trace and trace.starts[pc] local command for _, candidate in ipairs(candidates or {}) do @@ -313,8 +314,15 @@ function ScriptRunner:exec(script, ctx) pc = command.pc end local first, after = command.pc, command.after + local nestedCall = origin.generation == 2 and (op == "scall" or op == "farscall") + local depth = nestedCall and #(ctx.g2Stack or {}) or 0 positive = after > first while pc >= first and pc < after and pc <= #script do step() end + -- Gen-2 decoded calls are synchronous: their hook's post-next code + -- observes the completed subroutine, including any native suspension. + if nestedCall then + while pc <= #script and not ctx.scriptCancelled and #(ctx.g2Stack or {}) > depth do dispatch() end + end completed = true end local copy = Decoded.copy(Decoded.source(origin, command)) @@ -332,6 +340,9 @@ function ScriptRunner:exec(script, ctx) end if positive then visited = {} end end + end + while pc <= #script do + dispatch() if ctx.scriptCancelled then return end end end diff --git a/tests/engine/decoded_script_profile.lua b/tests/engine/decoded_script_profile.lua index 3a828060f..4a1fac628 100644 --- a/tests/engine/decoded_script_profile.lua +++ b/tests/engine/decoded_script_profile.lua @@ -400,4 +400,28 @@ for _, gen in ipairs({ 2, 3 }) do T.eq(calls, 1, "private tracing does not replay public compiler wrappers") compiler.compile = original end + +-- The decoded Gen-2 call contract runs its subroutine before post-next code. +do + local g, r, program, _, bus = fixture(2, { + ENTRY = { { "scall", "SUB" }, { "setval", 7 }, { "endall" } }, + SUB = { { "setval", 9 }, { "pause", 2 }, { "end" } }, + }) + local observed + bus:wrap("script.command", function(next, ctx, op, ir) + local result = next(ctx, op, ir) + if op == "scall" then + observed = ctx.g2Var + end + return result + end) + r:run(program) + T.eq(observed, nil, "Gen-2 call hook remains suspended with its subroutine") + T.eq(r:isRunning(), true, "nested native wait keeps the caller suspended") + r:update() + r:update() + local ctx = r.ctx + T.eq(observed, 9, "Gen-2 post-next sees the completed subroutine state") + T.eq(ctx.g2Var, 7, "the caller continues after its decoded call hook closes") +end T.finish() From cc5275275a69cdba5e8f8ed3b30570775ade2c92 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:53:11 +0100 Subject: [PATCH 098/138] compat(M6): expose shared native NPC controls on legacy handles --- .gitignore | 1 + docs/legacy-npc-controls.md | 55 +++++++ src/mods/Api3NPC.lua | 33 +---- src/mods/Loader.lua | 4 + src/world/NpcControls.lua | 49 +++++++ src/world/WorldAPI.lua | 80 ++++++++++- tests/engine/legacy_npc_controls.lua | 134 ++++++++++++++++++ .../legacy_npc_controls/mods/legacy/main.lua | 18 +++ .../mods/legacy/manifest.json | 1 + tests/fixtures/legacy_npc_controls/worker.lua | 16 +++ tools/check_decoded_profiles.py | 18 ++- tools/check_legacy_npc_contract.py | 54 +++++++ tools/decoded-profile-check/main.lua | 4 + tools/decoded-profile-check/npcs.lua | 88 ++++++++++++ 14 files changed, 524 insertions(+), 31 deletions(-) create mode 100644 docs/legacy-npc-controls.md create mode 100644 src/world/NpcControls.lua create mode 100644 tests/engine/legacy_npc_controls.lua create mode 100644 tests/fixtures/legacy_npc_controls/mods/legacy/main.lua create mode 100644 tests/fixtures/legacy_npc_controls/mods/legacy/manifest.json create mode 100644 tests/fixtures/legacy_npc_controls/worker.lua create mode 100644 tools/check_legacy_npc_contract.py create mode 100644 tools/decoded-profile-check/npcs.lua diff --git a/.gitignore b/.gitignore index 7a048e237..19c16fb8e 100644 --- a/.gitignore +++ b/.gitignore @@ -183,4 +183,5 @@ mobile/ios/bundle_id.local !/tests/fixtures/ /tests/fixtures/* !/tests/fixtures/upstream_mods/ +!/tests/fixtures/legacy_npc_controls/ !/tests/engine/ diff --git a/docs/legacy-npc-controls.md b/docs/legacy-npc-controls.md new file mode 100644 index 000000000..6459e74e7 --- /dev/null +++ b/docs/legacy-npc-controls.md @@ -0,0 +1,55 @@ +# API-2 native NPC controls (M6) + +Legacy `mod.world:npc(mapId, indexOrName)` handles now expose `stepNow`, +`canStep`, `placeAt`, `isMoving`, `setPassable` and `setAppearance`. Existing +`ow`, `npc` and `id` fields, scripted movement, facing, position and elevation +remain unchanged. + +The operations share the existing API-3 native actor implementation through +`src/world/NpcControls.lua`. API 3 keeps its original admission, validation, +ownership and cleanup policies. API 2 adds controls to its existing handle and +retains immediate replay semantics: `stepNow(direction)` deliberately skips +collision and the scripted movement queue. Call `canStep(direction)` first +when the map should decide whether that move is legal. + +`placeAt(x, y, facing)` clears the active step, its target and phase, and aligns +native cell/pixel coordinates. Coordinates must be finite nonnegative integers +up to 1,000,000. The optional facing uses `up/down/left/right`. `isMoving()` +reads native motion; `setPassable(value)` uses legacy boolean coercion. +`setAppearance(id)` resolves the native sprite on a private definition copy +before committing it to the actor. A missing sprite or a rendering error leaves +the actor and imported template unchanged. + +New controls decline stale handles after a map/world/save/data/version change, +actor removal or mod retirement. Failed-entry rollback, disable and replacement +of the loader's bus retire the new controls. Previously applied controls remain +native scene changes. Existing legacy handle methods keep their behavior. +Caller permission attribution is restored on both success and failure. + +The extension is available without changing a mod manifest, API level or public +hook. It is an additive compatibility feature; reverting the M6 commit removes +the extension while restoring the original shared implementation. + +## Qualification + +The unchanged API-2 consumer fixture runs through both production loaders +against pinned reference `535a5ec` on Red, Blue, Yellow, Gold, Silver, Crystal, +FireRed and Emerald. Its public movement, passability, placement, motion and +appearance results match in all eight comparisons. The fixture declares those +three generations explicitly and is test-owned. + +The native suite also uses real Map collision and NPC updates, checks queue-free +motion and cancellation of a running step, preserves old fields/methods, checks +peer passability and sprite failure atomicity, and exercises stale ownership. +The existing API-3 NPC and portable movement suites qualify the shared helper. +Sprite construction uses a deterministic test seam; rendered artwork equivalence +and retail gameplay are outside these checks. + +`tools/check_decoded_profiles.py --npc-controls` uses the same guarded LÖVE host +as M5. The first two phases measure 64 identical legacy handle lookups, position +and facing calls per frame before and after M6. A third phase exercises all six +new controls and 1,024 native NPC updates per frame. It reports separate F11 +sections for lookup and new-control workloads, with 120 warm-up and 120 measured +frames per phase. These are owned CPU contract workloads with deterministic +sprite construction. The PR records the measured cost and source fingerprints; +no world-scene or handheld FPS claim is made. diff --git a/src/mods/Api3NPC.lua b/src/mods/Api3NPC.lua index 8a4091f2a..b1a29b51b 100644 --- a/src/mods/Api3NPC.lua +++ b/src/mods/Api3NPC.lua @@ -2,7 +2,7 @@ local Runtime = require("src.mods.Runtime") local Version = require("src.core.GameVersion") local Edits = require("src.mods.Api3WorldEdits") -local Collision = require("src.world.Collision") +local Controls = require("src.world.NpcControls") local copy = require("src.mods.Merge").deepCopy local N = {} local services = setmetatable({}, { __mode = "k" }) @@ -382,7 +382,7 @@ function N.new(loader, owner, gen, current, native, licensed, req) return npc.cellX, npc.cellY end if name == "isMoving" then - return not not npc.moving + return Controls.isMoving(npc) end if name == "canStep" or name == "stepNow" or name == "face" or name == "scriptMove" then if not dirs[a] then @@ -390,7 +390,7 @@ function N.new(loader, owner, gen, current, native, licensed, req) end end if name == "canStep" then - return not not Collision.canMove(onMap, peers or {}, npc, a) + return Controls.canStep(npc, onMap, peers, a) end if name == "scriptMove" and (b ~= nil and not integer(b, 1, 1024) or c ~= nil and type(c) ~= "function") @@ -432,32 +432,13 @@ function N.new(loader, owner, gen, current, native, licensed, req) elseif name == "face" then npc.facing = a elseif name == "stepNow" then - if npc.moving then - return nil, "already moving" - end - npc.facing = a - npc.targetX, npc.targetY = Collision.target(npc.cellX, npc.cellY, a) - npc.moving, npc.progress = true, 0 + return Controls.stepNow(npc, a) elseif name == "placeAt" then - npc.moving, npc.marching, npc.progress = false, false, 0 - npc.targetX, npc.targetY = nil, nil - npc.cellX, npc.cellY, npc.px, npc.py = a, b, a * 16, b * 16 - if c then - npc.facing = c - end + return Controls.placeAt(npc, a, b, c) elseif name == "setPassable" then - npc.passable = a + return Controls.setPassable(npc, a) elseif name == "setAppearance" then - if not sprite(game, a) then - return nil, "unknown sprite: " .. a - end - local proxy = { id = npc.id, def = copy(npc.def) } - proxy.def.sprite = a - proxy.def.graphicsId, proxy.def.graphics, proxy.def.graphicsVar, proxy.def.varSprite = - nil, nil, nil, nil - req("src.world.NPC").refreshSprite(proxy, game.data) - npc.def, npc.sprite, npc.appearance = proxy.def, proxy.sprite, a - npc.big = proxy.sprite and proxy.sprite.big or false + return Controls.setAppearance(npc, game, a, req) end return true end) diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index ac33e99e3..0d6b9f099 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1197,6 +1197,10 @@ function Loader:_api(mod) local module = game and engineRequire("src.world.WorldAPI") if not module then return nil end world = module.new(game, modId, readContext) + world._npcAlive = function() + return loader.mods[modId] == mod and loader.apis[modId] == api + and loader.exports[modId] ~= nil and isActive(mod) + end world.overviewContext = overviewContext return world end }, api, loader, mod.manifest)) diff --git a/src/world/NpcControls.lua b/src/world/NpcControls.lua new file mode 100644 index 000000000..d5a08d952 --- /dev/null +++ b/src/world/NpcControls.lua @@ -0,0 +1,49 @@ +-- Original contribution by Roxas2712. See LICENSE-ROXAS2712.md. +-- Shared native actor operations. Facades retain their own admission policy. +local Collision = require("src.world.Collision") +local copy = require("src.mods.Merge").deepCopy +local C = {} +function C.isMoving(actor) + return not not actor.moving +end +function C.canStep(actor, map, peers, direction) + return not not Collision.canMove(map, peers or {}, actor, direction) +end +function C.stepNow(actor, direction) + if actor.moving then + return nil, "already moving" + end + actor.facing = direction + actor.targetX, actor.targetY = Collision.target(actor.cellX, actor.cellY, direction) + actor.moving, actor.progress = true, 0 + return true +end +function C.placeAt(actor, x, y, facing) + actor.moving, actor.marching, actor.progress = false, false, 0 + actor.targetX, actor.targetY = nil, nil + actor.cellX, actor.cellY, actor.px, actor.py = x, y, x * 16, y * 16 + if facing then + actor.facing = facing + end + return true +end +function C.setPassable(actor, passable) + actor.passable = passable + return true +end +function C.setAppearance(actor, game, id, req) + if not (game.data and game.data.sprites and game.data.sprites[id]) then + return nil, "unknown sprite: " .. tostring(id) + end + -- Resolve first, then commit. A renderer failure cannot half-change a live + -- actor or its imported template. This is the existing API-3 native path. + local proxy = { id = actor.id, def = copy(actor.def) } + proxy.def.sprite = id + proxy.def.graphicsId, proxy.def.graphics, proxy.def.graphicsVar, proxy.def.varSprite = nil, nil, nil, nil + local nativeNpc = (req or require)("src.world.NPC") + nativeNpc.refreshSprite(proxy, game.data) + actor.def, actor.sprite, actor.appearance = proxy.def, proxy.sprite, id + actor.big = proxy.sprite and proxy.sprite.big or false + return true +end +return C diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 3e2ae3080..f2fddcada 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -10,6 +10,9 @@ local Actions = require("src.world.WorldActions") local MapLoader = require("src.world.MapLoader") local Gen3Elevation = require("src.world.Gen3Elevation") local Runtime = require("src.mods.Runtime") +local NpcControls = require("src.world.NpcControls") +local Collision = require("src.world.Collision") +local Version = require("src.core.GameVersion") local Reads = require("src.mods.WorldReads") local WorldOverview = require("src.mods.WorldOverview") local MapOverview = require("src.world.MapOverview") @@ -401,6 +404,79 @@ function Handle:elevation() return self.npc.gen3Elevation end +-- New immediate controls retain the legacy handle and its existing methods. +-- A retained handle cannot apply these controls to a later native cohort. +local function npcCoordinate(v) + return type(v) == "number" and v == v and v >= 0 and v <= 1000000 and v % 1 == 0 +end +local function control(handle, name, a, b, c) + local context = handle._controlContext + local api, game, ow, actor = context[1], context[2], context[3], context[4] + if + not ow.map + or handle.ow ~= ow + or handle.npc ~= actor + or api.game ~= game + or game.data ~= context[5] + or game.save ~= context[6] + or Version.get() ~= context[8] + or api:overworld() ~= ow + or ow.map ~= context[7] + or ow.map.id ~= context[10] + or game.overworld ~= context[11] + or api._npcAlive and (not api._npcAlive() or Runtime.hooks ~= context[9]) + then + return nil, "NPC handle is stale" + end + local found = false + for _, candidate in ipairs(ow.npcs or {}) do + if candidate == actor then + found = true + break + end + end + if not found then + return nil, "NPC handle is stale" + end + if name == "stepNow" or name == "canStep" then + if not Collision.DELTA[a] then + return nil, "invalid direction" + end + end + if name == "placeAt" then + if not npcCoordinate(a) or not npcCoordinate(b) or c ~= nil and not Collision.DELTA[c] then + return nil, "invalid coordinates" + end + end + if name == "canStep" then + return NpcControls.canStep(actor, ow.map, ow.cast or ow.entities, a) + end + if name == "isMoving" then + return NpcControls.isMoving(actor) + end + if name == "setAppearance" then + return NpcControls.setAppearance(actor, game, a) + end + if name == "setPassable" then + return NpcControls.setPassable(actor, not not a) + end + return NpcControls[name](actor, a, b, c) +end +for _, method in ipairs({ "stepNow", "canStep", "placeAt", "isMoving", "setPassable", "setAppearance" }) do + local name = method + Handle[name] = function(self, ...) + local previous = Runtime.currentMod + Runtime.currentMod = nil + local result = { pcall(control, self, name, ...) } + Runtime.currentMod = previous + if not result[1] then + local ok, message = pcall(tostring, result[2]) + return nil, "NPC operation failed: " .. (ok and message or "unprintable error") + end + return result[2], result[3] + end +end + function WorldAPI:npc(mapId, indexOrName) local ow = self:overworld() if not ow then return nil, NO_OVERWORLD end @@ -408,7 +484,9 @@ function WorldAPI:npc(mapId, indexOrName) for _, npc in ipairs(ow.npcs or {}) do if npc.def.index == indexOrName or npc.def.name == indexOrName or npc.id == indexOrName then - return setmetatable({ ow = ow, npc = npc, id = npc.id }, Handle) + return setmetatable({ ow = ow, npc = npc, id = npc.id, + _controlContext = { self, self.game, ow, npc, self.game.data, self.game.save, + ow.map, Version.get(), Runtime.hooks, ow.map and ow.map.id, self.game.overworld } }, Handle) end end return nil, "no such object: " .. tostring(indexOrName) diff --git a/tests/engine/legacy_npc_controls.lua b/tests/engine/legacy_npc_controls.lua new file mode 100644 index 000000000..a70ed9da9 --- /dev/null +++ b/tests/engine/legacy_npc_controls.lua @@ -0,0 +1,134 @@ +local T = require("tests.harness") +love = require("tests.love_stub") +local Contract = require("tests.api3_contract.npcs") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local function fixture(version) + local f = Contract.fixture("tests/fixtures/legacy_npc_controls", "gen2", version, "legacy") + -- API 2 materializes its legacy world lazily; the fixture already owns a + -- constructed game by the time the exported lookup is called. + f.loader._game = function() + return f.live + end + f.legacy = f.loader + f.handle = assert(f.loader.exports.legacy.npc(f.home, "IMPORTED")) + return f +end +for _, version in ipairs({ "red", "gold", "firered" }) do + local f = fixture(version) + local h, actor = f.handle, f.imported + T.eq(h.ow, f.ow, "legacy world field remains the native world") + T.eq(h.npc, actor, "legacy actor field retains its original identity") + T.eq(h.id, actor.id, "legacy public identity is preserved") + T.eq(h:isMoving(), false, "new motion query reads native state") + T.eq(h:placeAt(1, 1, "right"), true, "immediate placement is available to API 2") + T.eq(actor.px, 16, "placement synchronizes native pixel coordinates") + T.eq(actor.py, 16, "placement synchronizes native pixel coordinates") + T.eq(h:canStep("right"), true, "query uses actual native map collision") + local queue = #f.ow.scriptMoves + T.eq(h:stepNow("right"), true, "immediate stepping starts native motion") + T.eq(#f.ow.scriptMoves, queue, "immediate step creates no script queue entry") + T.eq(actor.targetX, 2, "native target follows the requested direction") + T.eq(h:isMoving(), true, "motion query sees the started step") + T.eq(h:stepNow("down"), nil, "an active step cannot be overwritten") + for _ = 1, 16 do + actor:update(f.ow.map, f.ow.entities) + end + T.eq(actor.cellX, 2, "actual NPC updates complete the step") + T.eq(h:isMoving(), false, "actual NPC updates clear native motion") + T.eq(h:stepNow("right"), true, "another native step starts") + T.eq(h:placeAt(3, 4, "up"), true, "placement cancels an in-flight step") + T.eq(actor.targetX, nil, "placement clears the old native target") + T.eq(actor.progress, 0, "placement clears the old step phase") + T.eq(actor.moving, false, "placement clears the old movement flag") + T.eq(actor.facing, "up", "placement applies optional facing") + local peer = + f:make(f.home, { index = 2, name = "PEER", sprite = "PROBE", x = 4, y = 4, movement = "STAY", range = "DOWN" }) + f.ow.npcs[#f.ow.npcs + 1] = peer + f.ow.entities[#f.ow.entities + 1] = peer + T.eq(h:canStep("right"), false, "collision query sees a native peer") + local other = assert(f.legacy.exports.legacy.npc(f.home, "PEER")) + T.eq(other:setPassable(true), true, "API-2 passability control reaches the native actor") + T.eq(h:canStep("right"), true, "native collision honors the changed peer passability") + T.eq(other:setPassable(false), true, "passability can be restored") + T.eq(h:canStep("right"), false, "restored passability restores native blocking") + local def = actor.def + T.eq(h:setAppearance("ALT"), true, "appearance resolves through the native sprite path") + T.eq(actor.appearance, "ALT", "native appearance identity updates") + T.eq(actor.def.sprite, "ALT", "native actor definition uses the chosen sprite") + T.eq(def.sprite, "PROBE", "imported shared template is retained") + local changed = actor.def + T.eq(h:setAppearance("MISSING"), nil, "missing appearance is declined") + T.eq(actor.def, changed, "a declined appearance cannot half-change the actor") + T.eq(h:stepNow("north"), nil, "bad directions are declined") + T.eq(h:placeAt(0 / 0, 2), nil, "non-finite coordinates are declined") + T.eq(actor.cellX, 3, "invalid input leaves the native cell unchanged") + local x, y = h:position() + T.eq(x, 3, "existing position method remains available") + T.eq(y, 4, "existing position method retains its returns") + T.eq(h:face("left"), true, "existing facing method remains available") + T.eq(actor.facing, "left", "existing facing method retains its native effect") + Runtime.currentMod = "legacy" + T.eq(h:isMoving(), false, "new control is callable under mod attribution") + T.eq(Runtime.currentMod, "legacy", "new control restores caller attribution") + Runtime.currentMod = nil + f.game.save = {} + T.eq(h:placeAt(0, 0), nil, "retained new controls decline a replacement save") + T.eq(actor.cellX, 3, "stale control cannot change the previous native actor") + f.restoreRenderer() +end +for _, change in ipairs({ "rollback", "disabled", "map", "map_id", "fast_world", "removed", "version", "bus" }) do + local f = fixture("gold") + local h = f.handle + if change == "rollback" then + f.legacy:_rollback("legacy") + elseif change == "disabled" then + f.legacy.mods.legacy.enabled = false + elseif change == "map" then + f.ow.map = { id = "CHANGED" } + elseif change == "map_id" then + f.ow.map.id = "CHANGED" + elseif change == "fast_world" then + f.game.overworld = {} + elseif change == "removed" then + f.ow.npcs = {} + elseif change == "version" then + Version.set("silver") + else + Runtime.install(require("src.mods.Events").new(), require("src.mods.Hooks").new(), {}) + end + T.eq(h:setPassable(true), nil, change .. " retires new controls") + T.eq(f.imported.passable, nil, change .. " does not change the old native actor") + f.restoreRenderer() +end + +do + local f = fixture("gold") + local Renderer = require("src.render.SpriteRenderer") + local old = Renderer.new + local def, sprite = f.imported.def, f.imported.sprite + Renderer.new = function() + error("owned sprite failure") + end + Runtime.currentMod = "legacy" + local ok, why = f.handle:setAppearance("ALT") + T.eq(ok, nil, "native sprite failures are contained") + T.check(why:find("owned sprite failure", 1, true) ~= nil, "native sprite failure remains diagnosable") + T.eq(Runtime.currentMod, "legacy", "failed native control restores caller attribution") + T.eq(f.imported.def, def, "failed native sprite resolution retains the old definition") + T.eq(f.imported.sprite, sprite, "failed native sprite resolution retains the old renderer") + Runtime.currentMod = nil + Renderer.new = old + f.restoreRenderer() +end + +do + local f = fixture("gold") + f.ow.map = nil + local h = assert(f.legacy.exports.legacy.npc(f.home, "IMPORTED")) + local value, why = h:isMoving() + T.eq(value, nil, "legacy lookup without a map cannot grant new controls") + T.eq(why, "NPC handle is stale", "missing-map control has a stable refusal") + f.restoreRenderer() +end +T.finish() diff --git a/tests/fixtures/legacy_npc_controls/mods/legacy/main.lua b/tests/fixtures/legacy_npc_controls/mods/legacy/main.lua new file mode 100644 index 000000000..ee1762048 --- /dev/null +++ b/tests/fixtures/legacy_npc_controls/mods/legacy/main.lua @@ -0,0 +1,18 @@ +return function(mod) + mod.exports.npc = function(...) + return mod.world:npc(...) + end + mod.exports.sample = function(mapId, ref, appearance) + local h = assert(mod.world:npc(mapId, ref)) + assert(h:placeAt(1, 1, "right")) + local allowed = h:canStep("right") + assert(h:setPassable(true)) + assert(h:stepNow("right")) + local moving = h:isMoving() + assert(h:placeAt(3, 4, "up")) + assert(h:setPassable(false)) + assert(h:setAppearance(appearance)) + local x, y = h:position() + return { x = x, y = y, allowed = allowed, moving = moving, stopped = not h:isMoving() } + end +end diff --git a/tests/fixtures/legacy_npc_controls/mods/legacy/manifest.json b/tests/fixtures/legacy_npc_controls/mods/legacy/manifest.json new file mode 100644 index 000000000..ac5706ad2 --- /dev/null +++ b/tests/fixtures/legacy_npc_controls/mods/legacy/manifest.json @@ -0,0 +1 @@ +{"id":"legacy","name":"Legacy NPC controls fixture","version":"1.0.0","api":2,"entry":"main.lua","gen2compat":true,"games":["gen1","gen2","gen3"]} diff --git a/tests/fixtures/legacy_npc_controls/worker.lua b/tests/fixtures/legacy_npc_controls/worker.lua new file mode 100644 index 000000000..bd4bbcf1b --- /dev/null +++ b/tests/fixtures/legacy_npc_controls/worker.lua @@ -0,0 +1,16 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version = assert(arg[1]), assert(arg[2]), assert(arg[3]) +love = require("tests.love_stub") +local f = dofile(root .. "/tests/api3_contract/npcs.lua").fixture( + root .. "/tests/fixtures/legacy_npc_controls", + backend, + version, + "legacy" +) +f.loader._game = function() + return f.live +end +local result = f.exports.sample(f.home, "IMPORTED", "ALT") +assert(result.x == 3 and result.y == 4 and result.allowed and result.moving and result.stopped) +f.restoreRenderer() +print("LEGACY_NPC_RESULT " .. require("src.link.Json").encode(result)) diff --git a/tools/check_decoded_profiles.py b/tools/check_decoded_profiles.py index c97276dac..7af5f6f0e 100644 --- a/tools/check_decoded_profiles.py +++ b/tools/check_decoded_profiles.py @@ -13,6 +13,7 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--love', type=Path, required=True) parser.add_argument('--qa-tools', type=Path) + parser.add_argument('--npc-controls', action='store_true') parser.add_argument('--baseline', required=True, help='local pre-M5 Git commit') parser.add_argument('--report', type=Path, required=True) args = parser.parse_args() @@ -27,10 +28,17 @@ def main(): 'src/script/Gen2ScriptVM.lua', 'src/script/Gen3ScriptVM.lua', 'src/script/Commands.lua', 'src/mods/Loader.lua', 'src/mods/DecodedCallbacks.lua', 'tools/decoded-profile-check/main.lua', 'tools/decoded-profile-check/conf.lua') + if args.npc_controls: + files = ('src/world/WorldAPI.lua', 'src/world/NpcControls.lua', 'src/mods/Api3NPC.lua', + 'src/mods/Loader.lua', 'tools/decoded-profile-check/main.lua', + 'tools/decoded-profile-check/conf.lua', 'tools/decoded-profile-check/npcs.lua', + 'tests/fixtures/legacy_npc_controls/mods/legacy/main.lua', + 'tests/fixtures/legacy_npc_controls/mods/legacy/manifest.json') hashes = {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in files} before = qa_run(['git', 'rev-parse', '--verify', args.baseline + '^{commit}'], cwd=root, capture_output=True, text=True, check=True).stdout.strip() - blob = qa_run(['git', 'show', before + ':src/script/ScriptRunner.lua'], cwd=root, + baseline_path = 'src/world/WorldAPI.lua' if args.npc_controls else 'src/script/ScriptRunner.lua' + blob = qa_run(['git', 'show', before + ':' + baseline_path], cwd=root, capture_output=True, check=True).stdout baseline = args.report.parent / 'baseline-runner.lua' baseline.write_bytes(blob) @@ -39,16 +47,18 @@ def main(): result = qa_run([str(args.love.resolve()), str(root / 'tools/decoded-profile-check')], cwd=root, env=dict(os.environ, POKEPORT_IDENTITY=identity, DECODED_PROFILE_REPO=str(root), DECODED_PROFILE_QA_GUARDED='1', - DECODED_PROFILE_BASELINE=str(baseline.resolve())), + DECODED_PROFILE_BASELINE=str(baseline.resolve()), + NPC_CONTROLS_QA='1' if args.npc_controls else '0'), capture_output=True, text=True, timeout=90) args.report.with_suffix('.log').write_text(result.stdout + result.stderr) - records = [line[len('M5_NATIVE_RESULT '):] for line in result.stdout.splitlines() - if line.startswith('M5_NATIVE_RESULT ')] + prefix = 'M6_NATIVE_RESULT ' if args.npc_controls else 'M5_NATIVE_RESULT ' + records = [line[len(prefix):] for line in result.stdout.splitlines() if line.startswith(prefix)] if result.returncode or len(records) != 1: raise RuntimeError('native profile probe failed; see ' + str(args.report.with_suffix('.log'))) if any(hashlib.sha256((root / name).read_bytes()).hexdigest() != digest for name, digest in hashes.items()): raise RuntimeError('measured source changed during the probe') report = json.loads(records[0]) + report['baselinePath'] = baseline_path report.update(baseline=before, baselineRunnerSha256=hashlib.sha256(blob).hexdigest(), sha256=hashes, minimumFreeGiB=20, playerProfilesUsed=False) args.report.write_text(json.dumps(report, indent=2) + '\n') diff --git a/tools/check_legacy_npc_contract.py b/tools/check_legacy_npc_contract.py new file mode 100644 index 000000000..5e4f6b8a3 --- /dev/null +++ b/tools/check_legacy_npc_contract.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Compare the unchanged API-2 NPC consumer on both pinned native backends.""" +import argparse +import hashlib +import json +from pathlib import Path +import sys + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--reference', type=Path, required=True) + parser.add_argument('--lua', required=True) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(args.qa_tools or root/'tools')) + from qa_storage import qa_source, qa_cache, qa_run + reference = args.reference.resolve() + qa_source(root); qa_source(reference); qa_source(args.lua) + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True, exist_ok=True) + ref = qa_run(['git','rev-parse','HEAD'], cwd=reference, capture_output=True, text=True, check=True).stdout.strip() + status=qa_run(['git','status','--porcelain','--untracked-files=no'],cwd=reference, + capture_output=True,text=True,check=True).stdout + if status.strip(): raise RuntimeError('pinned reference has tracked modifications') + if ref != '535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91': + raise RuntimeError('reference does not match the pinned NPC contract') + files=root/'tests/fixtures/legacy_npc_controls/mods/legacy' + hashes={p.name:hashlib.sha256(p.read_bytes()).hexdigest()for p in files.iterdir() if p.is_file()} + worker = root/'tests/fixtures/legacy_npc_controls/worker.lua' + records=[] + for version in ('red','blue','yellow','gold','silver','crystal','firered','emerald'): + results={} + for backend, engine in [('gen1',reference),('gen2',root)]: + result=qa_run([args.lua,str(worker),str(root),backend,version],cwd=engine, + capture_output=True,text=True,timeout=60) + lines=[line[len('LEGACY_NPC_RESULT '):]for line in result.stdout.splitlines() + if line.startswith('LEGACY_NPC_RESULT ')] + if result.returncode or len(lines)!=1: + raise RuntimeError(f'{backend}/{version}:\n{result.stdout}{result.stderr}') + results[backend]=json.loads(lines[0]) + if results['gen1']!=results['gen2']: + raise RuntimeError(f'{version}: NPC contract differs: {results}') + records.append({'version':version,'result':results['gen2']}) + print('PASS '+version,flush=True) + if {p.name:hashlib.sha256(p.read_bytes()).hexdigest()for p in files.iterdir() if p.is_file()} != hashes: + raise RuntimeError('NPC consumer bytes changed during the comparison') + args.report.write_text(json.dumps({'reference':ref,'consumerSha256':hashes,'comparisons':records},indent=2)+'\n') + + +if __name__=='__main__': + main() diff --git a/tools/decoded-profile-check/main.lua b/tools/decoded-profile-check/main.lua index b4288d9a8..e73a148aa 100644 --- a/tools/decoded-profile-check/main.lua +++ b/tools/decoded-profile-check/main.lua @@ -6,6 +6,10 @@ function love.errorhandler(message) return 1 end end +if os.getenv('NPC_CONTROLS_QA') == '1' then + require('tools.decoded-profile-check.npcs') + return +end local Version = require("src.core.GameVersion") Version.set("firered") local Native = require("src.script.ScriptRunner") diff --git a/tools/decoded-profile-check/npcs.lua b/tools/decoded-profile-check/npcs.lua new file mode 100644 index 000000000..f667c0137 --- /dev/null +++ b/tools/decoded-profile-check/npcs.lua @@ -0,0 +1,88 @@ +-- Owned native CPU workload. Sprite construction is a deterministic seam; +-- map collision and actor updates use the production engine. +local Contract = require("tests.api3_contract.npcs") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local Native = require("src.world.WorldAPI") +local file = assert(io.open(assert(os.getenv("DECODED_PROFILE_BASELINE")), "rb")) +local Original = assert(loadstring(file:read("*a"), "@baseline/WorldAPI.lua"))() +file:close() +local phase, frame, fixture, total = 1, 0, nil, 0 +local results, WINDOW, WARMUP = {}, 120, 120 +local function start() + Profile.setEnabled(false) + package.loaded["src.world.WorldAPI"] = phase == 1 and Original or Native + fixture = Contract.fixture("tests/fixtures/legacy_npc_controls", "gen2", "gold", "legacy") + fixture.loader._game = function() + return fixture.live + end + -- Bind the actual API-2 loader facade before restoring the global module. + assert(fixture.exports.npc(fixture.home, "IMPORTED")) + package.loaded["src.world.WorldAPI"] = Native + frame, total = 0, 0 +end +function love.run() + start() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + Profile.setEnabled(true) + end + local label = phase == 3 and "npc.controls + native updates (64)" or "npc.lookup + legacy calls (64)" + local close = Profile.section(label) + local began = love.timer.getTime() + for _ = 1, 64 do + local h = assert(fixture.exports.npc(fixture.home, "IMPORTED")) + if phase == 3 then + assert(h:placeAt(1, 1, "right")) + assert(h:canStep("right")) + assert(h:setPassable(true)) + assert(h:stepNow("right")) + assert(h:isMoving()) + for _ = 1, 16 do + fixture.imported:update(fixture.ow.map, fixture.ow.entities) + end + assert(not h:isMoving()) + assert(h:setAppearance("ALT")) + else + h:position() + h:face("left") + end + end + if frame >= WARMUP then + total = total + love.timer.getTime() - began + end + close() + love.graphics.clear(0.03, 0.04, 0.06) + love.graphics.present() + if frame >= WARMUP then + Profile.frame() + end + frame = frame + 1 + if frame == WINDOW + WARMUP then + results[phase] = { + mode = ({ "before", "native", "controls" })[phase], + frames = WINDOW, + operationMeanMs = total / WINDOW * 1000, + } + fixture.restoreRenderer() + if phase == 3 then + print("M6_NATIVE_RESULT " .. Json.encode({ + phases = results, + renderer = love.graphics.getRendererInfo(), + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + scope = "Actual LÖVE host, production API-2 loader, native Map collision and NPC updates over owned data; deterministic sprite construction, no rendered artwork or cartridge scene", + })) + return 0 + end + phase = phase + 1 + start() + end + end +end From 5d6e1e63c86117f4ed551c57de8029722cca6a93 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:00:01 +0100 Subject: [PATCH 099/138] Allow the NPC fixture driver to load the legacy API consumer --- tests/api3_contract/npcs.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/api3_contract/npcs.lua b/tests/api3_contract/npcs.lua index 7ee2a243f..1e3170249 100644 --- a/tests/api3_contract/npcs.lua +++ b/tests/api3_contract/npcs.lua @@ -1,9 +1,9 @@ -- Real loaders/native public adapters with native actor/map/timing seams. local M = {} local unpack = table.unpack or unpack -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, modId) local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/npcs%.lua$") - local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "npcs") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, modId or "npcs") local Runtime = require("src.mods.Runtime") love = love or require("tests.love_stub") -- Render construction has no bearing on the public state contract. From db524f3e7c733b855e2cf25f9f89f2030a1f7104 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:33:35 +0100 Subject: [PATCH 100/138] compat(M7): align Gen2 registry descriptors and isolate native limits --- docs/gen2-registry-contracts.md | 30 +++++++++++++++ src/mods/ApricornContent.lua | 4 ++ src/mods/DecorationContent.lua | 1 + src/mods/LandmarkContent.lua | 1 + src/mods/Schemas.lua | 8 ++-- tests/engine/gen2_apricorn_content.lua | 1 + tools/check_gen2_registry_contract.py | 49 +++++++++++++++++++++++++ tools/gen2-registry-contract/fields.lua | 18 +++++++++ 8 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 docs/gen2-registry-contracts.md create mode 100644 tools/check_gen2_registry_contract.py create mode 100644 tools/gen2-registry-contract/fields.lua diff --git a/docs/gen2-registry-contracts.md b/docs/gen2-registry-contracts.md new file mode 100644 index 000000000..435b17f30 --- /dev/null +++ b/docs/gen2-registry-contracts.md @@ -0,0 +1,30 @@ +# Gen2 registry contracts + +The `apricorns`, `decorations`, `landmarks`, `phone_contacts` and +`radio_channels` record descriptors match the pinned `535a5ec` Gen2 contract. +Their native adapters, imported built-ins, merge ownership and rollback already +exist; this change aligns the remaining descriptor bounds without replacing +those implementations. Gen3 decoration and landmark adapters are outside this +Gen2 contract. + +Record validation and native execution have separate constraints. Native phone +indices remain 1..255 with original rows and holes reserved; landmark indices +remain bytes. Native apricorn event operands and decoration ownership events +remain 0..65534; 65535 has a variable-operand meaning in native scripts. +Apricorn ordering indices remain 1..65535. The adapters reject these unsupported +values before recording the contribution, so errors remain attributable and +earlier writes by the same failed owner roll back. Finite-value, reference, +collision and canonical-ID checks remain in place. + +Landmark coordinates retain their existing native OAM units. This change does +not reinterpret previously authored records. Public record fields, merge +semantics and existing valid registrations keep their behavior. + +Run `tools/check_gen2_registry_contract.py --reference +--lua --report /fields.json` to compare the complete Gen2 +field descriptors, including optionality, bounds and referenced registries. +The same owned inspector runs unchanged in both checkouts. Native content +suites separately exercise the real Loader, merge targets and consumers. + +This is load-time validation. It adds no per-frame operation or performance +flag. ARM performance has not been measured. diff --git a/src/mods/ApricornContent.lua b/src/mods/ApricornContent.lua index bd55a63be..2146365c6 100644 --- a/src/mods/ApricornContent.lua +++ b/src/mods/ApricornContent.lua @@ -41,6 +41,10 @@ local function row(loader,id,value,defer) for k in pairs(value)do assert(registry.spec.fields[k],'apricorns: unknown field '..tostring(k))end local ok,why=require('src.mods.Schemas').check(registry.spec,'apricorns',id,value,'register') assert(ok,why) + -- The public schema describes records; native script operands have a + -- narrower space (0xffff selects the halfword variable in setevent). + assert(value.event<=65534,'apricorns: native event must be 0..65534') + assert(value.index<=65535,'apricorns: native recipe index must be 1..65535') local function resolve(id) if not defer then return nativeItem(loader,id) end local found,key=pcall(nativeItem,loader,id) diff --git a/src/mods/DecorationContent.lua b/src/mods/DecorationContent.lua index e6d1ddd16..059e0f790 100644 --- a/src/mods/DecorationContent.lua +++ b/src/mods/DecorationContent.lua @@ -33,6 +33,7 @@ local function record(registry,id,value) assert(type(value)=='table'and value~=Merge.DELETE,'decorations: expected record') for key in pairs(value)do assert(registry.spec.fields[key],'decorations: unknown field '..tostring(key))end local ok,why=Schemas.check(registry.spec,'decorations',id,value,'register');assert(ok,why) + assert(value.flag<=65534,'decorations: native ownership event must be 0..65534') assert(value.action==nil or Native.action(value.action),'decorations: unknown native action') return copy(value) end diff --git a/src/mods/LandmarkContent.lua b/src/mods/LandmarkContent.lua index 1b45322bb..eb18aa634 100644 --- a/src/mods/LandmarkContent.lua +++ b/src/mods/LandmarkContent.lua @@ -30,6 +30,7 @@ local function record(registry,id,value) finite(value) for key in pairs(value)do assert(registry.spec.fields[key],'landmarks: unknown field '..tostring(key))end local ok,why=Schemas.check(registry.spec,'landmarks',id,value,'register');assert(ok,why) + assert(index(value.index),'landmarks: native index must be a byte') return copy(value) end function M.prepare(loader,data) diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index d79a49459..f5387d823 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -1260,7 +1260,7 @@ R.text_pointers = { R.decorations = { semantics="record",generations="2", - fields={type=f.int(1,6),name=f.str,action=f.opt(f.str),flag=f.int(0,65534),sprite=f.int(0,255)}, + fields={type=f.int(1,6),name=f.str,action=f.opt(f.str),flag=f.int(0),sprite=f.int(0,255)}, example='mod.content.decorations:patch("deco:2", {name="CUSTOM"})', notes="Native imported GSC DECO bytes (deco:0..255), ownership event bits, room callbacks and house-PC selection. Action must be a known SET_UP_/PUT_AWAY_ native decoration action. No retail attribute catalogue is bundled. Requires a complete imported native attribute table.", } @@ -1270,7 +1270,7 @@ R.decorations = { R.phone_contacts = { semantics = "record", generations = "2", fields = { - index=f.opt(f.int(1,255)), number=f.opt(f.int(0,255)), + index=f.opt(f.int(0,255)), number=f.opt(f.int(0,255)), name=f.opt(f.str), class=f.opt(f.str), member=f.opt(f.str), map=f.opt(f.id("maps")), rematchEvent=f.opt(f.int(0,2047)), calleeTime=f.opt(f.int(0,7)), callerTime=f.opt(f.int(0,7)), @@ -1282,14 +1282,14 @@ R.phone_contacts = { R.apricorns = { semantics="record", generations="2", - fields={apricorn=f.id("items"),ball=f.id("items"),event=f.int(0,65534),index=f.int(1,65535)}, + fields={apricorn=f.id("items"),ball=f.id("items"),event=f.int(0),index=f.int(1)}, example='mod.content.apricorns:patch("RED_APRICORN", {ball="ULTRA_BALL"})', notes="Native Gen2 recipe records bind real Kurt item selection, event flags and ball delivery. A current native Kurt program import and indexed GSC item IDs are required. New recipes preserve the selected native item byte; generated native branches retain original dialogue/day flags/refusal behavior.", } R.landmarks = { semantics="record",generations="2", - fields={id=f.opt(f.str),name=f.str,x=f.int(0),y=f.int(0),index=f.int(0,255)}, + fields={id=f.opt(f.str),name=f.str,x=f.int(0),y=f.int(0),index=f.int(0)}, example='mod.content.landmarks:patch("LANDMARK_ROUTE_29", {x=80})', notes="Native Gen2 town-map records and script landmark names. Coordinates retain native OAM byte units, not tile cells. The index is the map-header byte. Native identities win index collisions; otherwise lexical ID order is deterministic. Imported identity metadata is independent of display names.", } diff --git a/tests/engine/gen2_apricorn_content.lua b/tests/engine/gen2_apricorn_content.lua index c780450a4..687ad29c8 100644 --- a/tests/engine/gen2_apricorn_content.lua +++ b/tests/engine/gen2_apricorn_content.lua @@ -151,6 +151,7 @@ end V.set('crystal') for _,bad in ipairs({ "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=65535,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=5000,index=65536})", "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=-1,index=8})", "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=0/0,index=8})", "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=5000,index=math.huge})", diff --git a/tools/check_gen2_registry_contract.py b/tools/check_gen2_registry_contract.py new file mode 100644 index 000000000..1dd52e393 --- /dev/null +++ b/tools/check_gen2_registry_contract.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Compare the five public Gen2 registry descriptors with a pinned backend.""" +import argparse +import hashlib +import json +from pathlib import Path +import sys + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--reference', type=Path, required=True) + parser.add_argument('--lua', required=True) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + args=parser.parse_args() + root=Path(__file__).resolve().parents[1] + sys.path.insert(0,str(args.qa_tools or root/'tools')) + from qa_storage import qa_source, qa_cache, qa_run + reference=args.reference.resolve() + qa_source(root); qa_source(reference); qa_source(args.lua) + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True,exist_ok=True) + ref=qa_run(['git','rev-parse','HEAD'],cwd=reference,capture_output=True,text=True,check=True).stdout.strip() + status=qa_run(['git','status','--porcelain','--untracked-files=no'],cwd=reference, + capture_output=True,text=True,check=True).stdout + if status.strip() or ref!='535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91': + raise RuntimeError('reference must be the clean pinned contract') + worker=root/'tools/gen2-registry-contract/fields.lua' + digest=hashlib.sha256(worker.read_bytes()).hexdigest() + results=[] + for engine in (reference,root): + run=qa_run([args.lua,str(worker)],cwd=engine,capture_output=True,text=True,timeout=60) + records=[line.removeprefix('REGISTRY_CONTRACT ') for line in run.stdout.splitlines() + if line.startswith('REGISTRY_CONTRACT ')] + if run.returncode or len(records)!=1: + raise RuntimeError(run.stdout+run.stderr) + results.append(json.loads(records[0])) + if results[0]!=results[1]: + raise RuntimeError('public registry descriptors differ: '+json.dumps(results,sort_keys=True)) + if hashlib.sha256(worker.read_bytes()).hexdigest()!=digest: + raise RuntimeError('contract consumer changed during the comparison') + args.report.write_text(json.dumps({'reference':ref,'consumerSha256':digest, + 'registries':results[1]},indent=2)+'\n') + print('PASS: five identical Gen2 registry descriptors') + + +if __name__=='__main__': + main() diff --git a/tools/gen2-registry-contract/fields.lua b/tools/gen2-registry-contract/fields.lua new file mode 100644 index 000000000..025db5579 --- /dev/null +++ b/tools/gen2-registry-contract/fields.lua @@ -0,0 +1,18 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Inspect public Gen2 descriptors without executing another engine's bodies. +package.path='./?.lua;./?/init.lua;'..package.path +local Schemas=require('src.mods.Schemas') +local Json=require('src.link.Json') +local function describe(field) + local out={kind=field.kind,min=field.min,max=field.max,registry=field.registry} + if field.inner then out.inner=describe(field.inner)end + return out +end +local out={} +for _,name in ipairs({'apricorns','decorations','landmarks','phone_contacts','radio_channels'})do + local spec=assert(Schemas.REGISTRIES[name],name) + local entry={semantics=spec.semantics,fields={}} + for key,field in pairs(spec.fields)do entry.fields[key]=describe(field)end + out[name]=entry +end +print('REGISTRY_CONTRACT '..Json.encode(out)) From 19316eb6c5c368f0e57a36da8d5c84da6b25ca29 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:35:43 +0100 Subject: [PATCH 101/138] Measure registry validation with a guarded native FrameProfile probe --- tools/check_gen2_registry_contract.py | 30 +++++++++++++-- tools/gen2-registry-contract/conf.lua | 7 ++++ tools/gen2-registry-contract/main.lua | 55 +++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 tools/gen2-registry-contract/conf.lua create mode 100644 tools/gen2-registry-contract/main.lua diff --git a/tools/check_gen2_registry_contract.py b/tools/check_gen2_registry_contract.py index 1dd52e393..a20dee711 100644 --- a/tools/check_gen2_registry_contract.py +++ b/tools/check_gen2_registry_contract.py @@ -3,8 +3,10 @@ import argparse import hashlib import json +import os from pathlib import Path import sys +import time def main(): @@ -13,12 +15,17 @@ def main(): parser.add_argument('--lua', required=True) parser.add_argument('--report', type=Path, required=True) parser.add_argument('--qa-tools', type=Path) + parser.add_argument('--love', type=Path) + parser.add_argument('--baseline', help='local commit for the optional native validation profile') args=parser.parse_args() root=Path(__file__).resolve().parents[1] sys.path.insert(0,str(args.qa_tools or root/'tools')) - from qa_storage import qa_source, qa_cache, qa_run + from qa_storage import qa_source, qa_cache, qa_profile, qa_run reference=args.reference.resolve() qa_source(root); qa_source(reference); qa_source(args.lua) + if args.love: + if not args.baseline: parser.error('--love requires --baseline') + qa_source(args.love.resolve()) qa_cache(args.report.parent) args.report.parent.mkdir(parents=True,exist_ok=True) ref=qa_run(['git','rev-parse','HEAD'],cwd=reference,capture_output=True,text=True,check=True).stdout.strip() @@ -40,8 +47,25 @@ def main(): raise RuntimeError('public registry descriptors differ: '+json.dumps(results,sort_keys=True)) if hashlib.sha256(worker.read_bytes()).hexdigest()!=digest: raise RuntimeError('contract consumer changed during the comparison') - args.report.write_text(json.dumps({'reference':ref,'consumerSha256':digest, - 'registries':results[1]},indent=2)+'\n') + report={'reference':ref,'consumerSha256':digest,'registries':results[1]} + if args.love: + baseline=qa_run(['git','show',args.baseline+':src/mods/Schemas.lua'],cwd=root, + capture_output=True,check=True).stdout + path=args.report.parent/'baseline-schemas.lua' + path.write_bytes(baseline) + identity='oasc-gen2-registry-qa-'+str(time.time_ns()) + qa_profile(Path.home()/'Library/Application Support/LOVE'/identity) + run=qa_run([str(args.love.resolve()),str(root/'tools/gen2-registry-contract')],cwd=root, + env=dict(os.environ,GEN2_REGISTRY_QA='1',GEN2_REGISTRY_ROOT=str(root), + GEN2_REGISTRY_BASELINE=str(path.resolve()),POKEPORT_IDENTITY=identity), + capture_output=True,text=True,timeout=60) + args.report.with_suffix('.log').write_text(run.stdout+run.stderr) + profiles=[line.removeprefix('REGISTRY_PROFILE ')for line in run.stdout.splitlines() + if line.startswith('REGISTRY_PROFILE ')] + if run.returncode or len(profiles)!=1:raise RuntimeError('native registry probe failed') + report['profile']=json.loads(profiles[0]) + report['baseline']=args.baseline + args.report.write_text(json.dumps(report,indent=2)+'\n') print('PASS: five identical Gen2 registry descriptors') diff --git a/tools/gen2-registry-contract/conf.lua b/tools/gen2-registry-contract/conf.lua new file mode 100644 index 000000000..a414765a8 --- /dev/null +++ b/tools/gen2-registry-contract/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv('GEN2_REGISTRY_QA')=='1','use the guarded registry checker') + t.identity=assert(os.getenv('POKEPORT_IDENTITY')) + t.window.title='Gen2 registry validation profile' + t.window.width,t.window.height,t.window.vsync=480,240,1 + t.modules.audio=false +end diff --git a/tools/gen2-registry-contract/main.lua b/tools/gen2-registry-contract/main.lua new file mode 100644 index 000000000..4a52777ac --- /dev/null +++ b/tools/gen2-registry-contract/main.lua @@ -0,0 +1,55 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local root=assert(os.getenv('GEN2_REGISTRY_ROOT')) +package.path=root..'/?.lua;'..root..'/?/init.lua;'..package.path +local Profile=require('src.core.FrameProfile') +local Json=require('src.link.Json') +local file=assert(io.open(assert(os.getenv('GEN2_REGISTRY_BASELINE')),'rb')) +local before=assert(loadstring(file:read('*a'),'@baseline/Schemas.lua'))() +file:close() +local after=require('src.mods.Schemas') +local rows={ + apricorns={apricorn='FRUIT',ball='BALL',event=600,index=1}, + decorations={type=2,name='BED',action='SET_UP_BED',flag=500,sprite=0}, + landmarks={name='HOME',x=80,y=72,index=1}, + phone_contacts={index=2,name='CALLER',number=2,calleeTime=0,callerTime=0}, + radio_channels={channel=9,name='STATION'}, +} +local phase,frame,total=1,0,0 +local results={} +function love.errorhandler(message) + print(debug.traceback(tostring(message),2)) + return function()return 1 end +end +function love.run() + return function() + love.event.pump() + for name in love.event.poll()do if name=='quit'then return 1 end end + if frame==120 then Profile.setEnabled(true)end + local schema=phase==1 and before or after + local close=Profile.section('mods.registry.validate (320 records)') + local began=love.timer.getTime() + for _=1,64 do + for name,row in pairs(rows)do + assert(schema.check(schema.REGISTRIES[name],name,'QA',row,'register')) + end + end + if frame>=120 then total=total+love.timer.getTime()-began end + close() + love.graphics.clear(.03,.04,.06) + love.graphics.print('Gen2 registry validation: '..(phase==1 and 'before' or 'after'),12,12) + love.graphics.present() + if frame>=120 then Profile.frame()end + frame=frame+1 + if frame==240 then + results[phase]={mode=phase==1 and 'before' or 'after',frames=120,meanMs=total/120*1000} + if phase==2 then + print('REGISTRY_PROFILE '..Json.encode({phases=results,renderer=love.graphics.getRendererInfo(), + os=love.system.getOS(),arch=jit.arch, + scope='Owned records, native Love host and FrameProfile section; schema validation only, no gameplay scene'})) + return 0 + end + Profile.setEnabled(false) + phase,frame,total=2,0,0 + end + end +end From 6c9be52eedb07ed80e2265cc9b32780649ff5415 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:39:00 +0100 Subject: [PATCH 102/138] Share native GSC Unown form registration and imported data with external consumers --- docs/native-gen2-unown.md | 42 +++++++++++ src/battle/BattleState.lua | 5 +- src/compat/gen2/Unown.lua | 19 +++++ src/core/ModCompat.lua | 1 + src/pokemon/Gen2Unown.lua | 125 ++++++++++++++++++++++++++++++ src/pokemon/Sprites.lua | 10 +-- src/script/Commands.lua | 2 + src/script/Gen2Commands.lua | 6 ++ src/ui/PokedexUnownMode.lua | 2 + tests/engine/gen2_unown.lua | 146 ++++++++++++++++++++++++++++++++++++ 10 files changed, 350 insertions(+), 8 deletions(-) create mode 100644 docs/native-gen2-unown.md create mode 100644 src/compat/gen2/Unown.lua create mode 100644 src/pokemon/Gen2Unown.lua create mode 100644 tests/engine/gen2_unown.lua diff --git a/docs/native-gen2-unown.md b/docs/native-gen2-unown.md new file mode 100644 index 000000000..2706cc7c2 --- /dev/null +++ b/docs/native-gen2-unown.md @@ -0,0 +1,42 @@ +# Native GSC Unown state + +`src.pokemon.Gen2Unown` shares native Gold/Silver/Crystal form identity and +catch-order state between sprite selection, battle captures, script gifts, +VAR_UNOWNCOUNT, the native Unown dex and public consumers. Stored reference +letter overrides and DV-derived forms use the same sprite/counter decision. +The byte calculation follows the existing native GetUnownLetter arithmetic. + +`src.core.gen2.Unown` is an explicit adapter to this service. `word(letter)` +reads the active native `Game.data.unown_dex.words`; an explicit native dataset +may be supplied as a second argument. No retail word catalogue or picture +paths are embedded in the adapter. `forms(pokemon)` maps A–Z names to the +actual numeric native form records; `formSprite` resolves the same records. +Reads follow the current dataset instead of retaining an old edition's data. + +The native save remains `save.pokedex.unownDex` and `unownForms`. Public `dex` +reads return a detached list. Known ordered forms keep their order; missing +valid entries from an older set-only save are appended alphabetically. Reads +do not migrate saves. A new form writes the normalized ordered list and set; +no separate `save.unownDex` is created. New party and PC captures and script +gifts register only after successful transfer. Eggs and refused transfers do +not register a form. + +`unown.unlocked` reports a newly recorded form once, after native state is +committed. Payload fields are `letter`, `name`, imported `word` and `count`. +Native API1/2 consumers use this same producer. Repeated catches, +reentrant duplicate writes and another species do not fire the event. + +## Verification and limits + +ROM-free coverage includes all 65,536 packed DV byte combinations, native +sprite and stored-letter selection, actual party/PC capture and gift paths, +old save reads, refused full storage, native counter/menu agreement, imported +word replacement, actual Loader consumers and event rollback boundaries. +Real-ROM checks are separate private receipts; ROM data is never committed. + +This does not establish the complete reference Unown API: unlocked-set/wild +DV selection, puzzle/encounter gates, direct mutation of reference-shaped view +tables and the remaining legacy Pokédex screen protocol need further work. +Polished Crystal's 28 forms and other editions retain their previous native +catch paths. Full Unown story/GPU/hardware and complete VASC/Gen1 parity are +not claimed. VASC files and protected Gen1/player instances are unchanged. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 297e84c74..38b8b2ac5 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -9940,7 +9940,8 @@ function BattleState:storeCaughtMon() -- Ruins of Alph researcher and the dex's UNOWN mode gate on local form = require("src.pokemon.Sprites").formIndex( game.data.pokemon[species], self.enemy.mon) - if dex and type(form)=='number' then + local nativeUnown = require("src.pokemon.Gen2Unown") + if not nativeUnown.supported() and dex and type(form)=='number' then dex.unownForms = dex.unownForms or {} if not dex.unownForms[form] then dex.unownForms[form] = true @@ -9984,12 +9985,14 @@ function BattleState:storeCaughtMon() self.enemy.mon.ball = self.lastBall end if Party.add(game.save.party, self.enemy.mon) then + if nativeUnown.supported() then nativeUnown.registerCatch(game.save, self.enemy.mon, game.data) end require('src.pokemon.Gen4PoketchState').remember(game, self.enemy.mon) askCaughtNickname() else destination = "box" local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon) if boxNum then + if nativeUnown.supported() then nativeUnown.registerCatch(game.save, self.enemy.mon, game.data) end require('src.pokemon.Gen4PoketchState').remember(game, self.enemy.mon) askCaughtNickname() -- _ItemUseBallText07/08 keyed on EVENT_MET_BILL diff --git a/src/compat/gen2/Unown.lua b/src/compat/gen2/Unown.lua new file mode 100644 index 000000000..c69e3e0af --- /dev/null +++ b/src/compat/gen2/Unown.lua @@ -0,0 +1,19 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Reference spellings over the native imported records and native save. +local Native = require("src.pokemon.Gen2Unown") +local M = {NUM_UNOWN=Native.NUM_UNOWN, SPECIES="UNOWN", ALPHABET=Native.ALPHABET, + name=Native.name, index=Native.index, letterFromDVs=Native.letterFromDVs, + dvsForLetter=Native.dvsForLetter, monLetter=Native.monLetter, + word=Native.word, formSprite=Native.formSprite, + dex=Native.letters, caught=Native.caught, count=Native.count, + updateDex=Native.updateDex, registerCatch=Native.registerCatch} +-- Reference consumers index forms by A..Z; native storage indexes by number. +-- The public name map is derived on demand; picture records remain native. +function M.forms(pokemon) + local source = Native.forms(pokemon) + if not source then return nil end + local out = {} + for i = 1, 26 do if source[i] then out[Native.name(i)] = source[i] end end + return out +end +return M diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..0ac1b1c54 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -67,6 +67,7 @@ ModCompat.ALIASES = { -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", + ["src.core.gen2.Unown"] = "src.compat.gen2.Unown", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", diff --git a/src/pokemon/Gen2Unown.lua b/src/pokemon/Gen2Unown.lua new file mode 100644 index 000000000..48d6d55cc --- /dev/null +++ b/src/pokemon/Gen2Unown.lua @@ -0,0 +1,125 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Shared native GSC form identity and catch-order state. Words and pictures +-- remain in the imported dataset, never in a second cartridge catalogue. +local M = {NUM_UNOWN = 26, ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"} +function M.supported() + local version = require("src.core.GameVersion").get() + return version == "gold" or version == "silver" or version == "crystal" +end +function M.index(letter) + if type(letter) == "string" and #letter == 1 then + return M.ALPHABET:find(letter:upper(), 1, true) + end + if type(letter) == "number" and letter % 1 == 0 and letter >= 1 and letter <= 26 then + return letter + end +end +function M.name(letter) + local index = M.index(letter) + return index and M.ALPHABET:sub(index, index) or nil +end +local function middle(dv) + dv = dv or 0 + if type(dv) ~= "number" or dv ~= dv or dv < 0 or dv > 15 then return nil end + return math.floor(dv / 2) % 4 +end +function M.letterFromDVs(dvs) + if type(dvs) ~= "table" then return nil end + local a,b,c,d = middle(dvs.attack), middle(dvs.defense), middle(dvs.speed), middle(dvs.special) + if not (a and b and c and d) then return nil end + return math.floor((a*64+b*16+c*4+d) / 10) + 1 +end +function M.dvsForLetter(letter) + local index = M.index(letter) + if not index then return nil end + local packed = (index - 1) * 10 + return {attack = math.floor(packed / 64) % 4 * 2, + defense = math.floor(packed / 16) % 4 * 2, + speed = math.floor(packed / 4) % 4 * 2, special = packed % 4 * 2} +end +function M.liveData(data) + if type(data) == "table" then return data end + local game = package.loaded["src.core.Game"] + return type(game) == "table" and type(game.data) == "table" and game.data or nil +end +function M.definition(data) + data = M.liveData(data) + if not (data and type(data.pokemon) == "table") then return nil end + local known = data.pokemon.UNOWN + if type(known) == "table" then return known end + for _, def in pairs(data.pokemon) do + if type(def) == "table" and (def.translationSourceName or def.name) == "UNOWN" then + return def + end + end +end +function M.word(letter, data) + local index = M.index(letter) + data = M.liveData(data) + local record = data and data.unown_dex + local words = type(record) == "table" and record.words + local word = index and type(words) == "table" and words[index] + return type(word) == "string" and word or nil +end +function M.forms(pokemon) + local def = M.definition({pokemon = pokemon}) + return def and def.forms or nil +end +function M.formSprite(pokemon, letter, back) + local index, def = M.index(letter), M.definition({pokemon = pokemon}) + if not (index and def) then return nil end + local form = def.forms and def.forms[index] + if back then return form and form.spriteBack or def.spriteBack end + return form and form.spriteFront or def.spriteFront +end +function M.monLetter(mon, data) + if type(mon) ~= "table" or mon.isEgg then return nil end + data = M.liveData(data) + local def = data and data.pokemon and data.pokemon[mon.species] + if def then + if def ~= M.definition(data) then return nil end + elseif mon.species ~= "UNOWN" then return nil end + return M.index(mon.unownLetter) or M.letterFromDVs(mon.dvs) +end +function M.letters(save) + local dex = type(save) == "table" and save.pokedex + if type(dex) ~= "table" then return {} end + local out, seen = {}, {} + local function add(letter) + local index = M.index(letter) + if index and not seen[index] then seen[index] = true; out[#out + 1] = index end + end + for _, letter in ipairs(type(dex.unownDex) == "table" and dex.unownDex or {}) do add(letter) end + local forms = type(dex.unownForms) == "table" and dex.unownForms or {} + for i = 1, 26 do if forms[i] then add(i) end end + return out +end +function M.count(save) return #M.letters(save) end +function M.caught(save, letter) + local index = M.index(letter) + if not index then return false end + for _, present in ipairs(M.letters(save)) do if present == index then return true end end + return false +end +function M.updateDex(save, letter, data) + local index = M.index(letter) + if not (M.supported() and type(save) == "table" and index) or M.caught(save, index) then return false end + if save.pokedex ~= nil and type(save.pokedex) ~= "table" then return false end + if save.pokedex and save.pokedex.unownForms ~= nil and type(save.pokedex.unownForms) ~= "table" then return false end + local list = M.letters(save) + list[#list + 1] = index + save.pokedex = save.pokedex or {} + local dex = save.pokedex + dex.unownDex, dex.unownForms = list, dex.unownForms or {} + for _, value in ipairs(list) do dex.unownForms[value] = true end + local Runtime = require("src.mods.Runtime") + if Runtime.wants("unown.unlocked") then + Runtime.emit("unown.unlocked", {letter=index, name=M.name(index), word=M.word(index, data), count=#list}) + end + return true +end +function M.registerCatch(save, mon, data) + local letter = M.monLetter(mon, data) + return letter and M.updateDex(save, letter, data) or false +end +return M diff --git a/src/pokemon/Sprites.lua b/src/pokemon/Sprites.lua index 245e67151..9c52bea60 100644 --- a/src/pokemon/Sprites.lua +++ b/src/pokemon/Sprites.lua @@ -14,9 +14,6 @@ local Sprites = {} local function samePath(path) return path end --- (dv & 6) >> 1 -- the middle two bits GetUnownLetter (20:$5749) keeps -local function mid2(dv) return math.floor((dv or 0) / 2) % 4 end - -- GetUnownLetter packs the middle two bits of the atk/def/spd/spc DVs into -- one byte and divides by 10, giving letter 1..26. A species record only -- carries `forms` when the ROM has per-form pics (UNOWN). @@ -28,10 +25,9 @@ function Sprites.formIndex(def, mon) return mon.form end local dvs = mon.dvs - if type(dvs) ~= "table" then return nil end - local packed = mid2(dvs.attack) * 64 + mid2(dvs.defense) * 16 - + mid2(dvs.speed) * 4 + mid2(dvs.special) - local index = math.floor(packed / 10) + 1 + local Unown = require("src.pokemon.Gen2Unown") + local index = (Unown.supported() and Unown.index(mon.unownLetter)) or Unown.letterFromDVs(dvs) + if not index then return nil end if not forms[index] then return nil end return index end diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 53cef91fa..adfc5e7ce 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -1333,6 +1333,8 @@ function Commands.give_pokemon(ctx, species, level, skipNickname, opts) dex.seen[species] = true dex.owned[species] = true end + local Unown = require("src.pokemon.Gen2Unown") + if Unown.supported() then Unown.registerCatch(ctx.save, mon, ctx.game.data) end require('src.pokemon.Gen4Forms').record(ctx.game,species,mon) ctx.lastCheck = true ctx.addedToParty = addedToParty diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..53eb79977 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2666,6 +2666,12 @@ function Commands.g2_readvar(ctx, var) -- sampled by `special SampleKenjiBreakCountdown` ctx.g2Var = ctx.save.g2KenjiBreak or 0 elseif var == 14 then + local Unown = require("src.pokemon.Gen2Unown") + if Unown.supported() then + ctx.g2Var = Unown.count(ctx.save) + ctx.lastCheck = ctx.g2Var ~= 0 + return + end -- UnownCaught (engine/events/unown_walls.asm): how many distinct UNOWN -- letters are in the dex. The port has no per-letter model, so a caught -- UNOWN counts once -- enough for the Ruins researcher's `ifgreater 2` diff --git a/src/ui/PokedexUnownMode.lua b/src/ui/PokedexUnownMode.lua index d5907e743..0906e2f59 100644 --- a/src/ui/PokedexUnownMode.lua +++ b/src/ui/PokedexUnownMode.lua @@ -54,6 +54,8 @@ end -- only have the unownForms set, so fall back to alphabetical for those -- rather than showing an empty page. local function caughtLetters(save) + local Unown = require("src.pokemon.Gen2Unown") + if Unown.supported() then return Unown.letters(save) end local dex = save and save.pokedex if not dex then return {} end local ordered = dex.unownDex diff --git a/tests/engine/gen2_unown.lua b/tests/engine/gen2_unown.lua new file mode 100644 index 000000000..13a7e031e --- /dev/null +++ b/tests/engine/gen2_unown.lua @@ -0,0 +1,146 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local previous=V.get() +require('src.core.ModCompat').install() +require('src.script.Gen2Commands') +local U=require('src.core.gen2.Unown');local N=require('src.pokemon.Gen2Unown') +local Json=require('src.link.Json');local Loader=require('src.mods.Loader') +local Game=require('src.core.Game');local originalData=Game.data +local function fixture() + local d=T.fixtures.fresh();local def=require('src.mods.Merge').deepCopy(d.pokemon.FIXMON_A) + def.id='SPECIES_201';def.index=201;def.dex=201;def.name='UNOWN';def.forms={} + for i=1,26 do def.forms[i]={spriteFront='native-front-'..i,spriteBack='native-back-'..i}end + d.pokemon.SPECIES_201=def;d.constants.speciesOrder={[201]='SPECIES_201'} + + d.unown_dex={words={}};for i=1,26 do d.unown_dex.words[i]='AUTHORED-'..i end + return d +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local d=fixture();Game.data=d + T.neq(U,N,version..' reference spelling is an explicit native adapter') + for i=1,26 do + local dvs=U.dvsForLetter(i);local mon={species='SPECIES_201',dvs=dvs} + T.eq(U.index(U.name(i)),i,version..' complete letter identity roundtrip') + T.eq(U.letterFromDVs(dvs),i,version..' inverse has correct native DV letter') + T.eq(require('src.pokemon.Sprites').formIndex(d.pokemon.SPECIES_201,mon),i,version..' native sprite form shares DV selection') + T.eq(U.monLetter(mon),i,version..' public mon identity reads current native dataset') + T.eq(U.word(i),'AUTHORED-'..i,version..' words are imported data, not a retail catalogue') + T.eq(U.formSprite(d.pokemon,i),d.pokemon.SPECIES_201.forms[i].spriteFront,version..' native form art path returned') + T.eq(U.formSprite(d.pokemon,i,true),d.pokemon.SPECIES_201.forms[i].spriteBack,version..' native back art path returned') + end + T.eq(U.forms(d.pokemon).A,d.pokemon.SPECIES_201.forms[1],version..' public named form resolves actual native first row') + T.eq(U.forms(d.pokemon).Z,d.pokemon.SPECIES_201.forms[26],version..' public named form resolves actual native last row') + local save={pokedex={unownForms={[1]=true,[3]=true,[5]=false,[99]=true}}};local before=Json.encode(save) + T.same(U.dex(save),{1,3},version..' older set-only save remains readable') + T.eq(U.count(save),2,version..' invalid/false form flags are not real letters') + T.eq(Json.encode(save),before,version..' reading an older save does not migrate it') + local detached=U.dex(save);detached[1]=26;T.same(U.dex(save),{1,3},version..' public list reads are detached') + T.check(U.registerCatch(save,{species='SPECIES_201',dvs=U.dvsForLetter(4)}),version..' newly received native mon records its form') + T.same(save.pokedex.unownDex,{1,3,4},version..' previous set-only letters retained before new caught form') + T.eq(save.unownDex,nil,version..' no parallel reference save list is created') + T.eq(U.registerCatch(save,{species='SPECIES_201',dvs=U.dvsForLetter(4)}),false,version..' second catch of same form is silent') + save.pokedex.unownDex={4,4,0,27};save.pokedex.unownForms[2]=true + T.same(U.dex(save),{4,1,2,3},version..' partial or malformed old ordered data retains known order and missing old forms') + local ctx={save=save};require('src.script.Commands').g2_readvar(ctx,14) + T.eq(ctx.g2Var,U.count(save),version..' native VAR_UNOWNCOUNT shares public count') + local screen=require('src.ui.PokedexUnownMode').new({data=d,save=save}) + T.same(screen.letters,U.dex(save),version..' native dex page shares public caught order') + local stored={species='SPECIES_201',dvs=U.dvsForLetter(2),unownLetter=3} + T.eq(U.monLetter(stored),3,version..' authored stored letter has reference precedence') + T.eq(require('src.pokemon.Sprites').formIndex(d.pokemon.SPECIES_201,stored),3,version..' stored letter also reaches native rendering') + local oldMon={species='SPECIES_201',unownLetter=7} + T.eq(U.monLetter(oldMon),7,version..' older stored-form record remains readable without DVs') + T.eq(require('src.pokemon.Sprites').formIndex(d.pokemon.SPECIES_201,oldMon),7,version..' native art agrees for an older stored-form record') + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local files={['mods/forms/manifest.json']=Json.encode({id='forms',name='Native form observer',version='1.0.0',api=mode[1],content_format=mode[2],entry='main.lua',affects_link=false}), + ['mods/forms/main.lua']=[[ + local mod=...;local Unown=require('src.core.gen2.Unown') + mod.exports.events={};mod.exports.read=Unown.dex + mod.events:on("unown.unlocked",function(e) + mod.exports.events[#mod.exports.events+1]={letter=e.letter,name=e.name,word=e.word,count=e.count} + assert(Unown.caught(mod.exports.save,e.letter)) + assert(not Unown.updateDex(mod.exports.save,e.letter)) + e.letter=99;return false + end) + ]]} + local live=fixture();Game.data=live + local r=T.sdk.loadMods({'mods/forms'},{data=live,fs=T.sdk.memfs(files)}) + local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' actual Loader admits native module and public event') + local out=r.loader.exports.forms;out.save={} + T.check(N.registerCatch(out.save,{species='SPECIES_201',dvs=U.dvsForLetter(9)},live),label..' real native update reaches loaded consumer') + T.same(out.events,{{letter=9,name='I',word='AUTHORED-9',count=1}},label..' exactly one completed form notification') + T.same(out.read(out.save),{9},label..' listener mutation/return does not change the native form') + T.eq(N.registerCatch(out.save,{species='FIXMON_A',dvs=U.dvsForLetter(10)},d),false,label..' another species does not register an Unown form') + T.eq(#out.events,1,label..' non-form catch remains silent') + Loader.teardown();r.release() + end + Game.data=d + -- Native transfers run for real; only deferred UI/audio are omitted. + local Battle=require('src.battle.BattleState');local Boxes=require('src.pokemon.Boxes');Boxes.load(nil) + local function gameFor(fullParty,fullBoxes) + local save={player={name='QA',id=1},party={},pokedex={owned={},seen={}},flags={}} + if fullParty then for i=1,6 do save.party[i]={species='FIXMON_A'}end end + if fullBoxes then + local boxes=Boxes.ensure(save) + for _,box in ipairs(boxes)do for i=1,Boxes.capacity()do box[i]={species='FIXMON_A'}end end + end + return {save=save,data=d,stack={push=function()end}} + end + local function capture(game,letter) + local mon={species='SPECIES_201',level=5,dvs=U.dvsForLetter(letter)} + local battle=setmetatable({game=game,enemy={mon=mon,name='FORM'}, + restoreMimicked=function()end,sayNext=function()end,uiNext=function()end},{__index=Battle}) + battle:storeCaughtMon();return mon + end + local g=gameFor(false,false);local caught=capture(g,6) + T.eq(g.save.party[1],caught,version..' real battle capture enters party') + T.same(U.dex(g.save),{6},version..' party capture registers native form') + g=gameFor(true,false);caught=capture(g,7) + T.eq(g.save.boxes[1][1],caught,version..' real full-party capture enters native PC box') + T.same(U.dex(g.save),{7},version..' box capture registers native form') + g=gameFor(true,true);capture(g,8) + T.same(U.dex(g.save),{},version..' refused full-storage capture cannot publish a form') + local Commands=require('src.script.Commands') + for _,fullParty in ipairs({false,true})do + g=gameFor(fullParty,false) + local ctx={game=g,save=g.save} + Commands.give_pokemon(ctx,'SPECIES_201',5,true) + local received=fullParty and g.save.boxes[1][1]or g.save.party[1] + T.check(received~=nil,version..' actual gift transfer completes') + T.same(U.dex(g.save),{U.monLetter(received,d)},version..' actual gift records received native form') + end + g=gameFor(false,false) + Commands.give_pokemon({game=g,save=g.save},'SPECIES_201',5,true,{egg=true}) + T.same(U.dex(g.save),{},version..' actual egg receipt cannot register a caught form') +end +-- Independently pack the actual two DV bytes and mask hardware bit positions. +local bit=require('bit') +for first=0,255 do for second=0,255 do + local packed=bit.lshift(bit.band(first,0x60),1)+bit.lshift(bit.band(first,0x06),3) + +bit.rshift(bit.band(second,0x60),3)+bit.rshift(bit.band(second,0x06),1) + local dvs={attack=bit.rshift(first,4),defense=bit.band(first,15),speed=bit.rshift(second,4),special=bit.band(second,15)} + T.eq(U.letterFromDVs(dvs),math.floor(packed/10)+1,'all cartridge DV byte combinations preserve native form') +end end +for _,invalid in ipairs({false,0,27,-1,1.5,'','AA',0/0})do + T.eq(U.index(invalid),nil,'malformed letter has no identity') + T.eq(U.name(invalid),nil,'malformed letter has no label') + T.eq(U.updateDex({},invalid),false,'malformed form cannot enter native save') +end +T.eq(U.letterFromDVs(nil),nil,'missing DVs are not letter A') +T.eq(U.letterFromDVs({attack=math.huge}),nil,'invalid DVs cannot reach renderer') +T.eq(U.registerCatch({}, {species='UNOWN',isEgg=true,dvs=U.dvsForLetter(1)}),false,'egg has no caught form') +for _,broken in ipairs({{pokedex=false},{pokedex='bad'},{pokedex={unownForms='bad'}}})do + local bytes=Json.encode(broken);T.eq(U.updateDex(broken,1),false,'malformed native state refuses a write') + T.eq(Json.encode(broken),bytes,'refused malformed-state write is atomic') +end +T.eq(U.word(1,{unown_dex=false}),nil,'malformed imported word record is unavailable') +Game.data={pokemon={}};T.eq(U.word(1),nil,'data reload does not revive old imported words') +for _,other in ipairs({'prism','polishedcrystal','red','firered','heartgold'})do + V.set(other) + T.eq(U.updateDex({},1),false,other..' existing form model remains unchanged') +end +V.set('red');local save={};T.eq(U.updateDex(save,1),false,'native GSC save writes are generation gated') +T.same(save,{},'unsupported game is not mutated') +Game.data=originalData;V.set(previous) +T.finish('native GSC Unown identity, imported words, ordered dex and public observation') From f4ff1e2c9a1f7e823b2e3b4245d7344c4c1862d2 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:49:18 +0100 Subject: [PATCH 103/138] Use the native egg predicate for Unown form registration --- src/pokemon/Gen2Unown.lua | 2 +- tests/engine/gen2_unown.lua | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pokemon/Gen2Unown.lua b/src/pokemon/Gen2Unown.lua index 48d6d55cc..a1b37adf6 100644 --- a/src/pokemon/Gen2Unown.lua +++ b/src/pokemon/Gen2Unown.lua @@ -73,7 +73,7 @@ function M.formSprite(pokemon, letter, back) return form and form.spriteFront or def.spriteFront end function M.monLetter(mon, data) - if type(mon) ~= "table" or mon.isEgg then return nil end + if type(mon) ~= "table" or require("src.pokemon.Party").isEgg(mon) then return nil end data = M.liveData(data) local def = data and data.pokemon and data.pokemon[mon.species] if def then diff --git a/tests/engine/gen2_unown.lua b/tests/engine/gen2_unown.lua index 13a7e031e..336e97dd2 100644 --- a/tests/engine/gen2_unown.lua +++ b/tests/engine/gen2_unown.lua @@ -130,6 +130,7 @@ end T.eq(U.letterFromDVs(nil),nil,'missing DVs are not letter A') T.eq(U.letterFromDVs({attack=math.huge}),nil,'invalid DVs cannot reach renderer') T.eq(U.registerCatch({}, {species='UNOWN',isEgg=true,dvs=U.dvsForLetter(1)}),false,'egg has no caught form') +T.eq(U.registerCatch({}, {species='UNOWN',eggSteps=3,dvs=U.dvsForLetter(1)}),false,'native egg counter also prevents registration when an old flag is missing') for _,broken in ipairs({{pokedex=false},{pokedex='bad'},{pokedex={unownForms='bad'}}})do local bytes=Json.encode(broken);T.eq(U.updateDex(broken,1),false,'malformed native state refuses a write') T.eq(Json.encode(broken),bytes,'refused malformed-state write is atomic') From f83f4aec56d1d3fb5f3da6eb70f3b4ec5821fc04 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:57:26 +0100 Subject: [PATCH 104/138] Share saved Gen2 RTC across native world scripts and clock card --- docs/native-gen2-clock.md | 41 ++++++++++ src/core/GameVersion.lua | 2 + src/core/Gen2Clock.lua | 81 ++++++++++++++++++++ src/core/ModCompat.lua | 1 + src/mods/Capabilities.lua | 1 + src/mods/Loader.lua | 4 + src/mods/UnifiedCallbacks.lua | 1 + src/mods/WorldOverview.lua | 2 +- src/pokemon/Evolution.lua | 11 +-- src/render/PaletteFX.lua | 16 ++++ src/script/Gen2Commands.lua | 14 ++-- src/script/Gen2Daily.lua | 2 +- src/ui/PokegearMenu.lua | 8 +- src/world/OverworldController.lua | 14 +++- tests/engine/gen2_clock.lua | 123 ++++++++++++++++++++++++++++++ 15 files changed, 299 insertions(+), 22 deletions(-) create mode 100644 docs/native-gen2-clock.md create mode 100644 src/core/Gen2Clock.lua create mode 100644 tests/engine/gen2_clock.lua diff --git a/docs/native-gen2-clock.md b/docs/native-gen2-clock.md new file mode 100644 index 000000000..d19b13c60 --- /dev/null +++ b/docs/native-gen2-clock.md @@ -0,0 +1,41 @@ +# Shared native Gen2 RTC + +The Pokégear previously displayed elapsed play time while world palettes, +object time masks and script variables used the host clock. The native +`Gen2Clock` now provides the same saved RTC-base reads to these consumers, +the native happiness evolution gate and observational encounter summaries. +The existing `src.core.gen2.Clock` name resolves to this native module. +`world.hour()`, `world.minute()` and `world.weekday()` read that same save. + +`setTime(save, hour, minute)` stores `rtc.startMinute`, an offset from the +host RTC, rather than a frozen hour. `setWeekday` stores `rtc.startDay` and +keeps the older native `g2DayOffset` field synchronized. Old saves without +an RTC base retain their host-hour and existing weekday-offset behavior. +Read calls never create clock data. Invalid/non-finite writes are rejected. +The normal native save codec preserves the offsets; play time is separate. + +World palettes, object-load period bits and `VAR_HOUR` share the saved hour; +the normal object-load freeze still applies. An explicit palette-query hour +retains precedence. The existing `world.tod` hook remains a presentation +override and does not rewrite the saved RTC. A raw scene-clock query uses +the current native save, independently of a room's forced palette. + +`clock.day_changed` is produced by actual weekday reads, including the live +world clock poll. It reports `day`, `previous` and `reason` (`rollover` or +`set`). A save's first observation is silent. Observations are per save, +reentrant-safe, subscription-aware and retired on mod/edition reload. +Native API 1/2 and unified API 2/3 use the normal event dispatch boundaries; +other generations do not advertise this native GSC seam. + +The clock card, native script readers, evolution time gate and world methods +are exercised directly in synthetic engine tests, including minute wrap, +weekly rollover, save roundtrip, RTC-versus-play-time display, both callback +paths and actual mod loading. Graphics IO alone is replaced for the clock +card. These checks do not establish a complete original-story clock-setting +sequence, DST, battery-backed SRAM RTC conversion, all daily scheduling or +fresh GPU acceptance for this change. Existing daily date keys still use +host calendar dates; lottery weekday selection now shares the saved weekday. + +The public contract is compared with the read-only Gen1Recomp reference; +the implementation is native fork code. No VASC implementation, private +assets, game text or ROM-derived clock data is included. diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 7ebabc4fd..9165d3fc2 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1085,6 +1085,8 @@ function GameVersion.set(id) local previous = GameVersion.current GameVersion.current = GameVersion.VERSIONS[id] and id or "red" if previous ~= GameVersion.current then + local clock = package.loaded["src.core.Gen2Clock"] + if clock then clock.resetObservations() end local decorations = package.loaded["src.compat.gen2.Decorations"] if decorations then decorations.useRegistry(nil) end end diff --git a/src/core/Gen2Clock.lua b/src/core/Gen2Clock.lua new file mode 100644 index 000000000..1c8d4d996 --- /dev/null +++ b/src/core/Gen2Clock.lua @@ -0,0 +1,81 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Shared native GSC RTC reads. The public Clock spelling follows the +-- reference contract; no private mod implementation or clock is embedded. +local Clock = { MINUTES_PER_DAY = 1440, DAYS = 7, DEFAULT_HOUR = 10, DEFAULT_MINUTE = 0 } +local Strings = require("src.core.Strings") +Clock.DAY_NAMES = { "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY" } +local observed = setmetatable({}, { __mode = "k" }) +local function integer(value) + local n = tonumber(value) + if n and n == n and n ~= math.huge and n ~= -math.huge then return math.floor(n) end +end +local function rtc(save) + return type(save) == "table" and type(save.rtc) == "table" and save.rtc or nil +end +function Clock.hostMinutes() + return ((integer(os.date("%H")) or 0) * 60 + (integer(os.date("%M")) or 0)) % 1440 +end +function Clock.hostWeekday(now) + return (integer(os.date("%w", now)) or 0) % 7 +end +function Clock.minutes(save) + local state = rtc(save) + return (Clock.hostMinutes() + (state and integer(state.startMinute) or 0)) % 1440 +end +function Clock.hour(save) return math.floor(Clock.minutes(save) / 60) end +function Clock.minute(save) return Clock.minutes(save) % 60 end +function Clock.isSet(save) + local state = rtc(save) + return state ~= nil and integer(state.startMinute) ~= nil +end +function Clock.setTime(save, hour, minute) + if type(save) ~= "table" then return false end + hour, minute = integer(hour or 0), integer(minute or 0) + if not hour or not minute then return false end + save.rtc = rtc(save) or {} + save.rtc.startMinute = ((hour % 24) * 60 + minute % 60 - Clock.hostMinutes()) % 1440 + return true +end +local function note(save, day, reason) + if type(save) ~= "table" then return day end + local Runtime = require("src.mods.Runtime") + if not require("src.core.GameVersion").isGen2() or not Runtime.wants("clock.day_changed") then + observed[save] = nil + return day + end + local previous = observed[save] + observed[save] = day -- Commit before dispatch: reentrant reads cannot emit twice. + if previous ~= nil and previous ~= day then + Runtime.emit("clock.day_changed", { day = day, previous = previous, reason = reason }) + end + return day +end +function Clock.weekday(save, now) + local state = rtc(save) + local offset = state and integer(state.startDay) + if offset == nil then offset = type(save) == "table" and integer(save.g2DayOffset) or 0 end + return note(save, (Clock.hostWeekday(now) + (offset or 0)) % 7, "rollover") +end +function Clock.setWeekday(save, day) + day = integer(day) + if type(save) ~= "table" or not day then return false end + save.rtc = rtc(save) or {} + day = day % 7 + save.rtc.startDay = (day - Clock.hostWeekday()) % 7 + save.rtc.dayOfWeek = day + -- Keep the existing native field in sync for older native consumers/saves. + save.g2DayOffset = save.rtc.startDay + note(save, day, "set") + return true +end +function Clock.weekdayName(day) + local name = Clock.DAY_NAMES[day] + return name and Strings(name) +end +function Clock.daytimeLabel(hour) + hour = integer(hour) or Clock.hour() + hour = hour % 24 + return Strings(hour < 4 and "NITE" or hour < 10 and "MORN" or hour < 18 and "DAY" or "NITE") +end +function Clock.resetObservations() observed = setmetatable({}, { __mode = "k" }) end +return Clock diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index dff3b2660..cfa15f5b5 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -67,6 +67,7 @@ ModCompat.ALIASES = { -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", + ["src.core.gen2.Clock"] = "src.core.Gen2Clock", ["src.ui.gen2.MapRadio"] = "src.ui.Gen2MapRadio", -- Explicit signature adapter: native operations take (data, save). ["src.core.gen2.Decorations"] = "src.compat.gen2.Decorations", diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 32da142d7..10461d3bd 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -48,6 +48,7 @@ add("events", "12345", [[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) add("events", "2345", "pokemon.egg_hatched") +add("events", "2", "clock.day_changed") add("events", "12", [[ intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started intro.oak_speech.step diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index eeb14c0ba..77dc64b20 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1426,6 +1426,8 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + local clock = package.loaded["src.core.Gen2Clock"] + if clock then clock.resetObservations() end Api3NPC.cancelAll() Api3Warp.cancelAll() PortableScripts.cancelAll(nil, nil, "mods reloaded") @@ -1806,6 +1808,8 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + local clock = package.loaded["src.core.Gen2Clock"] + if clock then clock.resetObservations() end Api3NPC.cancelAll() Api3Warp.cancelAll() PortableScripts.cancelAll(nil, nil, "mods unloaded") diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 5e6a6195d..9c3e25970 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -26,6 +26,7 @@ C.hooks = names([[ ui.title_menu.items warp.destination world.tod zoom.range ]]) C.events = names([[ + clock.day_changed assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt battle.ended battle.exp_gained battle.fainted battle.move_used battle.started battle.status_inflicted battle.turn_ended battle.turn_started discord.join_requested flag.changed game.ready intro.birch_speech.finished diff --git a/src/mods/WorldOverview.lua b/src/mods/WorldOverview.lua index 491963d48..0bb09b62b 100644 --- a/src/mods/WorldOverview.lua +++ b/src/mods/WorldOverview.lua @@ -92,7 +92,7 @@ function Overview.encounters(game, ow, context, mapId, terrain, opts) end if GameVersion.isGen2() then if not daytime then - hour = tonumber(os.date("%H")) or 12 + hour = require("src.core.Gen2Clock").hour(game.save) daytime = hour < 4 and "NITE" or hour < 10 and "MORNING" or hour < 18 and "DAY" or "NITE" end else diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index 321387210..e9506843f 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -89,7 +89,7 @@ Evolution.METHODS = { return false end if not evo.timeOfDay then return true end - local night = Evolution.isNight() + local night = Evolution.isNight(game.save) if evo.timeOfDay == "NITE" then return night end return not night -- TR_MORNDAY: morn or day end, @@ -223,10 +223,11 @@ Evolution.HAPPINESS_TO_EVOLVE = 220 Evolution.BASE_HAPPINESS = 70 -- GSC time-of-day bands (constants/time_constants.asm): MORN 4-9, DAY --- 10-17, NITE 18-3. The port has no in-game clock, so the wall clock is --- what GSC's RTC would have been. -function Evolution.isNight() - local hour = tonumber(os.date("%H")) or 12 +-- 10-17, NITE 18-3. GSC reads the shared saved RTC offset; other editions +-- retain their existing host-clock behavior. +function Evolution.isNight(save) + local hour = require("src.core.GameVersion").isGen2() + and require("src.core.Gen2Clock").hour(save) or tonumber(os.date("%H")) or 12 return hour >= 18 or hour < 4 end diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 88984d94b..606309982 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -535,6 +535,22 @@ function PaletteFX.gen2Tod() return gen2Tod end -- bgSet fetches it (LoadMapPals). Neither reads or writes the live state, so -- calling them cannot disturb what the 2D atlas was baked with. +-- Public RTC-only query used by Gen2 scene renderers (including train rides). +-- It must not inherit the live map override, darkness, or atlas bake state. +function PaletteFX.clockDaytime(hour) + hour = tonumber(hour) + if not hour and GameVersion.isGen2() then + local game = package.loaded["src.core.Game"] + hour = require("src.core.Gen2Clock").hour(game and game.save) + end + if not hour then hour = tonumber(os.date("%H")) end + hour = math.floor(hour or 12) % 24 + if hour < 4 then return "NITE" end + if hour < 10 then return "MORN" end + if hour < 18 then return "DAY" end + return "NITE" +end + -- Which of MORN / DAY / NITE / DARK a map takes. -- -- mapDef a record out of data.maps; its `mapPalette` is map header diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index 4302cb11f..02d35376d 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2699,12 +2699,9 @@ function Commands.g2_readvar(ctx, var) elseif var == 6 then ctx.g2Var = countKeys(ctx.save.pokedex and ctx.save.pokedex.seen) elseif var == 10 then - ctx.g2Var = tonumber(os.date("%H")) or 0 + ctx.g2Var = require("src.core.Gen2Clock").hour(ctx.save) elseif var == 11 then - -- g2DayOffset is what mom's SetDayOfWeek picker answered, as a shift off - -- the host day, so Arthur still shows up on a real Thursday. - ctx.g2Var = ((tonumber(os.date("%w")) or 0) - + (ctx.save.g2DayOffset or 0)) % 7 + ctx.g2Var = require("src.core.Gen2Clock").weekday(ctx.save) elseif var == 16 then -- BoxFreeSpace counts only the current box, not all available PC slots. local Boxes = require("src.pokemon.Boxes") @@ -2955,7 +2952,8 @@ local WEEKDAYS = { -- either way the day still rolls over at real midnight instead of freezing. function Commands.g2_set_day_of_week(ctx) local game, runner = ctx.game, ctx.runner - local host = tonumber(os.date("%w")) or 0 + local Clock = require("src.core.Gen2Clock") + local current = Clock.weekday(ctx.save) local picked repeat Commands.show_text(ctx, "SetDayOfWeek.OakTimeWhatDayIsItText") @@ -2969,14 +2967,14 @@ function Commands.g2_set_day_of_week(ctx) -- no cancel: the ROM's loop watches A only, and onCancel is the only -- other path back to runner:resume() local menu = require("src.ui.Menu").new(game, items, { cancelable = false }) - menu.index = host + 1 + menu.index = current + 1 menu:clampScroll() game.stack:push(menu) runner:yield() Commands.ask(ctx, Strings(WEEKDAYS[picked + 1]) .. resolvedText(ctx, "SetDayOfWeek.OakTimeIsItText")) until ctx.lastCheck - ctx.save.g2DayOffset = (picked - host) % 7 + Clock.setWeekday(ctx.save, picked) end -- UnownPuzzle (SpecialsPointers row 41). The chamber scripts run diff --git a/src/script/Gen2Daily.lua b/src/script/Gen2Daily.lua index 96e54c666..6337f5cd9 100644 --- a/src/script/Gen2Daily.lua +++ b/src/script/Gen2Daily.lua @@ -45,7 +45,7 @@ end local function nextFriday(save, now) local date = os.date("*t", now) - local weekday = (date.wday - 1 + (tonumber(save.g2DayOffset) or 0)) % 7 + local weekday = require("src.core.Gen2Clock").weekday(save, now) local days = (5 - weekday) % 7 -- Match the weekday chosen at Mom's clock. if days == 0 then days = 7 end -- Calendar arithmetic, rather than seconds, also handles daylight saving. diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index 872242134..34b9076ac 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -799,10 +799,10 @@ function PokegearMenu:drawClock() self:text("SWITCH", 13, 1) local save = self.game.save - local t = math.floor(require("src.core.SaveData").playSeconds(save)) - local hour = math.floor(t / 3600) % 24 - local minute = math.floor(t / 60) % 60 - local day = DAYS[(math.floor(t / 86400) % 7) + 1] + local Clock = require("src.core.Gen2Clock") + local minutes = Clock.minutes(save) + local hour, minute = math.floor(minutes / 60), minutes % 60 + local day = Clock.weekdayName(Clock.weekday(save) + 1) local display = hour % 12 if display == 0 then display = 12 end self:text(day, 6, 6) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 6cabda580..f1463f132 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -28,6 +28,7 @@ local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local ScriptRunner = require("src.script.ScriptRunner") local GameVersion = require("src.core.GameVersion") +local Gen2Clock = require("src.core.Gen2Clock") local Tilt = require("src.render.Tilt") local TextBox = require("src.render.TextBox") local Transition = require("src.render.Transition") @@ -283,7 +284,7 @@ local function objectTodBit() local ow = Game and Game.overworld local bit = ow and tonumber(ow.objectTodBit) if bit then return bit end - local period = todPeriodAt(tonumber(os.date("%H")) or 12) + local period = todPeriodAt(Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil)) if period and tonumber(period.bit) then return tonumber(period.bit) end return TOD_BITS[objectTimeOfDay()] or TOD_BITS.DAY end @@ -2265,7 +2266,7 @@ local function sameTod(tod) return tod end -- < 4 NITE, < 10 MORN, < 18 DAY, else NITE. Gen 1 has no clock at all, so -- only Gen 2 consults the host one. local function clockTimeOfDay() - local hour = tonumber(os.date("%H")) or 12 + local hour = Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil) if hour < 4 then return "NITE" end if hour < 10 then return "MORNING" end if hour < 18 then return "DAY" end @@ -2278,7 +2279,7 @@ OverworldState.clockTimeOfDay = clockTimeOfDay -- The cartridge's MAPOBJECT_TIMEOFDAY bit for the hour on the clock right now. function OverworldState.clockTimeOfDayBit() - local period = todPeriodAt(tonumber(os.date("%H")) or 12) + local period = todPeriodAt(Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil)) return (period and tonumber(period.bit)) or TOD_BITS.DAY end @@ -2301,6 +2302,7 @@ function OverworldState:syncGen2Tod(tod) end function OverworldState:timeOfDay() + if GameVersion.isGen2() then Gen2Clock.weekday(Game and Game.save) end local tod = GameVersion.isGen2() and clockTimeOfDay() or (self.tod or "DAY") if not Runtime.wantsHook("world.tod") then if tod ~= self.tod then @@ -2337,6 +2339,12 @@ function OverworldState:timeOfDay() return self.tod end +-- Legacy callers and native scripts read the same saved RTC offset. Both +-- dot and colon calls are observational; no cached second clock on World. +function OverworldState.hour() return Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil) end +function OverworldState.minute() return Gen2Clock.minute(GameVersion.isGen2() and Game and Game.save or nil) end +function OverworldState.weekday() return Gen2Clock.weekday(GameVersion.isGen2() and Game and Game.save or nil) end + function OverworldState:paletteNameFor(map) local palettes = FieldDefaults.field(Game.data, "palettes") local name = map.def.palette or paletteLookup(palettes, map.id, map.def.tileset) diff --git a/tests/engine/gen2_clock.lua b/tests/engine/gen2_clock.lua new file mode 100644 index 000000000..db54d0dcc --- /dev/null +++ b/tests/engine/gen2_clock.lua @@ -0,0 +1,123 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local V = require('src.core.GameVersion');local version = V.get() +require('src.core.ModCompat').install() +local Clock = require('src.core.gen2.Clock') +local P = require('src.render.PaletteFX');local W = require('src.world.OverworldController') +require('src.script.Gen2Commands');local Commands = require('src.script.Commands');local Loader = require('src.mods.Loader') +local Json = require('src.link.Json');local Save = require('src.core.SaveData') +local date = os.date;local host = { hour=13, minute=20, day=2 } +os.date = function(format, now) + if now then return date(format,now) end + if format=='%H' then return tostring(host.hour) end + if format=='%M' then return tostring(host.minute) end + if format=='%w' then return tostring(host.day) end + return date(format,now) +end +local slot,oldGame +for i=1,100 do local name,value=debug.getupvalue(W.timeOfDay,i) + if name=='Game' then slot=i;oldGame=value;break end + if not name then break end +end +assert(slot) +local nativeGame=require('src.core.Game');local oldSave=nativeGame.save +T.eq(Clock,require('src.core.Gen2Clock'),'public name shares actual native clock') +T.eq(Clock.minutes(),800,'no save reads host RTC') +T.eq(Clock.isSet({}),false,'old save has no fabricated clock anchor') +local malformed={rtc='invalid'} +T.eq(Clock.hour(malformed),13,'malformed optional clock falls back to host') +T.eq(Clock.setTime(malformed,0/0,10),false,'NaN cannot poison saved clock') +T.eq(malformed.rtc,'invalid','refused write is atomic') +T.eq(Clock.setTime(false,10,0),false,'no saved state means no write') +T.eq(Clock.setWeekday({},math.huge),false,'infinite weekday rejected') +T.eq(Clock.weekdayName(0),nil,'weekday label API is one-based') +for _,edition in ipairs({'gold','silver','crystal'}) do + V.set(edition);host.hour=13;host.minute=20;host.day=2 + local save={playTime=0,g2DayOffset=3};local untouched=Json.encode(save) + T.eq(Clock.hour(save),13,edition..' legacy hour retains host behavior') + T.eq(Clock.weekday(save),5,edition..' legacy native weekday offset retained') + T.eq(Json.encode(save),untouched,edition..' clock reads do not seed saved state') + T.eq(Clock.setTime(save,23,55),true,edition..' clock setting anchors native RTC') + T.eq(Clock.setWeekday(save,6),true,edition..' day picker stores native RTC base') + T.eq(Clock.hour(save),23,edition..' saved hour is observed immediately') + T.eq(Clock.minute(save),55,edition..' saved minute is observed immediately') + T.eq(Clock.weekday(save),6,edition..' weekday is observed immediately') + T.eq(Clock.isSet(save),true,edition..' explicit clock setting is distinguishable') + T.eq(save.g2DayOffset,save.rtc.startDay,edition..' old native day readers retain shared offset') + host.minute=30 + T.eq(Clock.minutes(save),5,edition..' RTC keeps ticking and wraps at midnight') + host.day=3 + T.eq(Clock.weekday(save),0,edition..' saved weekday follows RTC weekly rollover') + local round=assert(Save.decode(Save.encode(save))) + T.eq(Clock.minutes(round),5,edition..' save roundtrip retains RTC minute base') + T.eq(Clock.weekday(round),0,edition..' save roundtrip retains RTC weekday base') + local game={data=T.fixtures.fresh(),save=save} + debug.setupvalue(W.timeOfDay,slot,game);nativeGame.save=save + local world=setmetatable({map={id='RTC',def={mapPalette=0}},player={cellX=0,cellY=0}}, {__index=W}) + -- Control only graphics IO; native clock, period selection and commands run. + world.syncGen2Tod=function()end + for _,case in ipairs({{3,59,'NITE',4},{4,0,'MORNING',1},{9,59,'MORNING',1}, + {10,0,'DAY',2},{17,59,'DAY',2},{18,0,'NITE',4}})do + Clock.setTime(save,case[1],case[2]) + T.eq(world:timeOfDay(),case[3],edition..' native world reads saved boundary '..case[1]) + T.eq(W.clockTimeOfDayBit(),case[4],edition..' object-load bit uses same saved boundary '..case[1]) + T.eq(world.hour(),case[1],edition..' legacy world dot hour agrees') + T.eq(world:minute(),case[2],edition..' legacy world colon minute agrees') + T.eq(P.clockDaytime(),case[3]=='MORNING' and 'MORN' or case[3],edition..' scene palette raw RTC agrees') + T.eq(P.clockDaytime(12),'DAY',edition..' explicit scene hour remains authoritative') + local ctx={save=save,overworld=world} + Commands.g2_readvar(ctx,10);T.eq(ctx.g2Var,case[1],edition..' actual VAR_HOUR agrees') + Commands.g2_readvar(ctx,11);T.eq(ctx.g2Var,Clock.weekday(save),edition..' actual VAR_WEEKDAY agrees') + Commands.g2_checktime(ctx,case[4]);T.eq(ctx.lastCheck,true,edition..' actual checktime agrees') + T.eq(require('src.pokemon.Evolution').isNight(save),case[4]==4,edition..' happiness time gate agrees') + end + -- Native clock-card composition must show RTC, even with zero play time. + Clock.setTime(save,21,47);Clock.setWeekday(save,4) + local text={};local Gear=require('src.ui.PokegearMenu') + local gear=setmetatable({game=game,blink=0,gfx={cards={clock={}}}}, {__index=Gear}) + for _,method in ipairs({'drawTilemap','drawStrip','textbox','printBoxText','drawModeArrow'})do gear[method]=function()end end + gear.text=function(_,value,x,y)text[x..':'..y]=value end + gear:drawClock() + T.eq(text['6:6'],'THURSDAY',edition..' actual clock card shows selected weekday') + T.eq(text['6:8'],' 9',edition..' actual clock card shows RTC hour rather than play hours') + T.eq(text['9:8'],'47',edition..' actual clock card shows RTC minute rather than play minutes') + T.eq(text['12:8'],'PM',edition..' actual clock card shows RTC half-day') + for _,mode in ipairs({{1,'native'},{2,'native'},{2,'unified'},{3,'unified'}}) do + local files={['mods/rtc/manifest.json']=Json.encode({id='rtc',name='Native RTC observer',version='1.0.0',api=mode[1],content_format=mode[2],entry='main.lua',affects_link=false}), + ['mods/rtc/main.lua']=[[ + local mod=...;assert(mod:supports('events','clock.day_changed')) + local Clock=require('src.core.gen2.Clock');mod.exports.events={} + mod.exports.stop=mod.events:on('clock.day_changed',function(e) + mod.exports.events[#mod.exports.events+1]={day=e.day,previous=e.previous,reason=e.reason} + assert(Clock.weekday(mod.exports.save)==e.day) + e.day=99;return false + end) + ]]} + local result=T.sdk.loadMods({'mods/rtc'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + local label=edition..'/'..mode[1]..'/'..mode[2] + T.eq(#result.errors,0,label..' actual loader admits public clock module/event') + local e=result.loader.exports.rtc;local own={};e.save=own + Clock.weekday(own);T.eq(#e.events,0,label..' first read is not a fictitious rollover') + host.day=(host.day+1)%7 + Clock.weekday(own);T.eq(#e.events,1,label..' real read observes rollover exactly once') + T.eq(e.events[1].reason,'rollover',label..' rollover reason supplied') + Clock.weekday(own);T.eq(#e.events,1,label..' reentrant/repeated reads do not repeat event') + Clock.weekday({});T.eq(#e.events,1,label..' another profile starts silently') + local selected=(Clock.weekday(own)+2)%7 + Clock.setWeekday(own,selected) + T.eq(#e.events,2,label..' native day setter observes one change') + T.eq(e.events[2].reason,'set',label..' explicit setter reason supplied') + T.eq(Clock.weekday(own),selected,label..' listener mutation cannot corrupt native weekday') + e.stop();host.day=(host.day+1)%7;Clock.weekday(own) + T.eq(#e.events,2,label..' unsubscribe retires actual observation') + Loader.teardown();result.release() + end +end +for _,edition in ipairs({'red','firered','emerald','heartgold'})do + V.set(edition) + T.eq(require('src.mods.Capabilities').supports('events','clock.day_changed',edition),false,edition..' native RTC event not falsely advertised') +end +nativeGame.save=oldSave;debug.setupvalue(W.timeOfDay,slot,oldGame) +Clock.resetObservations();os.date=date;V.set(version) +T.finish('shared native Gen2 saved RTC, actual world/scripts/clock-card and public event') From af2e1ae7333d61e20b5279fb4205b0537759b08f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:19:38 +0100 Subject: [PATCH 105/138] compat(M8): connect native lifecycle callbacks and published-pack followers --- docs/gen2-lifecycle-callbacks.md | 57 ++++++ src/battle/BattleState.lua | 61 +++++- src/compat/game3/Follower.lua | 9 + src/compat/game3/Pokemon.lua | 17 ++ src/compat/gen2/Unown.lua | 13 ++ src/core/Game.lua | 8 +- src/import/pmd/Sprite.lua | 86 ++++++++ src/mods/Capabilities.lua | 3 + src/mods/Gen1Compat.lua | 7 +- src/mods/Loader.lua | 5 +- src/mods/Manifest.lua | 5 + src/mods/UnifiedCallbacks.lua | 3 + src/pokemon/DayCare.lua | 42 +++- src/pokemon/Gen2Friendship.lua | 13 ++ src/pokemon/Gen2Pokerus.lua | 74 +++++++ src/script/Gen2Commands.lua | 7 + src/script/Gen2Daily.lua | 1 + src/ui/Gen2Intro.lua | 29 +++ src/ui/TitleState.lua | 4 + src/world/BugContest.lua | 5 + src/world/OverworldController.lua | 10 + src/world/RoamMons.lua | 5 + tests/engine/api3_compat.lua | 3 + tests/engine/compat_module_aliases.lua | 18 +- tests/engine/gen2_lifecycle_callbacks.lua | 233 ++++++++++++++++++++++ tests/engine/manifest_force_enable.lua | 31 +++ tests/engine/mod_supports.lua | 4 +- tests/engine/pack_sprite_adapter.lua | 47 +++++ tests/fixtures/upstream_mods/data.lua | 3 +- tests/fixtures/upstream_mods/worker.lua | 31 ++- tools/check_external_mod_fixtures.py | 3 +- tools/check_gen2_lifecycle_profile.py | 52 +++++ tools/gen2-lifecycle-profile/conf.lua | 7 + tools/gen2-lifecycle-profile/main.lua | 76 +++++++ 34 files changed, 955 insertions(+), 17 deletions(-) create mode 100644 docs/gen2-lifecycle-callbacks.md create mode 100644 src/compat/game3/Follower.lua create mode 100644 src/compat/game3/Pokemon.lua create mode 100644 src/compat/gen2/Unown.lua create mode 100644 src/import/pmd/Sprite.lua create mode 100644 src/pokemon/Gen2Pokerus.lua create mode 100644 tests/engine/gen2_lifecycle_callbacks.lua create mode 100644 tests/engine/manifest_force_enable.lua create mode 100644 tests/engine/pack_sprite_adapter.lua create mode 100644 tools/check_gen2_lifecycle_profile.py create mode 100644 tools/gen2-lifecycle-profile/conf.lua create mode 100644 tools/gen2-lifecycle-profile/main.lua diff --git a/docs/gen2-lifecycle-callbacks.md b/docs/gen2-lifecycle-callbacks.md new file mode 100644 index 000000000..fb2cd7294 --- /dev/null +++ b/docs/gen2-lifecycle-callbacks.md @@ -0,0 +1,57 @@ +# Native Gen2 lifecycle callbacks + +Gen2 callbacks now observe production state transitions through the ordinary +mod buses. Existing clock, phone, radio, apricorn and fixed-destination log +implementations remain the source of those notifications. + +| Boundary | Public contract | +| --- | --- | +| Day Care compatibility | `(next, {data, mon1, mon2, dayCare})`, byte 0..255 | +| Pending egg creation | `{egg, mother, father, compatibility, stepsToEgg}` | +| Hatch UI completion | `{mon, egg, slot, species, nickname}` | +| Friendship write | `{mon, event, reason, delta, from, to}` | +| Pokérus infection write | `{party, slot, mon, strain, days, source}` | +| Successful roaming encounter | `{index, slot, species, level, mapId}` | +| Actual roaming route change | `{index, slot, species, from, to, reason}` | +| First Unown form capture | `{letter, name, word, count}` | +| Completed contest judging | `{mon, score, place, results}` | +| Catch nickname decision | `(next, mon, {battle, name, game})` | +| Full-party catch disposition | `(next, {battle, mon, name, game})` | +| Trainer replacement style | `(next, battle)`, `shift` or `set` | +| Enemy switch/item decision | `(next, battle)`, canonical decision or claim | +| Return to launcher | `(next)` | +| Gen2 boot stages | `{screen, game}`, with `{skipped, frames}` at movie end | + +The native Day Care text-tier result remains unchanged. A private weak side +table carries an observed probability to the real egg roll; public method +patches and the existing `EGG_ODDS` table keep their unobserved behavior. Hatch +notifications retain an original egg snapshot before native in-place changes. +The existing `pokemon.egg_hatched` emission keeps its position and payload. +Friendship emits on actual changes after clamping. Duplicate Unown captures, +failed encounters and repeated movie completion do not announce new work. + +Pokérus previously existed only as a saved byte. Native GSC battle wins now +perform the party infection/spread transition, including the Goldenrod gate, +the rare de novo roll, cured immunity and the four-day spread stop. Daily +polling seeds its own stamp and advances counters by elapsed calendar days. +The event runs after the byte is written. This contribution concerns these +party-byte transitions; it does not claim an audit of all Pokérus consumers. + +`force_enable_env` retains a manifest variable name. Only a value of `1` +overrides a saved disable. Game targeting and no-mod discovery remain in +effect. Existing `log_url` and `mod:postLog` keep their network permission, +ownership and bounded transport behavior. + +Published sprite packs can be consumed through `src.import.pmd.Sprite` using +native fixed-frame rendering, row-major quads, timing and mutable anchors. +Follower, species and Unown query adapters cover the unchanged follower +consumer. The existing live player facade remains its provider. No ROM reader +is introduced. Authenticated follower entrypoints, spawn and animation hooks +run unchanged for Red, Gold, Crystal and FireRed over owned synthetic pack data. + +`tools/check_gen2_lifecycle_profile.py` runs native LÖVE and FrameProfile over +192 owned friendship/roamer/contest transitions per frame, comparing the prior +producer files, guarded native code and an ordinary registered observer. +It measures transition CPU work; it does not measure cartridge gameplay, art +correctness or handheld FPS. No performance rollback flag is added for this +compatibility task. ARM handheld performance has not been measured. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 290951a04..8785135b6 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -5444,6 +5444,32 @@ function BattleState:enemyAction(battler) return self:vanillaEnemyAction(battler) end +local function gen2EnemyClassAction(battle) + if not GameVersion.isGen2() or not Runtime.wantsHook("battle.enemy_switch_or_item")then + return TrainerAI.classAction(battle) + end + local native,view + local choice=Runtime.call("battle.enemy_switch_or_item",function(current) + native=TrainerAI.classAction(current) + if not native then return false end + view={kind=native.special=='aiSwitch' and 'switch' or 'item', + index=native.index,item=native.item} + return view + end,battle) + if choice==view then return native end + if choice==true then return {special='modEnemyTurn'}end + if type(choice)~='table'then return nil end + if choice.kind=='switch'then + local index=tonumber(choice.index) + local mon=index and battle.enemyParty and battle.enemyParty[index] + if index and index%1==0 and index~=battle.enemyIndex and mon and (mon.hp or 0)>0 then + return {special='aiSwitch',index=index} + end + elseif choice.kind=='item' and type(choice.item)=='string' and battle.data.items[choice.item]then + return {special='aiItem',item=choice.item} + end +end + function BattleState:vanillaEnemyAction(battler) battler = battler or self.enemy if not battler then return nil end @@ -5463,7 +5489,7 @@ function BattleState:vanillaEnemyAction(battler) -- right-hand slot ask too would have a trainer drink two SUPER POTIONs on -- the same Pokemon in the same turn. if battler == self.enemy then - local classAct = TrainerAI.classAction(self) + local classAct = gen2EnemyClassAction(self) if classAct then return classAct end end return TrainerAI.chooseMove(battler, self.rng, self) @@ -7544,6 +7570,7 @@ function BattleState:executeAction(user, target, action) if not (user and user.mon) then return end if user.mon.hp <= 0 then return end if not action then return end + if action.special=='modEnemyTurn'then return end -- A SWITCH NEEDS NO TARGET. Everything else does, and with four on the -- field the one it was aimed at can already be off it -- the entry's -- target is resolved as the entry runs, and there may be nobody left to @@ -8899,6 +8926,12 @@ function BattleState:enemyMonFainted() local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name local style = tostring((self.game.save.options or {}).battleStyle or "shift") :lower() + if GameVersion.isGen2() and Runtime.wantsHook("battle.style")then + local chosen=Runtime.call("battle.style",function(battle) + return tostring((battle.game.save.options or{}).battleStyle or 'shift'):lower() + end,self) + if chosen=='set' or chosen=='shift'then style=chosen end + end local partyCount = #self:playerPartyView() -- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the -- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays @@ -9868,6 +9901,11 @@ function BattleState:storeCaughtMon() -- ("It records them in the sequence that they were caught.") dex.unownDex = dex.unownDex or {} dex.unownDex[#dex.unownDex + 1] = form + if GameVersion.isGen2() and Runtime.wants("unown.unlocked")then + local words=game.data.unown_dex and game.data.unown_dex.words + Runtime.emit("unown.unlocked",{letter=form,name=string.char(64+form), + word=words and words[form],count=#dex.unownDex}) + end end end -- CAUGHT: the memo's "met at Lv5, ROUTE 101" comes from right here, and the @@ -9886,6 +9924,15 @@ function BattleState:storeCaughtMon() local function askCaughtNickname() local caught = self.enemy.mon local enemyName = self.enemy.name + if GameVersion.isGen2() and Runtime.wantsHook("catch.nickname")then + local decision=Runtime.call("catch.nickname",function()return true end,caught, + {battle=self,name=enemyName,game=game}) + if decision==false then return end + if type(decision)=='string'then + if #decision>0 then caught.nickname=decision:sub(1,10)end + return + end + end self:uiNext(function() return self:askNicknameUI(caught, enemyName) end) @@ -9905,6 +9952,12 @@ function BattleState:storeCaughtMon() require('src.pokemon.Gen4PoketchState').remember(game, self.enemy.mon) askCaughtNickname() else + local claimed=GameVersion.isGen2() and Runtime.wantsHook("catch.party_full") + and Runtime.call("catch.party_full",function()return false end, + {battle=self,mon=self.enemy.mon,name=self.enemy.name,game=game}) + if claimed then + destination='mod' + else destination = "box" local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon) if boxNum then @@ -9917,6 +9970,7 @@ function BattleState:storeCaughtMon() else self:sayNext(Strings("But every BOX\nis full!")) end + end end -- CatchMon copies the species name into wStringBuffer1, which is what the -- Bug Contest's "Caught !" and friends splice back out with text_ram @@ -10671,6 +10725,11 @@ function BattleState:finish() -- (home/overworld.asm:2343-2348) require("src.core.Music").restoreMap(self.data) self.game.stack:pop() + if not self._pokerusDone and not self.demo and not self.pokedudeDemo and self.result=='win' + and require('src.pokemon.Gen2Breeding').isVanilla()then + self._pokerusDone=true + require('src.pokemon.Gen2Pokerus').afterBattle(self.game.save) + end Runtime.emit("battle.ended", { battle = self, result = self.result or "run" }) -- Coming back from the battle screen is a fade, not a cut: EnterMap sees -- BIT_BATTLE_OVER_OR_BLACKOUT set and runs MapEntryAfterBattle diff --git a/src/compat/game3/Follower.lua b/src/compat/game3/Follower.lua new file mode 100644 index 000000000..86c09be9c --- /dev/null +++ b/src/compat/game3/Follower.lua @@ -0,0 +1,9 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Native=require('src.world.OptionalFollower') +local F={setShouldSpawn=Native.setShouldSpawn} +function F.current(world) + local game=package.loaded['src.core.Game'] + world=world or game and game.overworld + return world and Native.current(world)or nil +end +return F diff --git a/src/compat/game3/Pokemon.lua b/src/compat/game3/Pokemon.lua new file mode 100644 index 000000000..859ac5121 --- /dev/null +++ b/src/compat/game3/Pokemon.lua @@ -0,0 +1,17 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local P={} +function P.national(species) + local game=package.loaded['src.core.Game'] + local records=game and game.data and game.data.pokemon or require('src.core.Data').pokemon or{} + local key=type(species)=='number'and string.format('SPECIES_%03d',species)or species + local def=records[species]or records[key] + return def and(def.dex or def.nationalDex)or nil +end +function P.unownLetter(personality) + local bits=require('bit') + local n=tonumber(personality)or 0 + local value=0 + for shift=24,0,-8 do value=value*4+bits.band(bits.rshift(n,shift),3)end + return value%28 +end +return P diff --git a/src/compat/gen2/Unown.lua b/src/compat/gen2/Unown.lua new file mode 100644 index 000000000..1d385c7f2 --- /dev/null +++ b/src/compat/gen2/Unown.lua @@ -0,0 +1,13 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local U={} +local forms={};for n=1,26 do forms[n]=true end +function U.monLetter(mon) + if not mon or (mon.species~='UNOWN' and mon.species~='SPECIES_201' and mon.species~=201)then return nil end + local letter=mon.unownLetter + if type(letter)=='string'then letter=letter:upper():byte()-64 end + if type(letter)=='number' and letter%1==0 and letter>=1 and letter<=26 then return letter end + -- The native form selector owns the DV encoding. Its forms table is only + -- an availability check; all 26 forms are valid for this query. + return require('src.pokemon.Sprites').formIndex({forms=forms},mon) +end +return U diff --git a/src/core/Game.lua b/src/core/Game.lua index e11874b29..67a299d19 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1581,10 +1581,16 @@ end -- menus that used to call love.event.quit for the second case were killing -- the process instead, which is only the right answer on a build with no -- launcher behind it -- and HostShell decides that, not the menu. -function Game:returnToLauncher() +local function quitToLauncherBody() require("src.core.Music").stop() require("src.core.HostShell").toLauncher() end +function Game:returnToLauncher() + if ModRuntime.wantsHook("core.quit_to_launcher") then + return ModRuntime.call("core.quit_to_launcher", quitToLauncherBody) + end + return quitToLauncherBody() +end -- Unless an input.key wrapper swallows the event, releases reach Input -- even while a top state captures raw input: a diff --git a/src/import/pmd/Sprite.lua b/src/import/pmd/Sprite.lua new file mode 100644 index 000000000..4a10bd255 --- /dev/null +++ b/src/import/pmd/Sprite.lua @@ -0,0 +1,86 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Consume a published sprite pack through the native renderer. No ROM reader. +local Sprite={} +local directions={down=1,right=3,up=5,left=7} +local function positive(value,label) + assert(type(value)=='number'and value%1==0 and value>0 and value<=65536,label) + return value +end +function Sprite.definition(entry) + local meta=assert(entry.sprite,'sprite pack metadata is required') + local count=positive(entry.frames,'sprite pack frame count is required') + local def={image='asset_packs/pmd_red/sprites/'..assert(entry.file), + frames=count,walker=true,trueColor=true,cells={}} + for _,axis in ipairs({'Width','Height','Columns'})do + local value=positive(meta['frame'..axis],'invalid sprite pack frame '..axis) + def['frame'..axis],def['cell'..axis]=value,value + end + def.anchorX,def.anchorY=meta.anchorX,meta.anchorY + for n=0,count-1 do def.cells[n+1]={{tile=n,dx=0,dy=0}}end + return def +end +function Sprite.frame(meta,animation,direction,ticks) + local animations=assert(meta.animations,'sprite animations are required') + local group=assert(animations[(animation or 0)+1]or animations[1],'sprite animation is empty') + local frames=assert(meta.sequences[group[directions[direction]or 1]],'sprite direction is missing') + local duration=0 + for _,frame in ipairs(frames)do duration=duration+positive(frame[2],'invalid sprite frame duration')end + if duration==0 then return {1,1,0,0,0,0,0}end + local cursor=(tonumber(ticks)or 0)%duration + for _,frame in ipairs(frames)do + cursor=cursor-frame[2] + if cursor<0 then return frame end + end +end +local function active(self,facing) + return Sprite.frame(self._packAnimation,0,facing,self.pmdTicks) +end +local function step(self,moving) + self.pmdTicks=moving and self.pmdTicks+1 or 0 +end +local function draw(self,px,py,cx,cy,facing,phase,flip,top) + local frame=active(self,facing) + local native=require('src.render.SpriteRenderer') + self.offsetX,self.offsetY=self.anchorX-8,self.anchorY-16 + -- Fixed-frame rendering keeps the native full-color/depth behavior. Pack + -- anchor offsets replace the native cell-centered defaults on this instance. + return native.drawFixedFrame(self,px+(frame[3]or 0),py+(frame[4]or 0),cx,cy,frame[1]-1,false) +end +local function geometry(self,index) + local def=self.def + return {image=self.image,quad=self.frames[index], + x=index%def.frameColumns*def.frameWidth, + y=math.floor(index/def.frameColumns)*def.frameHeight, + width=def.frameWidth,height=def.frameHeight, + anchorX=self.anchorX,anchorY=self.anchorY} +end +local function pose(self,facing) + local frame=active(self,facing) + local out=geometry(self,frame[1]-1) + out.anchorX=out.anchorX-(frame[3]or 0) + out.anchorY=out.anchorY-(frame[4]or 0) + return out,false +end +function Sprite.new(entry,metadata) + local Native=require('src.render.SpriteRenderer') + local def=Sprite.definition(entry) + -- Avoid creating vertical-strip quads beyond a row-major pack sheet. + local count=def.frames;def.frames=1 + local renderer=Native.new(def);def.frames=count + local w,h=renderer.image:getDimensions() + assert(def.frameColumns*def.frameWidth<=w and math.ceil(count/def.frameColumns)*def.frameHeight<=h, + 'sprite pack frames exceed its image') + for n=0,count-1 do + renderer.frames[n]=love.graphics.newQuad(n%def.frameColumns*def.frameWidth, + math.floor(n/def.frameColumns)*def.frameHeight,def.frameWidth,def.frameHeight,w,h) + end + renderer.anchorX,renderer.anchorY=tonumber(def.anchorX)or 0,tonumber(def.anchorY)or 0 + renderer.offsetX,renderer.offsetY=renderer.anchorX-8,renderer.anchorY-16 + renderer.cellYBias=0 + renderer._packAnimation=metadata or entry.sprite + renderer.pmdTicks=0 + renderer.step,renderer.draw=step,draw + renderer.getFrameGeometry,renderer.getPoseGeometry=geometry,pose + return renderer +end +return Sprite diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 1bf8511b9..6585afe6e 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -34,6 +34,8 @@ add("hooks", "12", "ui.pc.items") add("hooks", "12345", "battle.status_hud_visible") add("hooks", "123", "pokemon.level_visible ui.party.grid_navigation") add("hooks", "123", "world.follower.spawn") +add("hooks", "2", "phone.contact_list breeding.compatibility catch.nickname catch.party_full battle.style battle.enemy_switch_or_item") +add("hooks", "12345", "core.quit_to_launcher") add("hooks", "12", "intro.oak_speech.build") add("events", "12345", [[ @@ -52,6 +54,7 @@ add("events", "12345", [[ ]]) add("events", "2345", "pokemon.egg_hatched") add("events", "2", "clock.day_changed") +add("events", "2", "radio.channel phone.call_received apricorn.converted breeding.egg_created egg.hatched happiness.changed pokerus.infected roamer.encountered roamer.moved unown.unlocked bug_contest.scored intro.boot.copyright intro.boot.gamefreak intro.boot.movie intro.boot.movie_ended intro.boot.title") add("events", "12", [[ intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started intro.oak_speech.step diff --git a/src/mods/Gen1Compat.lua b/src/mods/Gen1Compat.lua index fd427e7db..219d895d0 100644 --- a/src/mods/Gen1Compat.lua +++ b/src/mods/Gen1Compat.lua @@ -5,13 +5,12 @@ local C = {} C.ALIASES = { ['src.world.gen2.WorldAPI'] = 'src.world.WorldAPI', ['src.world.gen2.Follower'] = 'src.world.OptionalFollower', + ['src.world.game3.Follower'] = 'src.compat.game3.Follower', + ['src.core.game3.pokemon'] = 'src.compat.game3.Pokemon', + ['src.core.gen2.Unown'] = 'src.compat.gen2.Unown', } C.UNAVAILABLE = { ['src.import.pmd.Sprite'] = { 'mystery_dungeon_follower' }, - ['src.world.game3.Follower'] = { 'mystery_dungeon_follower' }, - ['src.core.gen2.Unown'] = { 'mystery_dungeon_follower' }, - ['src.core.game3.pokemon'] = { 'mystery_dungeon_follower' }, - ['src.core.game3.player'] = { 'mystery_dungeon_follower' }, } -- Concrete host modules and registered providers always take precedence. function C.hasProvider(name) diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 5f4ecb11f..9c083f1ed 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1499,7 +1499,10 @@ function Loader:load(data) tostring(id or "?")) end for id, mod in pairs(self.mods) do - if not self.disabled[id] and mod.manifest.experimental + local env=mod.manifest.force_enable_env + local forceEnabled=not self.noMods and env and os.getenv(env)=='1' + if forceEnabled then self.disabled[id]=nil end + if not forceEnabled and not self.disabled[id] and mod.manifest.experimental and ModGens.active(modsOpt[id], gen) ~= true then self.disabled[id] = true end diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index 19d80a8ab..a153a896e 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -253,6 +253,10 @@ function Manifest.validate(raw, path) local scriptFormat = raw.script_format == nil and defaultFormat or raw.script_format assert(scriptFormat == "native" or scriptFormat == "unified", "script_format must be native or unified") assert(scriptFormat ~= "unified" or api >= 2, "unified scripts require mod API 2") + local forceEnv=raw.force_enable_env + assert(forceEnv==nil or type(forceEnv)=='string' and forceEnv~='', + 'force_enable_env must be a nonempty string') + local logUrl=raw.log_url if logUrl~=nil then local bad=type(logUrl)~="string" or #logUrl>8192 or logUrl:find("[%z%c%s]") @@ -355,6 +359,7 @@ function Manifest.validate(raw, path) affects_link = affectsLink, permissions = permissions, permissionSet = permissionSet, + force_enable_env = forceEnv, script_format = scriptFormat, log_url = logUrl, options_schema = optionalFile(raw.options_schema, "options_schema"), diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 6ca3a3ee6..c9d2f0c9a 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -23,12 +23,15 @@ C.hooks = names([[ input.key input.gamepad input.wheel input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed music.select music.volume player.sprite pokemon.icon pokemon.level_visible pokemon.sprite render.compose render.hud render.letterbox render.zones save.new_game save.write screen.render_visible script.command trainer.before_battle trainer.party transition.style + phone.contact_list breeding.compatibility catch.nickname catch.party_full battle.style battle.enemy_switch_or_item core.quit_to_launcher ui.list_menu ui.naming.grid ui.options.rows ui.party.submenu ui.pc.items ui.start_menu.items ui.title_menu.items warp.destination world.follower.spawn world.talk world.tod zoom.range ]]) C.events = names([[ clock.day_changed world.live_maps_preparing world.live_maps_updated + radio.channel phone.call_received apricorn.converted breeding.egg_created egg.hatched happiness.changed pokerus.infected roamer.encountered roamer.moved unown.unlocked bug_contest.scored + intro.boot.copyright intro.boot.gamefreak intro.boot.movie intro.boot.movie_ended intro.boot.title assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt battle.ended battle.exp_gained battle.fainted battle.move_used battle.started battle.status_inflicted battle.turn_ended battle.turn_started discord.join_requested flag.changed game.ready intro.birch_speech.finished diff --git a/src/pokemon/DayCare.lua b/src/pokemon/DayCare.lua index e5a1c46c6..2cb866d15 100644 --- a/src/pokemon/DayCare.lua +++ b/src/pokemon/DayCare.lua @@ -296,7 +296,7 @@ end -- 5 = brimming with energy (the matching-DV rejection). The -- ordering is the ROM's too -- a pair that shares an OT ID is the *worst* -- match, and two of the same species from different trainers the best. -function DayCare.compatibility(data, save) +local function nativeCompatibility(data, save) local a, b = DayCare.pair(data, save) if not a then return 0 end local version = require("src.core.GameVersion").get() @@ -317,6 +317,35 @@ end DayCare.EGG_ODDS = { [0] = 0, [1] = 10 / 256, [2] = 40 / 256, [3] = 30 / 256, [4] = 80 / 256, [5] = 0 } +local compatibilityBytes={[0]=0,[1]=10,[2]=40,[3]=30,[4]=80,[5]=255} +local function evaluateCompatibility(data,save) + local tier=nativeCompatibility(data,save) + local byte=compatibilityBytes[tier] + local Runtime=require('src.mods.Runtime') + if not require('src.core.GameVersion').isGen2() or not Runtime.wantsHook("breeding.compatibility")then + return tier,byte + end + local context={data=data,mon1=DayCare.mon(save,1),mon2=DayCare.mon(save,2),dayCare=true} + local result=Runtime.call("breeding.compatibility",function(c) + local view={daycare={breed={{mon=c.mon1},{mon=c.mon2}}}} + return compatibilityBytes[nativeCompatibility(c.data,view)] + end,context) + local n=tonumber(result) + if not n or n~=n or math.abs(n)==math.huge then return tier,byte end + n=math.max(0,math.min(255,math.floor(n))) + -- Preserve the native public text tier. The step loop also receives the + -- exact byte so an authored probability need not round to a stock tier. + if n==0 then tier=0 elseif n==255 then tier=5 + elseif n<=10 then tier=1 elseif n<=30 then tier=3 elseif n<=40 then tier=2 else tier=4 end + return tier,n,true +end +local compatibilityReads=setmetatable({},{__mode='k'}) +function DayCare.compatibility(data,save) + local tier,byte,hooked=evaluateCompatibility(data,save) + if hooked and save then compatibilityReads[save]={tier=tier,byte=byte}end + return tier +end + -- --------------------------------------------------------------------------- -- HOENN'S OWN ANSWER TO THE SAME QUESTION (GetDaycareCompatibilityScore). -- @@ -553,7 +582,11 @@ function DayCare.step(data, save, expPerStep) end local version = require("src.core.GameVersion").get() local vanillaGen2 = version == "gold" or version == "silver" or version == "crystal" + compatibilityReads[save]=nil local tier = DayCare.compatibility(data, save) + local reading=compatibilityReads[save];compatibilityReads[save]=nil + local hooked=reading and reading.tier==tier + local compatibility=hooked and reading.byte or compatibilityBytes[tier] if vanillaGen2 then if tier == 0 or tier == 5 then return false end -- First attempt: 150..255 steps. Later attempts: a random byte; @@ -577,6 +610,7 @@ function DayCare.step(data, save, expPerStep) -- indifferent pair simply keeps walking. local odds = gen3 and (DayCare.gen3Score(data, save) / 100) or DayCare.EGG_ODDS[tier] + if vanillaGen2 and hooked then odds=compatibility/256 end if not gen3 and not vanillaGen2 then -- Preserve the existing shared-model behavior for other cartridges. odds = ({[0]=0,[1]=64/256,[2]=128/256,[3]=191/256,[4]=255/256})[tier] @@ -612,6 +646,12 @@ function DayCare.step(data, save, expPerStep) -- callback moves the MAN out to the fence -- that walk is the ROM's whole -- "an EGG is waiting" announcement, there is no phone call for it. DayCare.syncFlags(save) + local Runtime=require('src.mods.Runtime') + if require('src.core.GameVersion').isGen2() and Runtime.wants("breeding.egg_created")then + local mother,father=DayCare.parents(data,save) + Runtime.emit("breeding.egg_created",{egg=egg,mother=mother,father=father, + compatibility=compatibility,stepsToEgg=breed.stepsToEgg}) + end return true end diff --git a/src/pokemon/Gen2Friendship.lua b/src/pokemon/Gen2Friendship.lua index 7ef888c15..9cec9fc5c 100644 --- a/src/pokemon/Gen2Friendship.lua +++ b/src/pokemon/Gen2Friendship.lua @@ -11,6 +11,16 @@ local changes = { LEVELUPATHOME = {10,6,4}, } +local events={LEVELUP=1,VITAMIN=2,XITEM=3,GYMBATTLE=4,LEARNMOVE=5, + FAINT=6,POISONFAINT=7,STRONGFOE=8,BITTERPOWDER=15,ENERGYROOT=16,REVIVALHERB=17,LEVELUPATHOME=19} +local function changed(mon,from,event,reason) + local Runtime=require('src.mods.Runtime') + if from~=mon.happiness and Runtime.wants("happiness.changed")then + Runtime.emit("happiness.changed",{mon=mon,event=event,reason=reason, + from=from,to=mon.happiness,delta=mon.happiness-from}) + end +end + function Friendship.isVanilla() return require("src.pokemon.Gen2Breeding").isVanilla() end @@ -23,6 +33,7 @@ function Friendship.change(mon, reason) local h = mon.happiness local band = h < 100 and 1 or h < 200 and 2 or 3 mon.happiness = math.max(0, math.min(255, h + row[band])) + changed(mon,h,events[reason],"event") return true end @@ -34,7 +45,9 @@ function Friendship.stepCycle(save) if save.g2HappinessStepCount ~= 0 then return end for _, mon in ipairs(save.party or {}) do if not require("src.pokemon.Party").isEgg(mon) and mon.happiness ~= nil then + local from=mon.happiness mon.happiness = math.min(255, mon.happiness + 1) + changed(mon,from,nil,"step") end end end diff --git a/src/pokemon/Gen2Pokerus.lua b/src/pokemon/Gen2Pokerus.lua new file mode 100644 index 000000000..36094feb5 --- /dev/null +++ b/src/pokemon/Gen2Pokerus.lua @@ -0,0 +1,74 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native GSC party-byte transitions. Imported cured bytes retain immunity. +local P={} +local function value(mon) + local n=tonumber(mon and mon.pokerus)or 0 + if n~=n or math.abs(n)==math.huge then return 0 end + return math.max(0,math.floor(n))%256 +end +local function write(party,index,byte,source) + local mon=party[index];mon.pokerus=byte + local Runtime=require('src.mods.Runtime') + if Runtime.wants("pokerus.infected")then + Runtime.emit("pokerus.infected",{party=party,slot=index,mon=mon, + strain=math.floor(byte/16),days=byte%16,source=source}) + end + return index +end +function P.afterBattle(save,random) + if not require('src.pokemon.Gen2Breeding').isVanilla()then return end + local party=save and save.party or{} + if #party==0 then return end + random=random or function()return love.math.random(0,255)end + local carrier + for n,mon in ipairs(party)do if value(mon)%16>0 then carrier=n;break end end + if carrier then + if random()>=85 or #party==1 then return end + local direction=carrier==#party and -1 or (random()<129 and -1 or 1) + local byte=value(party[carrier]) + for target=carrier+direction,direction==1 and #party or 1,direction do + local nextByte=value(party[target]) + if nextByte==0 then + local strain=math.floor(byte/16) + return write(party,target,strain*16+strain%4+1,'spread') + end + if nextByte%4==0 then return end + byte=nextByte + end + return + end + local Flags=require('src.script.Flags') + if not Flags.get(save,require('src.script.Gen2Flags').engineFlag(21))then return end + if random()~=0 or random()>=3 then return end + local index + repeat index=random()%8+1 until index<=#party + if value(party[index])>=16 then return end + local roll + repeat roll=random()until roll~=0 + local strain=roll<16 and 0 or roll%8+1 + return write(party,index,strain*16+strain%4+1,'contracted') +end +function P.advance(party,days) + for _,mon in ipairs(party or{})do + local byte=value(mon) + if byte%16>0 then mon.pokerus=byte-byte%16+math.max(0,byte%16-days)end + end +end +local monthDays={0,31,59,90,120,151,181,212,243,273,304,334} +local function day(now) + local date=os.date('*t',now) + local y=date.year-1 + local ordinal=365*y+math.floor(y/4)-math.floor(y/100)+math.floor(y/400) + +monthDays[date.month]+date.day + if date.month>2 and date.year%4==0 and(date.year%100~=0 or date.year%400==0)then ordinal=ordinal+1 end + return ordinal +end +function P.poll(save,now) + if not save or not require('src.pokemon.Gen2Breeding').isVanilla()then return end + local today=day(now) + local previous=save.g2PokerusDay + save.g2PokerusDay=today + if type(previous)~='number' or previous==today then return end + P.advance(save.party,today 3 then placing = nil end s.placing = placing s.board = board + local Runtime=require('src.mods.Runtime') + if Runtime.wants("bug_contest.scored")then + Runtime.emit("bug_contest.scored",{mon=s.caught,score=s.caught and BugContest.score(s.caught)or 0, + place=placing or 0,results=board}) + end return placing, board end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 903e3a8b9..b764cc0ad 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -12818,6 +12818,12 @@ function OverworldState:hatchEgg(hatched) elseif GameVersion.isGen3() and Game.save then require("src.pokemon.Gen3GameStats").increment(Game.save, 13) -- GAME_STAT_HATCHED_EGGS end + local hatchObserver=GameVersion.isGen2() and Runtime.wants("egg.hatched") + local originalEgg,eggSlot + if hatchObserver then + originalEgg=require('src.mods.Merge').deepCopy(hatched) + for slot,mon in ipairs(Game.save.party or{})do if mon==hatched then eggSlot=slot;break end end + end local Pokemon = require("src.pokemon.Pokemon") local def = Game.data.pokemon[hatched.species] hatched.isEgg = nil @@ -12849,6 +12855,10 @@ function OverworldState:hatchEgg(hatched) local name = def and def.name or hatched.species local function finished() Runtime.emit("pokemon.egg_hatched", { mon = hatched }) + if hatchObserver then + Runtime.emit("egg.hatched",{mon=hatched,egg=originalEgg,slot=eggSlot, + species=originalEgg.species,nickname=hatched.nickname}) + end end local hatchText = Strings("Huh?\f%s hatched\nfrom the EGG!", name) local askText = gen2 and Strings("Give a nickname to\n%s?", name) or nil diff --git a/src/world/RoamMons.lua b/src/world/RoamMons.lua index 0286109bb..a04d4a846 100644 --- a/src/world/RoamMons.lua +++ b/src/world/RoamMons.lua @@ -104,6 +104,11 @@ function RoamMons.check(save, mapId, onWater, rng) -- which is the same statement. UpdateRoamMons stores the id it hopped to, -- so both sides of this are already normalised. if info.mapId ~= mapId then return nil end + local Runtime=require('src.mods.Runtime') + if Runtime.wants("roamer.encountered")then + Runtime.emit("roamer.encountered",{index=a,slot=info,species=info.species or name, + level=info.level or RoamMons.LEVEL,mapId=mapId}) + end return name, info end diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua index 35e00aece..7dc183f1a 100644 --- a/tests/engine/api3_compat.lua +++ b/tests/engine/api3_compat.lua @@ -124,6 +124,9 @@ do T.raises(function() m.events:on("checkpoint.restored", function() end) end, "unknown unified callback", "API-2 catalog unchanged") + T.check(pcall(function() + m.events:on("intro.boot.copyright", function() end) + end), "API-2 accepts the additive native boot notification") end -- Availability must be honest, and a probe cannot construct Game or attach a world. do diff --git a/tests/engine/compat_module_aliases.lua b/tests/engine/compat_module_aliases.lua index 583ba2234..de0ad9ceb 100644 --- a/tests/engine/compat_module_aliases.lua +++ b/tests/engine/compat_module_aliases.lua @@ -9,6 +9,12 @@ else T.raises(function()require('src.world.gen2.Follower')end,'src.world.OptionalFollower','alias identifies an unavailable native follower provider') end local names=require('src.mods.Gen1Compat') +for _,pair in ipairs({{'src.world.game3.Follower','src.compat.game3.Follower'}, + {'src.core.game3.pokemon','src.compat.game3.Pokemon'}, + {'src.core.gen2.Unown','src.compat.gen2.Unown'}})do + T.eq(require(pair[1]),require(pair[2]),'native adapter preserves require identity '..pair[1]) +end + for name in pairs(names.UNAVAILABLE)do if names.hasProvider(name)then local ok,module=pcall(require,name) @@ -30,10 +36,12 @@ end local providers=package.loaders or package.searchers local provided={marker='native-late-provider'} -local late=function(name)if name=='src.import.pmd.Sprite'then return function()return provided end end end +local lateName='src.compat.fixture.LateProvider' +names.UNAVAILABLE[lateName]={'contract_fixture'} +local late=function(name)if name==lateName then return function()return provided end end end providers[#providers+1]=late -T.eq(require('src.import.pmd.Sprite'),provided,'late concrete provider outranks the missing-module descriptor') -T.check(names.hasProvider('src.import.pmd.Sprite'),'loaded provider is recognized') -T.eq(names.unavailable('src.import.pmd.Sprite'),nil,'loaded provider has no missing-module diagnostic') -table.remove(providers);package.loaded['src.import.pmd.Sprite']=nil +T.eq(require(lateName),provided,'late concrete provider outranks the missing-module descriptor') +T.check(names.hasProvider(lateName),'loaded provider is recognized') +T.eq(names.unavailable(lateName),nil,'loaded provider has no missing-module diagnostic') +table.remove(providers);package.loaded[lateName]=nil;names.UNAVAILABLE[lateName]=nil T.finish() diff --git a/tests/engine/gen2_lifecycle_callbacks.lua b/tests/engine/gen2_lifecycle_callbacks.lua new file mode 100644 index 000000000..b2a2f9f24 --- /dev/null +++ b/tests/engine/gen2_lifecycle_callbacks.lua @@ -0,0 +1,233 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Owned data, real Loader/buses and production state transitions. IO only is +-- controlled; no callback producer is replaced with a test implementation. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local oldVersion=V.get();V.set('crystal') +local Json=require('src.link.Json');local Runtime=require('src.mods.Runtime') +local data=T.fixtures.fresh() +local source=[[ +local mod=... +local state={events={},odds=nil,nickname=nil,claim=false,quit=false,enemy=nil} +mod.exports.state=state +for _,name in ipairs({"happiness.changed","pokerus.infected","roamer.encountered","roamer.moved", + "breeding.egg_created","egg.hatched","unown.unlocked","bug_contest.scored", + "intro.boot.copyright","intro.boot.gamefreak","intro.boot.movie","intro.boot.movie_ended","intro.boot.title"})do + mod.events:on(name,function(value) + state.events[name]=state.events[name]or{};table.insert(state.events[name],value) + end) +end +mod.hooks:wrap("breeding.compatibility",function(next,c) + state.compatibility=c + if state.odds~=nil then return state.odds end + return next(c) +end) +mod.hooks:wrap("catch.nickname",function(next,mon,c) + state.catch={mon=mon,context=c} + if state.nickname~=nil then return state.nickname end + return next(mon,c) +end) +mod.hooks:wrap("catch.party_full",function(next,c) + state.full=c;return state.claim or next(c) +end) +mod.hooks:wrap("battle.style",function(next,battle)state.styleBattle=battle;return state.style or next(battle)end) +mod.hooks:wrap("battle.enemy_switch_or_item",function(next,battle) + state.battle=battle;if state.enemy~=nil then return state.enemy end;return next(battle) +end) +mod.hooks:wrap("core.quit_to_launcher",function(next)if state.quit then return false end;return next()end) +]] +local run=T.sdk.loadMods({'mods/lifecycle'},{data=data,fs=T.sdk.memfs({ + ['mods/lifecycle/manifest.json']=Json.encode({id='lifecycle',name='Lifecycle',version='1.0.0',api=2,entry='main.lua',games={'gen2'}}), + ['mods/lifecycle/main.lua']=source, +})}) +T.eq(#run.errors,0,'lifecycle observer loads through ordinary API-2 registration') +local state=assert(run.loader.exports.lifecycle.state) +local function events(name)return state.events[name]or{}end +local function last(name)local list=events(name);return list[#list]end +local Pokemon=require('src.pokemon.Pokemon') +local mon=Pokemon.new(data,'FIXMON_A',10,function(a)return a end);mon.happiness=254 +local Friendship=require('src.pokemon.Gen2Friendship') +Friendship.change(mon,'VITAMIN') +local changed=last("happiness.changed") +T.eq(changed.mon,mon,'friendship observer receives the live mon') +T.eq(changed.from,254,'friendship records the prior byte') +T.eq(changed.to,255,'friendship records the clamped byte') +T.eq(changed.delta,1,'friendship reports the actual change') +T.eq(changed.event,2,'vitamin maps to the public native event index') +T.eq(changed.reason,'event','friendship identifies event changes') +Friendship.change(mon,'VITAMIN');T.eq(#events("happiness.changed"),1,'clamped no-op does not announce a change') +mon.happiness=80 +local save={party={mon},flags={},player={name='TEST',id=1},pokedex={seen={},owned={}},options={}} +Friendship.stepCycle(save);T.eq(#events("happiness.changed"),1,'first 256-step cycle cannot announce a point') +Friendship.stepCycle(save);T.eq(last("happiness.changed").reason,'step','actual second cycle announces its point') +T.eq(last("happiness.changed").event,nil,'step changes carry no event index') + +local Roam=require('src.world.RoamMons') +save.g2RoamReleased=true;save.g2Roam={RAIKOU={mapId='OWN_MAP',active=true,level=40}} +T.eq(Roam.check(save,'OTHER_MAP',false,function()return 1 end),nil,'wrong map cannot announce a beast') +T.eq(#events("roamer.encountered"),0,'failed encounter stays silent') +T.eq(Roam.check(save,'OWN_MAP',false,function()return 1 end),'RAIKOU','real slot selection encounters the beast') +T.eq(last("roamer.encountered").slot,save.g2Roam.RAIKOU,'roamer record remains live') +T.eq(last("roamer.encountered").index,1,'roamer retains cartridge slot order') + +local Commands=require('src.script.Gen2Commands') +save.g2Roam.RAIKOU.mapId='ROUTE_29';save.g2RoamLastMap='ROUTE_40' +local oldWorldRandom=love.math.random +love.math.random=function(low,high)if low==0 and high==31 then return 1 end;return low end +Commands.g2_update_roam_positions({save=save,game={data=data,overworld={map={id='ROUTE_32'}}}}) +love.math.random=oldWorldRandom +T.eq(last("roamer.moved").from,'ROUTE_29','native route movement records the previous map') +T.eq(last("roamer.moved").to,'ROUTE_30','native route movement records its selected neighbor') +T.eq(last("roamer.moved").slot,save.g2Roam.RAIKOU,'movement observer sees the live native beast') + +local Contest=require('src.world.BugContest') +save.g2BugContest={caught=mon,scores={[2]={score=1,species='FIXMON_B'}}} +local game={data=data,save=save} +local place,board=Contest.judge(game) +T.eq(last("bug_contest.scored").place,place or 0,'contest announces the actual placing') +T.eq(last("bug_contest.scored").results,board,'contest announces the native sorted board') +T.eq(last("bug_contest.scored").score,Contest.score(mon),'contest announces the native computed score') + +local Virus=require('src.pokemon.Gen2Pokerus') +local party={{pokerus=0x32},{},{pokerus=0x20}} +local rolls={0,255};local cursor=0 +local function random()cursor=cursor+1;return assert(rolls[cursor],'unexpected infection RNG')end +T.eq(Virus.afterBattle({party=party},random),2,'actual active-byte spread writes the clean neighbor') +T.eq(party[2].pokerus,0x34,'spread derives days from its carrier strain') +T.eq(last("pokerus.infected").mon,party[2],'infection observer sees the written party record') +T.eq(last("pokerus.infected").party,party,'infection observer retains party identity') +T.eq(last("pokerus.infected").source,'spread','infection identifies its source') +Virus.advance(party,4) +T.eq(party[2].pokerus,0x30,'daily expiry keeps the immune strain') +T.eq(party[3].pokerus,0x20,'expiry preserves an already cured byte') +T.eq(#events("pokerus.infected"),1,'expiry cannot announce an infection') +local Flags=require('src.script.Gen2Flags') +local contracted={party={{}},flags={[Flags.engineFlag(21)]=true}} +rolls={0,2,0,19};cursor=0 +T.eq(Virus.afterBattle(contracted,random),1,'native rare roll contracts after the Goldenrod gate') +T.eq(last("pokerus.infected").source,'contracted','de novo infection has its own source') +T.eq(last("pokerus.infected").strain,4,'de novo strain comes from the sampled byte') +T.eq(last("pokerus.infected").days,1,'de novo countdown is stored before observer dispatch') +local start=os.time({year=2026,month=10,day=20,hour=12}) +Virus.poll(contracted,start);T.eq(contracted.party[1].pokerus,0x41,'first clock poll only seeds the clock') +Virus.poll(contracted,start+86400*2);T.eq(contracted.party[1].pokerus,0x40,'elapsed days cure the actual party byte') + +local DayCare=require('src.pokemon.DayCare') +local a=Pokemon.new(data,'FIXMON_A',10,function(n)return n end) +local b=Pokemon.new(data,'FIXMON_A',10,function(n)return n end) +a.otId,b.otId=1,2;a.dvs.defense,b.dvs.defense=1,2 +DayCare.deposit(save,1,a);DayCare.deposit(save,2,b) +T.eq(DayCare.compatibility(data,save),4,'native text tier remains unchanged by a forwarding hook') +T.eq(state.compatibility.mon1,a,'compatibility context names the first resident') +T.eq(state.compatibility.mon2,b,'compatibility context names the second resident') +T.eq(state.compatibility.dayCare,true,'compatibility identifies a native Day Care query') +state.odds=1;local pen=DayCare.store(save);pen.stepsToEgg=1 +local oldRandom=math.random +math.random=function(low,high)if low==0 and high==255 then return 0 end;return low or 0 end +local made=DayCare.step(data,save) +math.random=oldRandom +T.eq(made,true,'an authored non-stock byte reaches the real egg-producing step') +T.eq(last("breeding.egg_created").egg,pen.egg,'egg event identifies the actual pending egg') +T.eq(last("breeding.egg_created").compatibility,1,'egg event retains the exact authored probability') +T.eq(last("breeding.egg_created").mother,b,'egg event identifies the real mother') +state.odds=nil + +-- Actual queue construction at the native catch boundary, before any UI IO. +local Battle=require('src.battle.BattleState') +local function caught() + local foe=Pokemon.new(data,'FIXMON_B',10,function(n)return n end) + return setmetatable({game=game,data=data,enemy={mon=foe,name='OWN FOE'},queue={},kind='wild'},Battle),foe +end +state.nickname='OWNED NAME EXCEEDS LIMIT' +local battle,foe=caught();battle:storeCaughtMon() +T.eq(foe.nickname,'OWNED NAME','nickname contract applies the native ten-character limit') +T.eq(state.catch.mon,foe,'nickname hook receives the actual caught record') +T.eq(state.catch.context.battle,battle,'nickname context identifies the actual battle') +state.claim=true;save.party={mon,mon,mon,mon,mon,mon} +battle,foe=caught();battle:storeCaughtMon() +T.eq(state.full.mon,foe,'full-party hook receives the caught record') +T.eq(#save.party,6,'claimed catch cannot overfill the party') +T.eq(save.boxes,nil,'claimed catch does not also deposit to a native box') +state.claim=false;state.nickname=nil + +local unownDef=require('src.mods.Merge').deepCopy(data.pokemon.FIXMON_A) +unownDef.id,unownDef.dex,unownDef.evolutions='SPECIES_201',201,{} +unownDef.forms={};for n=1,26 do unownDef.forms[n]={}end +data.pokemon.SPECIES_201=unownDef;data.unown_dex={words={'OWN WORD'}} +local unown=Pokemon.new(data,'SPECIES_201',10,function(n)return n end) +unown.dvs={attack=0,defense=0,speed=0,special=0} +battle=caught();battle.enemy.mon=unown;state.claim=true;battle:storeCaughtMon() +T.eq(last("unown.unlocked").letter,1,'first native Unown capture unlocks its actual DV letter') +T.eq(last("unown.unlocked").word,'OWN WORD','Unown event reads the current imported word table') +T.eq(last("unown.unlocked").count,1,'Unown event reports the native ordered dex count') +battle=caught();battle.enemy.mon=unown;battle:storeCaughtMon() +T.eq(#events("unown.unlocked"),1,'catching a duplicate letter cannot unlock it again') +state.claim=false + +-- Dispatch the trainer decision through the real native enemy-action path. +battle,foe=caught();battle.player={mon=mon};battle.enemyParty={foe,Pokemon.new(data,'FIXMON_A',10,function(n)return n end)} +battle.enemyIndex=1;battle.trainer={id='OWN_AI'};battle.kind='trainer' +data.ai_classes=data.ai_classes or{};data.ai_classes.OWN_AI={} +state.enemy={kind='switch',index=2} +local action=battle:vanillaEnemyAction() +T.eq(action.special,'aiSwitch','canonical switch decisions enter the native action queue') +T.eq(action.index,2,'native switch retains the requested party slot') +T.eq(state.battle,battle,'enemy decision receives the actual battle') +state.enemy=true;action=battle:vanillaEnemyAction() +T.eq(action.special,'modEnemyTurn','a claimed enemy turn has an explicit native no-op action') +battle:executeAction(battle.enemy,battle.player,action) +T.eq(#battle.queue,0,'claimed enemy turn cannot also select a move') +state.enemy=nil +state.style='set';battle.expPaidPerSlot=true;battle.enemy.mon.hp=0 +battle:enemyMonFainted() +T.eq(state.styleBattle,battle,'native trainer replacement asks the style hook') + +-- Native hatching mutates the party record, then its real TextBox completion +-- boundary emits both old and new events. No producer is invoked directly. +local W=require('src.world.OverworldController');local slot,previous +for n=1,100 do local key,value=debug.getupvalue(W.hatchEgg,n) + if not key then break end;if key=='Game'then slot,previous=n,value;break end +end +assert(slot) +game.stack={states={}};function game.stack:push(screen)table.insert(self.states,screen)end +local egg=Pokemon.new(data,'FIXMON_A',5,function(n)return n end) +egg.isEgg=true;egg.eggSteps=1;save.party={egg} +debug.setupvalue(W.hatchEgg,slot,game) +T.eq(W.hatchEgg({},egg),true,'native hatching admits the actual party egg') +T.eq(#events("egg.hatched"),0,'egg event waits for the actual completion boundary') +game.stack.states[1].onDone();game.stack.states[2].choice(false) +local hatched=last("egg.hatched") +T.eq(hatched.mon,egg,'hatch event identifies the live party mon') +T.check(hatched.egg~=egg and hatched.egg.isEgg,'hatch event retains the original egg state') +T.eq(hatched.slot,1,'hatch event retains its party slot') +debug.setupvalue(W.hatchEgg,slot,previous) + +local Host=require('src.core.HostShell');local Music=require('src.core.Music') +local oldQuit,oldStop=Host.toLauncher,Music.stop;local quits=0 +Host.toLauncher=function()quits=quits+1 end;Music.stop=function()end -- external process/audio IO +local NativeGame=require('src.core.Game') +state.quit=true;T.eq(NativeGame:returnToLauncher(),false,'quit hook may retain the current game') +T.eq(quits,0,'a swallowed quit never reaches the host') +state.quit=false;NativeGame:returnToLauncher();T.eq(quits,1,'a forwarded quit reaches the host exactly once') +Host.toLauncher,Music.stop=oldQuit,oldStop + +local Intro=require('src.ui.Gen2Intro') +local stack={pop=function()end} +local introGame={data={field={intro={water='tests/fixture_data/assets/fixmon_a_front.png'}}}, + stack=stack,input={wasPressed=function()return false end}} +local intro=Intro.new(introGame,function()end);intro:enter() +T.eq(last("intro.boot.copyright").screen,intro,'first native branding stage announces copyright') +intro.scene=2;intro:enterScene() +T.eq(last("intro.boot.gamefreak").game,introGame,'second native branding stage identifies its game') +intro.scene=3;intro:enterScene() +T.eq(last("intro.boot.movie").screen,intro,'actual movie stage announces its screen') +intro._bootFrames=9;intro._bootSkipped=true;intro:finish();intro:finish() +T.eq(#events("intro.boot.movie_ended"),1,'movie completion cannot replay after a repeated finish') +T.eq(last("intro.boot.movie_ended").frames,9,'movie completion carries its frame count') +T.eq(last("intro.boot.movie_ended").skipped,true,'movie completion carries its skip decision') +local title=setmetatable({game=introGame,title={}},{__index=require('src.ui.TitleState')}) +title:enter();T.eq(last("intro.boot.title").screen,title,'actual title enter announces its screen') + + +require('src.mods.Loader').teardown();run.release();V.set(oldVersion) +T.finish('native Gen2 lifecycle callbacks through public mod registrations') diff --git a/tests/engine/manifest_force_enable.lua b/tests/engine/manifest_force_enable.lua new file mode 100644 index 000000000..18f8faf14 --- /dev/null +++ b/tests/engine/manifest_force_enable.lua @@ -0,0 +1,31 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local Loader=require('src.mods.Loader') +local V=require('src.core.GameVersion');local previous=V.get();V.set('gold') +local Json=require('src.link.Json');local Manifest=require('src.mods.Manifest') +local manifest={id='force_probe',name='Force probe',version='1.0.0',api=2,entry='main.lua', + games={'gen2'},force_enable_env='OWN_FORCE_ENABLE'} +T.eq(Manifest.validate(manifest).force_enable_env,'OWN_FORCE_ENABLE','manifest retains the optional variable name') +local getenv=os.getenv;local enabled +os.getenv=function(key)if key=='OWN_FORCE_ENABLE'then return enabled end;return getenv(key)end +local function load(opts) + local files={['mods/force_probe/manifest.json']=Json.encode(manifest), + ['mods/force_probe/main.lua']='local mod=...;mod.exports.loaded=true', + ['options.lua']='return {mods={force_probe=false}}'} + local loader=Loader.new({fs=T.sdk.memfs(files),dev=false,noMods=opts and opts.noMods}) + assert(loader:load(T.fixtures.fresh())) + return loader +end +for _,value in ipairs({'0','true','1'})do + enabled=value;local loader=load() + T.eq(loader.exports.force_probe~=nil,value=='1','only the explicit enabled environment value overrides a saved disable') + Loader.teardown() +end +enabled='1';local plain=load({noMods=true}) +T.eq(plain.exports.force_probe,nil,'no-mod boot keeps the discovery boundary') +Loader.teardown() +manifest.games={'gen3'};local wrong=load() +T.eq(wrong.exports.force_probe,nil,'force enable cannot bypass the declared game target') +Loader.teardown() +os.getenv=getenv;V.set(previous) +T.finish('manifest force-enable policy through the production Loader') diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua index 3fb17e290..b12a94eb7 100644 --- a/tests/engine/mod_supports.lua +++ b/tests/engine/mod_supports.lua @@ -77,8 +77,10 @@ for _, version in ipairs(GameVersion.ORDER) do version .. ': additive native flight interface is reported when installed') T.check(yes(api, 'hooks', 'core.logic_speed'), version .. ': native speed hook is advertised') - T.check(no(api, 'hooks', 'item.use') and no(api, 'events', 'egg.hatched'), + T.check(no(api, 'hooks', 'item.use'), version .. ': absent Gen1Recomp API-3 features are not advertised') + T.eq(yes(api, 'events', 'egg.hatched'), GameVersion.generation(version)==2, + version .. ': egg hatch observation follows the native Gen2 producer') T.check(yes(api, 'events', 'mod.probe.ready') and yes(api, 'events', 'mod.other.ready'), version .. ': namespaced user events do not need a producer installed') for _, name in ipairs(Catalog.hooks()) do diff --git a/tests/engine/pack_sprite_adapter.lua b/tests/engine/pack_sprite_adapter.lua new file mode 100644 index 000000000..d0e5df2ac --- /dev/null +++ b/tests/engine/pack_sprite_adapter.lua @@ -0,0 +1,47 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Sprite=require('src.import.pmd.Sprite') +local meta={frameWidth=32,frameHeight=24,frameColumns=2,anchorX=16,anchorY=24, + animations={{1,1,1,1,1,1,1,1}},sequences={{{1,2,3,4,0,0,0},{4,3,0,0,0,0,0}}}} +local entry={file='owned.png',frames=4,sprite=meta} +local def=Sprite.definition(entry) +T.eq(def.image,'asset_packs/pmd_red/sprites/owned.png','definition selects the published pack path') +T.eq(def.frameWidth,32,'definition retains native frame dimensions') +T.eq(def.cellColumns,2,'definition retains the consumer cell layout') +T.eq(def.cells[4][1].tile,3,'definition exposes zero-based tile cells') +T.eq(Sprite.frame(meta,0,'down',1),meta.sequences[1][1],'duration preserves the first frame') +T.eq(Sprite.frame(meta,0,'down',2),meta.sequences[1][2],'duration advances on its exact boundary') +T.eq(Sprite.frame(meta,0,'down',5),meta.sequences[1][1],'duration wraps after the whole sequence') +local oldImage,oldDraw=love.graphics.newImage,love.graphics.draw +love.graphics.newImage=function(path) + if path=='asset_packs/pmd_red/sprites/owned.png'then + return {getDimensions=function()return 64,48 end,getWidth=function()return 64 end,getHeight=function()return 48 end} + end + return oldImage(path) +end +local draw +love.graphics.draw=function(...)draw={...}end -- graphics IO capture only +local sprite=Sprite.new(entry) +T.eq(sprite.frames[3].x,32,'native quads use the row-major column') +T.eq(sprite.frames[3].y,24,'native quads use the row-major row') +sprite:step(true);sprite:step(true) +local pose,flip=sprite:getPoseGeometry('down') +T.eq(pose.x,32,'pose reports the animated atlas column') +T.eq(pose.y,24,'pose reports the animated atlas row') +T.eq(pose.anchorX,16,'pose retains the native anchor') +T.eq(flip,false,'pack direction frames do not inherit a native facing flip') +sprite.anchorX=20;sprite:draw(64,64,0,0,'down') +T.check(draw~=nil,'published pack frame reaches the real native fixed-frame renderer') +T.eq(draw[3],52,'a consumer anchor edit changes the native draw offset') +sprite:step(false);T.eq(sprite.pmdTicks,0,'stopping resets pack animation time') +love.graphics.newImage,love.graphics.draw=oldImage,oldDraw +local U=require('src.compat.gen2.Unown') +T.eq(U.monLetter({species='OTHER'}),nil,'Unown query rejects other species') +T.eq(U.monLetter({species='UNOWN',unownLetter='Z'}),26,'Unown query accepts a stored letter') +T.eq(U.monLetter({species='SPECIES_201',dvs={attack=0,defense=0,speed=0,special=0}}),1, + 'Unown query delegates the native DV selector') +local P=require('src.compat.game3.Pokemon') +T.eq(P.unownLetter(0),0,'GBA personality query is zero-based') +T.eq(P.unownLetter(0x03030303),255%28,'GBA personality query keeps two bits from each byte') +T.finish('published sprite-pack renderer and follower query adapters') diff --git a/tests/fixtures/upstream_mods/data.lua b/tests/fixtures/upstream_mods/data.lua index db0b5d609..b0269caf1 100644 --- a/tests/fixtures/upstream_mods/data.lua +++ b/tests/fixtures/upstream_mods/data.lua @@ -8,11 +8,12 @@ end function M.fresh() local data=require('tests.modkit.fixtures').fresh() local seed=data.pokemon.FIXMON_A - for index,id in ipairs({'MEW','VENUSAUR','CHARIZARD','BLASTOISE','PIDGEY','RATTATA','SPEAROW','CHARMANDER'}) do + for index,id in ipairs({'MEW','VENUSAUR','CHARIZARD','BLASTOISE','PIDGEY','RATTATA','SPEAROW','CHARMANDER','PIKACHU'}) do local mon=copy(seed);mon.id=id;mon.name=id;mon.index=index;mon.dex=index data.pokemon[id]=mon end data.pokemon.MEW.dex=151 + data.pokemon.PIKACHU.dex=25 for _,id in ipairs({'TACKLE','GROWL','QUICK_ATTACK','CUT','SURF'}) do data.moves[id]=copy(data.moves.FIX_TACKLE);data.moves[id].id=id end diff --git a/tests/fixtures/upstream_mods/worker.lua b/tests/fixtures/upstream_mods/worker.lua index e99379d1d..fa6141723 100644 --- a/tests/fixtures/upstream_mods/worker.lua +++ b/tests/fixtures/upstream_mods/worker.lua @@ -20,7 +20,12 @@ local function pack(importer,kind,name,entry) end pack('lttp','sprites','player',{file='synthetic.png',size=4,width=16,height=24}) pack('lttp','tiles','kakariko',{file='synthetic.png',size=4,width=1024,height=1024}) -pack('pmd_red','sprites','pikachu',{file='synthetic.png',size=4,width=16,height=24,sprite={dexNumber=25,monsterId=25}}) +local pmdMeta={dexNumber=25,monsterId=25,frameWidth=16,frameHeight=24,frameColumns=1, + anchorX=8,anchorY=24,animations={{1,1,1,1,1,1,1,1}},sequences={{{1,2,0,0,0,0,0}}}} +local pmdBytes=Writer.encode(pmdMeta) +pack('pmd_red','sprites','pikachu',{file='synthetic.png',size=4,width=16,height=24,frames=1, + sprite=pmdMeta,metadata={file='synthetic-meta.lua',size=#pmdBytes}}) +overlay[I.assetPath('pmd_red','sprites','synthetic-meta.lua')]=pmdBytes local function remap(path) if path==mount then return 'mods/'..fixture end if path:sub(1,#mount+1)==mount..'/' then return 'mods/'..fixture..path:sub(#mount+1) end @@ -91,5 +96,29 @@ if loaded and id=='example_mew_starter' and version=='firered' then assert(world.map.id=='MAP_G04_N03','profile renamed the native map') functional={species=mon.species,level=mon.level,nickname=mon.nickname,variable=save.gen3Vars[0x4002]} end +if loaded and id=='mystery_dungeon_follower' then + local Native=require('src.core.Game') + local previous={Native.data,Native.save,Native.overworld} + local image=love.graphics.newImage + love.graphics.newImage=function(path) + if path=='asset_packs/pmd_red/sprites/synthetic.png'then + return {getDimensions=function()return 16,24 end,getWidth=function()return 16 end,getHeight=function()return 24 end} + end + return image(path) + end + local npc={optionalFollower=true,pikachuFollower=true,px=0,py=0,moving=true} + local world={player={px=16,py=0,visible=true},npcs={npc}} + local save={party={{species='PIKACHU',hp=10,dvs={attack=0,defense=0,speed=0,special=0}}}} + Native.data,Native.save,Native.overworld=data,save,world + local count=#loader.errors + local spawn=loader.hooks:call('world.follower.spawn',function()return false end,Native,world) + assert(spawn and npc.sprite,'unchanged follower did not install its published pack renderer') + local stepped=loader.hooks:call('input.step',function(_,dt)return dt end,Native,0.25) + assert(stepped==0.25 and npc.sprite.pmdTicks==1,'unchanged follower did not advance its sprite') + assert(#loader.errors==count,table.concat(loader.errors,'; ')) + functional={spawned=true,stepped=true,anchorX=npc.sprite.anchorX,anchorY=npc.sprite.anchorY} + love.graphics.newImage=image + Native.data,Native.save,Native.overworld=unpack(previous) +end print('FIXTURE_RESULT '..Json.encode({fixture=fixture,version=version,loaded=loaded and true or false,hooks=names,error=errorText,functional=functional})) if not loaded then os.exit(1)end diff --git a/tools/check_external_mod_fixtures.py b/tools/check_external_mod_fixtures.py index b5ff89d33..f09f350b3 100644 --- a/tools/check_external_mod_fixtures.py +++ b/tools/check_external_mod_fixtures.py @@ -46,7 +46,8 @@ def main(): for name, digest in fixture['files'].items(): if hashlib.sha256((shipped / name).read_bytes()).hexdigest() != digest: raise RuntimeError('shipped gallery bytes differ: ' + fixture['path'] + '/' + name) - versions = ('red', 'firered') if fixture['path'] == 'example_mew_starter' else ('red',) + versions = (('red', 'firered') if fixture['path'] == 'example_mew_starter' else + ('red', 'gold', 'crystal', 'firered') if fixture['path'] == 'mystery_dungeon_follower' else ('red',)) for version in versions: run = qa_run([args.lua, str(root / 'tests/fixtures/upstream_mods/worker.lua'), str(reference), fixture['path'], version], cwd=root, diff --git a/tools/check_gen2_lifecycle_profile.py b/tools/check_gen2_lifecycle_profile.py new file mode 100644 index 000000000..d5d1aeb2a --- /dev/null +++ b/tools/check_gen2_lifecycle_profile.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Measure guarded native lifecycle producers before/after and with observers.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +import time + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--love',type=Path,required=True) + parser.add_argument('--baseline',required=True) + parser.add_argument('--report',type=Path,required=True) + parser.add_argument('--qa-tools',type=Path) + args=parser.parse_args() + root=Path(__file__).resolve().parents[1] + sys.path.insert(0,str(args.qa_tools or root/'tools')) + from qa_storage import qa_source,qa_cache,qa_copy,qa_profile,qa_run + qa_source(root);qa_source(args.love.resolve()) + qa_cache(args.report.parent);args.report.parent.mkdir(parents=True,exist_ok=True) + snapshot=args.report.parent/'baseline' + qa_copy(snapshot);snapshot.mkdir(parents=True,exist_ok=True) + paths={'Gen2Friendship':'src/pokemon/Gen2Friendship.lua', + 'RoamMons':'src/world/RoamMons.lua','BugContest':'src/world/BugContest.lua'} + hashes={} + for name,path in paths.items(): + data=qa_run(['git','show',args.baseline+':'+path],cwd=root,capture_output=True,check=True).stdout + (snapshot/(name+'.lua')).write_bytes(data) + hashes[path]={'before':hashlib.sha256(data).hexdigest(), + 'after':hashlib.sha256((root/path).read_bytes()).hexdigest()} + identity='oasc-gen2-lifecycle-qa-'+str(time.time_ns()) + qa_profile(Path.home()/'Library/Application Support/LOVE'/identity) + run=qa_run([str(args.love.resolve()),str(root/'tools/gen2-lifecycle-profile')],cwd=root, + env=dict(os.environ,GEN2_LIFECYCLE_PROFILE_QA='1',GEN2_LIFECYCLE_ROOT=str(root), + GEN2_LIFECYCLE_BASELINE=str(snapshot.resolve()),POKEPORT_IDENTITY=identity), + capture_output=True,text=True,timeout=90) + args.report.with_suffix('.log').write_text(run.stdout+run.stderr) + profiles=[line.removeprefix('LIFECYCLE_PROFILE ')for line in run.stdout.splitlines() + if line.startswith('LIFECYCLE_PROFILE ')] + if run.returncode or len(profiles)!=1:raise RuntimeError('native lifecycle probe failed; see its log') + for path,digests in hashes.items(): + if hashlib.sha256((root/path).read_bytes()).hexdigest()!=digests['after']: + raise RuntimeError('measured source changed during the probe') + report=json.loads(profiles[0]);report.update(baseline=args.baseline,sha256=hashes,playerProfilesUsed=False) + args.report.write_text(json.dumps(report,indent=2)+'\n') + print(json.dumps(report,indent=2)) + + +if __name__=='__main__':main() diff --git a/tools/gen2-lifecycle-profile/conf.lua b/tools/gen2-lifecycle-profile/conf.lua new file mode 100644 index 000000000..21e56512f --- /dev/null +++ b/tools/gen2-lifecycle-profile/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv('GEN2_LIFECYCLE_PROFILE_QA')=='1','use the guarded lifecycle checker') + t.identity=assert(os.getenv('POKEPORT_IDENTITY')) + t.window.title='Native Gen2 lifecycle profile' + t.window.width,t.window.height,t.window.vsync=480,240,1 + t.modules.audio=false +end diff --git a/tools/gen2-lifecycle-profile/main.lua b/tools/gen2-lifecycle-profile/main.lua new file mode 100644 index 000000000..0f5cb9125 --- /dev/null +++ b/tools/gen2-lifecycle-profile/main.lua @@ -0,0 +1,76 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local root=assert(os.getenv('GEN2_LIFECYCLE_ROOT')) +package.path=root..'/?.lua;'..root..'/?/init.lua;'..package.path +require('src.core.GameVersion').set('crystal') +local Profile=require('src.core.FrameProfile') +local Json=require('src.link.Json') +local SDK=require('tests.modkit.sdk') +local Loader=require('src.mods.Loader') +local before,after={},{} +for _,name in ipairs({'Gen2Friendship','RoamMons','BugContest'})do + before[name]=assert(loadfile(assert(os.getenv('GEN2_LIFECYCLE_BASELINE'))..'/'..name..'.lua'))() + after[name]=require((name=='Gen2Friendship'and 'src.pokemon.'or 'src.world.')..name) +end +local phase,frame,total,loader,save,game,modules=1,0,0 +local results={} +local function start() + Profile.setEnabled(false) + local data=require('tests.modkit.fixtures').fresh() + local enabled=phase~=2 + local files={ + ['mods/probe/manifest.json']=Json.encode({id='probe',name='Probe',version='1.0.0',api=2,entry='main.lua',games={'gen2'}}), + ['mods/probe/main.lua']=[[local mod=... + mod.exports.count=0 + for _,name in ipairs({'happiness.changed','roamer.encountered','bug_contest.scored'})do + mod.events:on(name,function()mod.exports.count=mod.exports.count+1 end) + end]], + ['options.lua']='return {mods={probe='..tostring(enabled)..'}}', + } + loader=Loader.new({fs=SDK.memfs(files),dev=false});assert(loader:load(data)) + local mon={happiness=70,hp=100,maxHp=100,species='FIXMON_A',dvs={},stats={attack=50,defense=50,speed=50,special=50}} + save={party={mon},flags={},g2RoamReleased=true,g2Roam={RAIKOU={active=true,mapId='OWN_MAP',level=40}}, + g2BugContest={caught=mon,scores={}}} + game={data=data,save=save};modules=phase==1 and before or after + frame,total=0,0 +end +local function rng()return 1 end +function love.errorhandler(message) + print(debug.traceback(tostring(message),2));return function()return 1 end +end +function love.run() + start() + return function() + love.event.pump() + for event in love.event.poll()do if event=='quit'then return 1 end end + if frame==120 then Profile.setEnabled(true)end + local close=Profile.section('mods.lifecycle (192 transitions)') + local began=love.timer.getTime() + for _=1,64 do + local mon=save.party[1];mon.happiness=70 + modules.Gen2Friendship.change(mon,'LEVELUP') + assert(modules.RoamMons.check(save,'OWN_MAP',false,rng)=='RAIKOU') + modules.BugContest.judge(game) + end + if frame>=120 then total=total+love.timer.getTime()-began end + close() + love.graphics.clear(.03,.04,.06) + love.graphics.print('Native lifecycle: '..({'before','after, no observer','after, observer'})[phase],12,12) + love.graphics.present() + if frame>=120 then Profile.frame()end + frame=frame+1 + if frame==240 then + results[phase]={mode=({'before','after_no_observer','after_observer'})[phase], + frames=120,meanMs=total/120*1000,observations=loader.exports.probe and loader.exports.probe.count or 0} + assert(#loader.errors==0,table.concat(loader.errors,'; ')) + Loader.teardown() + if phase==3 then + assert(results[1].observations==0 and results[2].observations==0 and results[3].observations==240*192) + print('LIFECYCLE_PROFILE '..Json.encode({phases=results,renderer=love.graphics.getRendererInfo(), + os=love.system.getOS(),arch=jit.arch, + scope='Native Love host, real Loader observer and three production producers over owned data; no cartridge scene or FPS claim'})) + return 0 + end + phase=phase+1;start() + end + end +end From ce4d894c79965786ca31c019029b74b9521823d3 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:25:53 +0100 Subject: [PATCH 106/138] Keep concrete player providers ahead of the live field fallback --- docs/gen2-lifecycle-callbacks.md | 3 ++- src/compat/game3/Player.lua | 12 ++++++++++++ src/mods/Gen1Compat.lua | 1 + tests/engine/compat_module_aliases.lua | 7 +++++++ 4 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 src/compat/game3/Player.lua diff --git a/docs/gen2-lifecycle-callbacks.md b/docs/gen2-lifecycle-callbacks.md index fb2cd7294..52d60f967 100644 --- a/docs/gen2-lifecycle-callbacks.md +++ b/docs/gen2-lifecycle-callbacks.md @@ -45,7 +45,8 @@ ownership and bounded transport behavior. Published sprite packs can be consumed through `src.import.pmd.Sprite` using native fixed-frame rendering, row-major quads, timing and mutable anchors. Follower, species and Unown query adapters cover the unchanged follower -consumer. The existing live player facade remains its provider. No ROM reader +consumer. A concrete live player provider takes precedence; other hosts use +a small live-field fallback. No ROM reader is introduced. Authenticated follower entrypoints, spawn and animation hooks run unchanged for Red, Gold, Crystal and FireRed over owned synthetic pack data. diff --git a/src/compat/game3/Player.lua b/src/compat/game3/Player.lua new file mode 100644 index 000000000..f708e02e4 --- /dev/null +++ b/src/compat/game3/Player.lua @@ -0,0 +1,12 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Field-only fallback for hosts without a concrete live player provider. +local function player() + local game=package.loaded['src.core.Game'] + return game and game.overworld and game.overworld.player +end +return setmetatable({},{__index=function(_,key) + local current=player();return current and current[key] +end,__newindex=function(_,key,value) + local current=assert(player(),'native player is unavailable') + current[key]=value +end}) diff --git a/src/mods/Gen1Compat.lua b/src/mods/Gen1Compat.lua index 219d895d0..00cae7bc5 100644 --- a/src/mods/Gen1Compat.lua +++ b/src/mods/Gen1Compat.lua @@ -7,6 +7,7 @@ C.ALIASES = { ['src.world.gen2.Follower'] = 'src.world.OptionalFollower', ['src.world.game3.Follower'] = 'src.compat.game3.Follower', ['src.core.game3.pokemon'] = 'src.compat.game3.Pokemon', + ['src.core.game3.player'] = 'src.compat.game3.Player', ['src.core.gen2.Unown'] = 'src.compat.gen2.Unown', } C.UNAVAILABLE = { diff --git a/tests/engine/compat_module_aliases.lua b/tests/engine/compat_module_aliases.lua index de0ad9ceb..a8b848fb2 100644 --- a/tests/engine/compat_module_aliases.lua +++ b/tests/engine/compat_module_aliases.lua @@ -8,6 +8,13 @@ if loadfile('src/world/OptionalFollower.lua') then else T.raises(function()require('src.world.gen2.Follower')end,'src.world.OptionalFollower','alias identifies an unavailable native follower provider') end +local sourcePlayer=require('src.core.game3.player') +local fallbackPlayer=require('src.compat.game3.Player') +if loadfile('src/core/game3/player.lua')then + T.neq(sourcePlayer,fallbackPlayer,'concrete live player provider takes precedence over the field fallback') +else + T.eq(sourcePlayer,fallbackPlayer,'host without a concrete player provider receives the live field fallback') +end local names=require('src.mods.Gen1Compat') for _,pair in ipairs({{'src.world.game3.Follower','src.compat.game3.Follower'}, {'src.core.game3.pokemon','src.compat.game3.Pokemon'}, From fa01a1df6fcc17df2efe711e3bec26f4defb207e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:31:49 +0100 Subject: [PATCH 107/138] Exercise force-enable policy against the native generation declaration --- docs/gen2-lifecycle-callbacks.md | 2 +- tests/engine/manifest_force_enable.lua | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/gen2-lifecycle-callbacks.md b/docs/gen2-lifecycle-callbacks.md index 52d60f967..db873eb69 100644 --- a/docs/gen2-lifecycle-callbacks.md +++ b/docs/gen2-lifecycle-callbacks.md @@ -38,7 +38,7 @@ The event runs after the byte is written. This contribution concerns these party-byte transitions; it does not claim an audit of all Pokérus consumers. `force_enable_env` retains a manifest variable name. Only a value of `1` -overrides a saved disable. Game targeting and no-mod discovery remain in +overrides a saved disable. Native generation targeting and no-mod discovery remain in effect. Existing `log_url` and `mod:postLog` keep their network permission, ownership and bounded transport behavior. diff --git a/tests/engine/manifest_force_enable.lua b/tests/engine/manifest_force_enable.lua index 18f8faf14..8fed30338 100644 --- a/tests/engine/manifest_force_enable.lua +++ b/tests/engine/manifest_force_enable.lua @@ -4,7 +4,7 @@ local T=require('tests.modkit');local Loader=require('src.mods.Loader') local V=require('src.core.GameVersion');local previous=V.get();V.set('gold') local Json=require('src.link.Json');local Manifest=require('src.mods.Manifest') local manifest={id='force_probe',name='Force probe',version='1.0.0',api=2,entry='main.lua', - games={'gen2'},force_enable_env='OWN_FORCE_ENABLE'} + games={'gen2'},generations={2},force_enable_env='OWN_FORCE_ENABLE'} T.eq(Manifest.validate(manifest).force_enable_env,'OWN_FORCE_ENABLE','manifest retains the optional variable name') local getenv=os.getenv;local enabled os.getenv=function(key)if key=='OWN_FORCE_ENABLE'then return enabled end;return getenv(key)end @@ -24,8 +24,8 @@ end enabled='1';local plain=load({noMods=true}) T.eq(plain.exports.force_probe,nil,'no-mod boot keeps the discovery boundary') Loader.teardown() -manifest.games={'gen3'};local wrong=load() -T.eq(wrong.exports.force_probe,nil,'force enable cannot bypass the declared game target') +manifest.games={'gen3'};manifest.generations={3};local wrong=load() +T.eq(wrong.exports.force_probe,nil,'force enable cannot bypass the declared generation target') Loader.teardown() os.getenv=getenv;V.set(previous) T.finish('manifest force-enable policy through the production Loader') From ea8905e19a7d317b25c6a01ece2181d183e4b39a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:26:36 +0100 Subject: [PATCH 108/138] Keep native Gen2 daily services on the saved RTC midnight --- docs/native-gen2-clock.md | 42 ++++++--- src/core/Gen2Clock.lua | 77 +++++++++++++---- src/script/Gen2Daily.lua | 20 ++--- tests/engine/gen2_clock.lua | 15 ++-- tests/engine/gen2_clock_lifecycle.lua | 119 ++++++++++++++++++++++++++ 5 files changed, 231 insertions(+), 42 deletions(-) create mode 100644 tests/engine/gen2_clock_lifecycle.lua diff --git a/docs/native-gen2-clock.md b/docs/native-gen2-clock.md index d19b13c60..27fa48ccb 100644 --- a/docs/native-gen2-clock.md +++ b/docs/native-gen2-clock.md @@ -7,12 +7,31 @@ the native happiness evolution gate and observational encounter summaries. The existing `src.core.gen2.Clock` name resolves to this native module. `world.hour()`, `world.minute()` and `world.weekday()` read that same save. -`setTime(save, hour, minute)` stores `rtc.startMinute`, an offset from the -host RTC, rather than a frozen hour. `setWeekday` stores `rtc.startDay` and -keeps the older native `g2DayOffset` field synchronized. Old saves without -an RTC base retain their host-hour and existing weekday-offset behavior. -Read calls never create clock data. Invalid/non-finite writes are rejected. -The normal native save codec preserves the offsets; play time is separate. +`setTime(save, hour, minute)` anchors the chosen minute, current game +weekday and calendar date to elapsed RTC seconds. Hours, weekdays and daily +calendar identity cross the same in-game midnight, including after a saved +session has been closed for days. Automatic host DST and time-zone changes do +not add another hour to a clock the player already set. Explicit system clock +adjustments still affect the host RTC; this is not a monotonic stopwatch. + +The legacy `rtc.startMinute`, `rtc.startDay` and `g2DayOffset` projections are +retained for older readers. New writes also store `anchorTime`, `anchorMinute`, +`anchorDay` and `anchorDate` in the same `save.rtc` record. Consumers should use +the native setters/readers instead of mutating these projections directly. +`setWeekday` edits the current game weekday without changing elapsed calendar +age. Editing time reanchors the clock without resetting its current date. + +Old saves without the elapsed anchor retain their original host-hour and +weekday-offset semantics, including their old midnight relationship, until +the clock is explicitly set. Read calls never create or migrate clock data. +Invalid/non-finite writes are rejected. The normal native save codec retains +both formats; elapsed play time is separate. + +`calendarDate(save, now)` and `dateKey(save, now)` give native daily and weekly +services the calendar belonging to that RTC. Daily flags/fruit use and the +lottery now expire on game midnight and the selected game Friday instead of +an unrelated host-calendar boundary. The optional timestamp on clock reads +uses the same snapshot for minute, weekday and calendar queries. World palettes, object-load period bits and `VAR_HOUR` share the saved hour; the normal object-load freeze still applies. An explicit palette-query hour @@ -23,7 +42,9 @@ the current native save, independently of a room's forced palette. `clock.day_changed` is produced by actual weekday reads, including the live world clock poll. It reports `day`, `previous` and `reason` (`rollover` or `set`). A save's first observation is silent. Observations are per save, -reentrant-safe, subscription-aware and retired on mod/edition reload. +reentrant-safe, subscription-aware and retired on mod/edition reload. An +observed offline whole week still produces one change, even when the weekday +number is the same; its calendar date is different. Native API 1/2 and unified API 2/3 use the normal event dispatch boundaries; other generations do not advertise this native GSC seam. @@ -32,9 +53,10 @@ are exercised directly in synthetic engine tests, including minute wrap, weekly rollover, save roundtrip, RTC-versus-play-time display, both callback paths and actual mod loading. Graphics IO alone is replaced for the clock card. These checks do not establish a complete original-story clock-setting -sequence, DST, battery-backed SRAM RTC conversion, all daily scheduling or -fresh GPU acceptance for this change. Existing daily date keys still use -host calendar dates; lottery weekday selection now shares the saved weekday. +sequence, manual DST-setting/check dialogs, battery-backed SRAM RTC +conversion, all daily services or fresh GPU acceptance for this change. +Automatic host DST isolation is covered; the original story clock-setting +screen and remaining manual DST lifecycle still need native implementation. The public contract is compared with the read-only Gen1Recomp reference; the implementation is native fork code. No VASC implementation, private diff --git a/src/core/Gen2Clock.lua b/src/core/Gen2Clock.lua index 1c8d4d996..f0f564663 100644 --- a/src/core/Gen2Clock.lua +++ b/src/core/Gen2Clock.lua @@ -12,18 +12,31 @@ end local function rtc(save) return type(save) == "table" and type(save.rtc) == "table" and save.rtc or nil end -function Clock.hostMinutes() - return ((integer(os.date("%H")) or 0) * 60 + (integer(os.date("%M")) or 0)) % 1440 +function Clock.hostMinutes(now) + return ((integer(os.date("%H", now)) or 0) * 60 + (integer(os.date("%M", now)) or 0)) % 1440 end function Clock.hostWeekday(now) return (integer(os.date("%w", now)) or 0) % 7 end -function Clock.minutes(save) +-- Newly set clocks advance by elapsed RTC seconds, including while closed. +-- The old host-local offsets remain readable without migrating a save. Host +-- DST/time-zone changes must not silently change an explicitly set game clock. +local function anchored(state, now) + if not state then return nil end + local at, minute, day = integer(state.anchorTime), integer(state.anchorMinute), integer(state.anchorDay) + if not at or not minute or minute < 0 or minute >= 1440 or not day or day < 0 or day >= 7 then return nil end + now = integer(now or os.time()) + if not now then return nil end + return minute + math.floor((now - at) / 60), day +end +function Clock.minutes(save, now) local state = rtc(save) - return (Clock.hostMinutes() + (state and integer(state.startMinute) or 0)) % 1440 + local total = anchored(state, now) + if total then return total % 1440 end + return (Clock.hostMinutes(now) + (state and integer(state.startMinute) or 0)) % 1440 end -function Clock.hour(save) return math.floor(Clock.minutes(save) / 60) end -function Clock.minute(save) return Clock.minutes(save) % 60 end +function Clock.hour(save, now) return math.floor(Clock.minutes(save, now) / 60) end +function Clock.minute(save, now) return Clock.minutes(save, now) % 60 end function Clock.isSet(save) local state = rtc(save) return state ~= nil and integer(state.startMinute) ~= nil @@ -32,42 +45,76 @@ function Clock.setTime(save, hour, minute) if type(save) ~= "table" then return false end hour, minute = integer(hour or 0), integer(minute or 0) if not hour or not minute then return false end + local now = os.time() + local day, date = Clock.weekday(save, now), Clock.dateKey(save, now) + local wanted = (hour % 24) * 60 + minute % 60 save.rtc = rtc(save) or {} - save.rtc.startMinute = ((hour % 24) * 60 + minute % 60 - Clock.hostMinutes()) % 1440 + save.rtc.startMinute = (wanted - Clock.hostMinutes(now)) % 1440 + save.rtc.startDay = (day - Clock.hostWeekday(now)) % 7 + save.g2DayOffset = save.rtc.startDay + save.rtc.anchorTime, save.rtc.anchorMinute = now, wanted + save.rtc.anchorDay, save.rtc.anchorDate = day, date return true end -local function note(save, day, reason) +local function note(save, day, reason, now) if type(save) ~= "table" then return day end local Runtime = require("src.mods.Runtime") if not require("src.core.GameVersion").isGen2() or not Runtime.wants("clock.day_changed") then observed[save] = nil return day end - local previous = observed[save] - observed[save] = day -- Commit before dispatch: reentrant reads cannot emit twice. - if previous ~= nil and previous ~= day then - Runtime.emit("clock.day_changed", { day = day, previous = previous, reason = reason }) + local previous, date = observed[save], Clock.dateKey(save, now) + observed[save] = { day=day, date=date } -- Commit before dispatch; reentrant reads are silent. + if previous ~= nil and (previous.day ~= day or previous.date ~= date) then + Runtime.emit("clock.day_changed", { day = day, previous = previous.day, reason = reason }) end return day end function Clock.weekday(save, now) local state = rtc(save) + local total, day = anchored(state, now) + if total then return note(save, (day + math.floor(total / 1440)) % 7, "rollover", now) end local offset = state and integer(state.startDay) if offset == nil then offset = type(save) == "table" and integer(save.g2DayOffset) or 0 end - return note(save, (Clock.hostWeekday(now) + (offset or 0)) % 7, "rollover") + return note(save, (Clock.hostWeekday(now) + (offset or 0)) % 7, "rollover", now) end function Clock.setWeekday(save, day) day = integer(day) if type(save) ~= "table" or not day then return false end + local now = os.time() save.rtc = rtc(save) or {} day = day % 7 - save.rtc.startDay = (day - Clock.hostWeekday()) % 7 + save.rtc.startDay = (day - Clock.hostWeekday(now)) % 7 save.rtc.dayOfWeek = day + local total = anchored(save.rtc, now) + if total then save.rtc.anchorDay = (day - math.floor(total / 1440)) % 7 end -- Keep the existing native field in sync for older native consumers/saves. save.g2DayOffset = save.rtc.startDay - note(save, day, "set") + note(save, day, "set", now) return true end +-- Calendar identity for daily/weekly timers. Only date arithmetic uses the +-- host calendar library; elapsed game days come from the same RTC as minutes +-- and weekday. Noon normalization avoids missing/duplicated local DST hours. +function Clock.calendarDate(save, now) + local state = rtc(save) + local total = anchored(state, now) + local year, month, day + if total and type(state.anchorDate) == "string" then + year, month, day = state.anchorDate:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$") + year, month, day = tonumber(year), tonumber(month), tonumber(day) + end + if year and month >= 1 and month <= 12 and day >= 1 and day <= 31 then + local ok, stamp = pcall(os.time, { year=year, month=month, + day=day + math.floor(total / 1440), hour=12, min=0, sec=0, isdst=nil }) + if ok and stamp then return os.date("*t", stamp) end + end + return os.date("*t", now) +end +function Clock.dateKey(save, now) + local date = Clock.calendarDate(save, now) + return ("%04d-%02d-%02d"):format(date.year, date.month, date.day) +end function Clock.weekdayName(day) local name = Clock.DAY_NAMES[day] return name and Strings(name) diff --git a/src/script/Gen2Daily.lua b/src/script/Gen2Daily.lua index 2a65d9160..8e308bca1 100644 --- a/src/script/Gen2Daily.lua +++ b/src/script/Gen2Daily.lua @@ -18,6 +18,8 @@ local Gen2Flags = require("src.script.Gen2Flags") local Gen2Daily = {} +-- Native RTC/calendar integration by Roxas2712; see LICENSE-ROXAS2712.md. +local Clock = require("src.core.Gen2Clock") local ENGINE_LUCKY_NUMBER_SHOW = 77 local GameVersion = require("src.core.GameVersion") @@ -39,19 +41,15 @@ local INITIAL_HIDDEN_EVENTS = { 1915, -- EVENT_INDIGO_PLATEAU_POKECENTER_RIVAL } -local function todayKey(now) - return os.date("%Y-%m-%d", now) -end - local function nextFriday(save, now) - local date = os.date("*t", now) - local weekday = require("src.core.Gen2Clock").weekday(save, now) + local date = Clock.calendarDate(save, now) + local weekday = Clock.weekday(save, now) local days = (5 - weekday) % 7 -- Match the weekday chosen at Mom's clock. if days == 0 then days = 7 end -- Calendar arithmetic, rather than seconds, also handles daylight saving. date.day, date.hour, date.min, date.sec = date.day + days, 12, 0, 0 date.isdst = nil - return todayKey(os.time(date)) + return os.date("%Y-%m-%d", os.time(date)) end function Gen2Daily.lotteryExpired(save, now) @@ -59,7 +57,7 @@ function Gen2Daily.lotteryExpired(save, now) -- Old saves already have a drawn ID; retain it when adding the timer. save.g2LuckyNextFriday = nextFriday(save, now) end - return todayKey(now) >= save.g2LuckyNextFriday + return Clock.dateKey(save, now) >= save.g2LuckyNextFriday end function Gen2Daily.resetLottery(save, now) @@ -121,8 +119,10 @@ function Gen2Daily.poll(save, now) Gen2Daily.ensureRoam(save, nil) if not save then return end Gen2Daily.seedInitialObjectFlags(save) - require('src.pokemon.Gen2Pokerus').poll(save,now) - local today = todayKey(now) + local today = Clock.dateKey(save, now) + if save.g2PokerusDay==nil or save.g2DailyDay~=today then + require('src.pokemon.Gen2Pokerus').poll(save,now) + end if save.g2DailyDay == today then return end save.g2DailyDay = today Gen2Daily.onNewDay(save) diff --git a/tests/engine/gen2_clock.lua b/tests/engine/gen2_clock.lua index db54d0dcc..7a64bc4ad 100644 --- a/tests/engine/gen2_clock.lua +++ b/tests/engine/gen2_clock.lua @@ -7,9 +7,11 @@ local Clock = require('src.core.gen2.Clock') local P = require('src.render.PaletteFX');local W = require('src.world.OverworldController') require('src.script.Gen2Commands');local Commands = require('src.script.Commands');local Loader = require('src.mods.Loader') local Json = require('src.link.Json');local Save = require('src.core.SaveData') -local date = os.date;local host = { hour=13, minute=20, day=2 } +local date,time = os.date,os.time;local elapsed=0;local baseTime=time() +os.time=function(value)if value then return time(value)end;return baseTime+elapsed end +local host = { hour=13, minute=20, day=2 } os.date = function(format, now) - if now then return date(format,now) end + if now and now~=os.time() then return date(format,now) end if format=='%H' then return tostring(host.hour) end if format=='%M' then return tostring(host.minute) end if format=='%w' then return tostring(host.day) end @@ -33,7 +35,7 @@ T.eq(Clock.setTime(false,10,0),false,'no saved state means no write') T.eq(Clock.setWeekday({},math.huge),false,'infinite weekday rejected') T.eq(Clock.weekdayName(0),nil,'weekday label API is one-based') for _,edition in ipairs({'gold','silver','crystal'}) do - V.set(edition);host.hour=13;host.minute=20;host.day=2 + V.set(edition);host.hour=13;host.minute=20;host.day=2;elapsed=0 local save={playTime=0,g2DayOffset=3};local untouched=Json.encode(save) T.eq(Clock.hour(save),13,edition..' legacy hour retains host behavior') T.eq(Clock.weekday(save),5,edition..' legacy native weekday offset retained') @@ -45,10 +47,9 @@ for _,edition in ipairs({'gold','silver','crystal'}) do T.eq(Clock.weekday(save),6,edition..' weekday is observed immediately') T.eq(Clock.isSet(save),true,edition..' explicit clock setting is distinguishable') T.eq(save.g2DayOffset,save.rtc.startDay,edition..' old native day readers retain shared offset') - host.minute=30 + host.minute=30;elapsed=600 T.eq(Clock.minutes(save),5,edition..' RTC keeps ticking and wraps at midnight') - host.day=3 - T.eq(Clock.weekday(save),0,edition..' saved weekday follows RTC weekly rollover') + T.eq(Clock.weekday(save),0,edition..' saved weekday rolls over at game midnight before host midnight') local round=assert(Save.decode(Save.encode(save))) T.eq(Clock.minutes(round),5,edition..' save roundtrip retains RTC minute base') T.eq(Clock.weekday(round),0,edition..' save roundtrip retains RTC weekday base') @@ -119,5 +120,5 @@ for _,edition in ipairs({'red','firered','emerald','heartgold'})do T.eq(require('src.mods.Capabilities').supports('events','clock.day_changed',edition),false,edition..' native RTC event not falsely advertised') end nativeGame.save=oldSave;debug.setupvalue(W.timeOfDay,slot,oldGame) -Clock.resetObservations();os.date=date;V.set(version) +Clock.resetObservations();os.date=date;os.time=time;V.set(version) T.finish('shared native Gen2 saved RTC, actual world/scripts/clock-card and public event') diff --git a/tests/engine/gen2_clock_lifecycle.lua b/tests/engine/gen2_clock_lifecycle.lua new file mode 100644 index 000000000..f9f7f36a6 --- /dev/null +++ b/tests/engine/gen2_clock_lifecycle.lua @@ -0,0 +1,119 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local version=V.get() +local C=require('src.core.Gen2Clock');local D=require('src.script.Gen2Daily') +local Save=require('src.core.SaveData');local Json=require('src.link.Json') +local date,time=os.date,os.time +-- A deterministic host clock. Date-table normalization still uses the OS +-- calendar, including real DST rules; elapsed time is independently advanced. +local now=time({year=2026,month=10,day=20,hour=13,min=20,sec=0}) +local origin=now;local shift=0 +os.time=function(value)if value then return time(value)end;return now end +os.date=function(format,stamp)return date(format,(stamp or now)+shift)end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);now=origin;shift=0 + local save={flags={},g2FruitTrees={used=true}} + T.eq(C.setTime(save,23,59),true,edition..' player can anchor before midnight') + T.eq(C.setWeekday(save,4),true,edition..' player chooses Thursday independently of host') + local startDate=C.dateKey(save);local offset=save.rtc.startMinute + T.eq(C.hour(save),23,edition..' explicit hour visible immediately') + T.eq(C.minute(save),59,edition..' explicit minute visible immediately') + D.poll(save);save.g2FruitTrees.used=true + local before=save.g2DailyDay + now=now+59;D.poll(save) + T.eq(C.minute(save),59,edition..' RTC waits for the full elapsed minute') + T.eq(C.weekday(save),4,edition..' weekday remains before game midnight') + T.eq(save.g2FruitTrees.used,true,edition..' daily use retained before midnight') + now=now+1;D.poll(save) + T.eq(C.hour(save),0,edition..' elapsed RTC crosses midnight') + T.eq(C.minute(save),0,edition..' minute wraps with the day') + T.eq(C.weekday(save),5,edition..' weekday crosses at same game midnight') + T.eq(C.hostWeekday(),C.hostWeekday(origin),edition..' host has not crossed midnight') + T.eq(save.g2FruitTrees.used,nil,edition..' native daily reset occurs at game midnight') + T.eq(save.g2DailyDay~=before,true,edition..' daily calendar uses the game boundary') + T.eq(C.dateKey(save)~=startDate,true,edition..' RTC calendar advances at same boundary') + save.g2FruitTrees.used=true;D.poll(save) + T.eq(save.g2FruitTrees.used,true,edition..' repeated poll cannot repeat daily reset') + local round=assert(Save.decode(Save.encode(save))) + now=now+7*86400+123*60 + T.eq(C.weekday(round),5,edition..' offline week retains proper weekday') + T.eq(C.hour(round),2,edition..' offline elapsed hours retained after save reload') + T.eq(C.minute(round),3,edition..' offline elapsed minutes retained after save reload') + local stale=round.g2DailyDay;D.poll(round) + T.eq(round.g2DailyDay~=stale,true,edition..' offline whole week still resets daily identity') + T.eq(round.g2FruitTrees.used,nil,edition..' offline reset does not depend on changed weekday') + local expected=C.minutes(round);local day=C.weekday(round);local calendar=C.dateKey(round) + shift=3600 + T.eq(C.minutes(round),expected,edition..' automatic host DST jump cannot double-advance game RTC') + T.eq(C.weekday(round),day,edition..' host DST cannot independently shift game weekday') + T.eq(C.dateKey(round),calendar,edition..' host DST cannot expire daily identity') + shift=-3600 + T.eq(C.minutes(round),expected,edition..' backward host DST jump cannot freeze game RTC') + shift=0 + T.eq(C.setTime(round,7,15),true,edition..' explicit edit reanchors current native clock') + T.eq(C.weekday(round),day,edition..' editing time preserves the chosen current weekday') + T.eq(C.dateKey(round),calendar,edition..' editing time preserves current game calendar date') + T.eq(C.hour(round),7,edition..' edited hour visible') + T.eq(C.minute(round),15,edition..' edited minute visible') + now=now+2*86400+45*60 + T.eq(C.weekday(round),(day+2)%7,edition..' future days follow new anchor') + T.eq(C.minutes(round),8*60,edition..' new anchor keeps ticking') + local oldDate=C.dateKey(round);C.setWeekday(round,0) + T.eq(C.weekday(round),0,edition..' weekday editing after offline elapsed days is immediate') + T.eq(C.dateKey(round),oldDate,edition..' weekday wheel does not rewrite calendar age') + C.setTime(round,23,59);now=now+60 + T.eq(C.weekday(round),1,edition..' edited weekday and time cross next midnight together') + -- The weekly service has to expire on the selected in-game Friday, not a + -- host Friday that is several hours out of phase. + now=origin;local lottery={flags={}};C.setTime(lottery,23,59);C.setWeekday(lottery,4) + D.resetLottery(lottery);T.eq(D.lotteryExpired(lottery),false,edition..' fresh lottery remains active Thursday') + now=now+60 + T.eq(C.weekday(lottery),5,edition..' weekly service reaches selected Friday') + T.eq(D.lotteryExpired(lottery),true,edition..' native lottery expires at game Friday midnight') + D.resetLottery(lottery);T.eq(D.lotteryExpired(lottery),false,edition..' Friday draw waits for next Friday') + now=now+6*86400 + T.eq(D.lotteryExpired(lottery),false,edition..' lottery does not expire a day early') + now=now+86400 + T.eq(D.lotteryExpired(lottery),true,edition..' next game Friday expires weekly draw') + -- Actual subscription dispatch must observe a game midnight even when + -- the host weekday is unchanged, and an offline full week too. + for _,api in ipairs({{1,'native'},{2,'native'},{2,'unified'},{3,'unified'}})do + local files={['mods/day/manifest.json']=Json.encode({id='day',name='Native midnight observer',version='1.0.0',api=api[1],content_format=api[2],entry='main.lua',affects_link=false}), + ['mods/day/main.lua']=[[ + local mod=...;mod.exports.events={} + local Clock=require('src.core.gen2.Clock') + mod.events:on('clock.day_changed',function(e) + mod.exports.events[#mod.exports.events+1]={day=e.day,previous=e.previous,reason=e.reason} + assert(Clock.weekday(mod.exports.save)==e.day) + e.day=99 + end) + ]]} + local loaded=T.sdk.loadMods({'mods/day'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#loaded.errors,0,edition..' real loader admits midnight observer') + local export=loaded.loader.exports.day;local own={};export.save=own + now=origin;C.setTime(own,23,59);C.setWeekday(own,4) + -- Explicit staging changes may emit; only changes after the first settled + -- observation are under test here. + C.weekday(own);local n=#export.events;now=now+60;C.weekday(own) + T.eq(#export.events,n+1,edition..' midnight event dispatched once through actual runtime') + T.eq(export.events[n+1].day,5,edition..' midnight event carries new game weekday') + T.eq(export.events[n+1].previous,4,edition..' midnight event retains previous game weekday') + C.weekday(own);T.eq(#export.events,n+1,edition..' reentrant/repeated reads cannot repeat midnight') + now=now+7*86400;C.weekday(own) + T.eq(#export.events,n+2,edition..' offline full week still emits one observed day change') + T.eq(export.events[n+2].day,5,edition..' full-week event correctly has same weekday') + require('src.mods.Loader').teardown();loaded.release() + end + -- Old offsets and old saves remain read-only on load, without introducing + -- an epoch guessed from today's time into another player's save. + local old={rtc={startMinute=offset,startDay=2},g2DayOffset=2} + local bytes=Json.encode(old) + T.eq(C.minutes(old),(C.hostMinutes()+offset)%1440,edition..' old minute offset retains exact semantics') + T.eq(C.weekday(old),(C.hostWeekday()+2)%7,edition..' old weekday offset retains exact semantics') + C.dateKey(old);T.eq(Json.encode(old),bytes,edition..' old save reads do not migrate or mutate state') + local malformed={rtc={anchorTime='bad',anchorMinute=0,anchorDay=0,startMinute=0}} + T.eq(C.minutes(malformed),C.hostMinutes(),edition..' malformed optional anchor falls back to old clock') +end +os.date=date;os.time=time;C.resetObservations();V.set(version) +T.finish('native RTC midnight, offline calendar, host DST isolation and actual daily/lottery lifecycle') From 26d40db31a81ab2b9afded0d4e512a44bdf908a3 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:47:30 +0100 Subject: [PATCH 109/138] Use shared native Unown and saved-calendar transitions without duplicate observers --- docs/gen2-lifecycle-callbacks.md | 2 +- src/battle/BattleState.lua | 6 +----- src/mods/Capabilities.lua | 3 +++ src/pokemon/Gen2Pokerus.lua | 7 ++++--- tests/engine/gen2_lifecycle_callbacks.lua | 1 + tests/engine/pack_sprite_adapter.lua | 2 +- 6 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/gen2-lifecycle-callbacks.md b/docs/gen2-lifecycle-callbacks.md index db873eb69..d089bc097 100644 --- a/docs/gen2-lifecycle-callbacks.md +++ b/docs/gen2-lifecycle-callbacks.md @@ -33,7 +33,7 @@ failed encounters and repeated movie completion do not announce new work. Pokérus previously existed only as a saved byte. Native GSC battle wins now perform the party infection/spread transition, including the Goldenrod gate, the rare de novo roll, cured immunity and the four-day spread stop. Daily -polling seeds its own stamp and advances counters by elapsed calendar days. +polling seeds its own stamp and advances counters by the saved RTC calendar. The event runs after the byte is written. This contribution concerns these party-byte transitions; it does not claim an audit of all Pokérus consumers. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 4891887f2..cadf7bdd3 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -9902,11 +9902,6 @@ function BattleState:storeCaughtMon() -- ("It records them in the sequence that they were caught.") dex.unownDex = dex.unownDex or {} dex.unownDex[#dex.unownDex + 1] = form - if GameVersion.isGen2() and Runtime.wants("unown.unlocked")then - local words=game.data.unown_dex and game.data.unown_dex.words - Runtime.emit("unown.unlocked",{letter=form,name=string.char(64+form), - word=words and words[form],count=#dex.unownDex}) - end end end -- CAUGHT: the memo's "met at Lv5, ROUTE 101" comes from right here, and the @@ -9958,6 +9953,7 @@ function BattleState:storeCaughtMon() and Runtime.call("catch.party_full",function()return false end, {battle=self,mon=self.enemy.mon,name=self.enemy.name,game=game}) if claimed then + if nativeUnown.supported() then nativeUnown.registerCatch(game.save,self.enemy.mon,game.data) end destination='mod' else destination = "box" diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 6585afe6e..54e4dba57 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -101,6 +101,9 @@ function Capabilities.supports(surface, name, version) -- this only promises the bus supports it, never that a producer is present. if surface == "events" and name:match("^mod%.[%w_%-]+%..+") then return true end local gens = callbacks[surface][name] + if name=="unown.unlocked" and version~="gold" and version~="silver" and version~="crystal"then + return false,"unown.unlocked has no native producer in "..version + end if gens then if gens:find(tostring(info.generation), 1, true) and nativeVersion(name, version) then return true diff --git a/src/pokemon/Gen2Pokerus.lua b/src/pokemon/Gen2Pokerus.lua index 36094feb5..5d12b7290 100644 --- a/src/pokemon/Gen2Pokerus.lua +++ b/src/pokemon/Gen2Pokerus.lua @@ -55,8 +55,9 @@ function P.advance(party,days) end end local monthDays={0,31,59,90,120,151,181,212,243,273,304,334} -local function day(now) - local date=os.date('*t',now) +local function day(save,now) + local Clock=require('src.core.Gen2Clock') + local date=Clock.calendarDate and Clock.calendarDate(save,now)or os.date('*t',now) local y=date.year-1 local ordinal=365*y+math.floor(y/4)-math.floor(y/100)+math.floor(y/400) +monthDays[date.month]+date.day @@ -65,7 +66,7 @@ local function day(now) end function P.poll(save,now) if not save or not require('src.pokemon.Gen2Breeding').isVanilla()then return end - local today=day(now) + local today=day(save,now) local previous=save.g2PokerusDay save.g2PokerusDay=today if type(previous)~='number' or previous==today then return end diff --git a/tests/engine/gen2_lifecycle_callbacks.lua b/tests/engine/gen2_lifecycle_callbacks.lua index b2a2f9f24..b50016764 100644 --- a/tests/engine/gen2_lifecycle_callbacks.lua +++ b/tests/engine/gen2_lifecycle_callbacks.lua @@ -152,6 +152,7 @@ state.claim=false;state.nickname=nil local unownDef=require('src.mods.Merge').deepCopy(data.pokemon.FIXMON_A) unownDef.id,unownDef.dex,unownDef.evolutions='SPECIES_201',201,{} +unownDef.translationSourceName='UNOWN' unownDef.forms={};for n=1,26 do unownDef.forms[n]={}end data.pokemon.SPECIES_201=unownDef;data.unown_dex={words={'OWN WORD'}} local unown=Pokemon.new(data,'SPECIES_201',10,function(n)return n end) diff --git a/tests/engine/pack_sprite_adapter.lua b/tests/engine/pack_sprite_adapter.lua index d0e5df2ac..f119718f3 100644 --- a/tests/engine/pack_sprite_adapter.lua +++ b/tests/engine/pack_sprite_adapter.lua @@ -39,7 +39,7 @@ love.graphics.newImage,love.graphics.draw=oldImage,oldDraw local U=require('src.compat.gen2.Unown') T.eq(U.monLetter({species='OTHER'}),nil,'Unown query rejects other species') T.eq(U.monLetter({species='UNOWN',unownLetter='Z'}),26,'Unown query accepts a stored letter') -T.eq(U.monLetter({species='SPECIES_201',dvs={attack=0,defense=0,speed=0,special=0}}),1, +T.eq(U.monLetter({species='UNOWN',dvs={attack=0,defense=0,speed=0,special=0}}),1, 'Unown query delegates the native DV selector') local P=require('src.compat.game3.Pokemon') T.eq(P.unownLetter(0),0,'GBA personality query is zero-based') From 3e5c0dd8035024656b4ab0d9e61186bd44a5a46e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:50:11 +0100 Subject: [PATCH 110/138] Qualify saved-midnight infection and native-only event capabilities --- src/mods/Capabilities.lua | 5 +++-- tests/engine/gen2_lifecycle_callbacks.lua | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 54e4dba57..66936f8f4 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -101,8 +101,9 @@ function Capabilities.supports(surface, name, version) -- this only promises the bus supports it, never that a producer is present. if surface == "events" and name:match("^mod%.[%w_%-]+%..+") then return true end local gens = callbacks[surface][name] - if name=="unown.unlocked" and version~="gold" and version~="silver" and version~="crystal"then - return false,"unown.unlocked has no native producer in "..version + if (name=="unown.unlocked" or name=="happiness.changed" or name=="pokerus.infected") + and version~="gold" and version~="silver" and version~="crystal"then + return false,name.." has no native producer in "..version end if gens then if gens:find(tostring(info.generation), 1, true) and nativeVersion(name, version) then diff --git a/tests/engine/gen2_lifecycle_callbacks.lua b/tests/engine/gen2_lifecycle_callbacks.lua index b50016764..3481dd1f4 100644 --- a/tests/engine/gen2_lifecycle_callbacks.lua +++ b/tests/engine/gen2_lifecycle_callbacks.lua @@ -111,6 +111,14 @@ T.eq(last("pokerus.infected").days,1,'de novo countdown is stored before observe local start=os.time({year=2026,month=10,day=20,hour=12}) Virus.poll(contracted,start);T.eq(contracted.party[1].pokerus,0x41,'first clock poll only seeds the clock') Virus.poll(contracted,start+86400*2);T.eq(contracted.party[1].pokerus,0x40,'elapsed days cure the actual party byte') +local shifted={party={{pokerus=0x34}},rtc={anchorTime=start,anchorMinute=1439, + anchorDay=2,anchorDate='2026-10-20'}} +Virus.poll(shifted,start);Virus.poll(shifted,start+120) +T.eq(shifted.party[1].pokerus,0x33,'infection countdown follows saved midnight while the host is still at noon') +local Caps=require('src.mods.Capabilities') +T.eq(Caps.supports('events','happiness.changed','prism'),false,'unimplemented hack friendship is not advertised') +T.eq(Caps.supports('events','pokerus.infected','polishedcrystal'),false,'unimplemented hack infection is not advertised') + local DayCare=require('src.pokemon.DayCare') local a=Pokemon.new(data,'FIXMON_A',10,function(n)return n end) From 858872a45fb059abc451a7538840c27a789c7b6f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:03:59 +0100 Subject: [PATCH 111/138] Respect concrete query providers in module alias qualification --- tests/engine/compat_module_aliases.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/engine/compat_module_aliases.lua b/tests/engine/compat_module_aliases.lua index a8b848fb2..d1d1c8f43 100644 --- a/tests/engine/compat_module_aliases.lua +++ b/tests/engine/compat_module_aliases.lua @@ -19,7 +19,12 @@ local names=require('src.mods.Gen1Compat') for _,pair in ipairs({{'src.world.game3.Follower','src.compat.game3.Follower'}, {'src.core.game3.pokemon','src.compat.game3.Pokemon'}, {'src.core.gen2.Unown','src.compat.gen2.Unown'}})do - T.eq(require(pair[1]),require(pair[2]),'native adapter preserves require identity '..pair[1]) + local source,adapter=require(pair[1]),require(pair[2]) + if loadfile(pair[1]:gsub('%.','/')..'.lua')then + T.neq(source,adapter,'concrete provider outranks the fallback '..pair[1]) + else + T.eq(source,adapter,'native adapter preserves require identity '..pair[1]) + end end for name in pairs(names.UNAVAILABLE)do From d7bbf97d03cc7af1070a4ac909c93f5f60882287 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 13:20:00 +0100 Subject: [PATCH 112/138] perf(P6): prewarm effects and stream DS synthesis from the audio worker --- docs/audio-workers-p6.md | 105 ++++++ src/audio/NitroAudio.lua | 135 +++++++- src/audio/NitroSequence.lua | 99 ++++++ src/audio/NitroTransport.lua | 287 +++++++++++++++++ src/audio/NitroWorker.lua | 87 +++++ src/core/AudioPcm.lua | 80 +++++ src/core/ChipAudio.lua | 142 +++++++- src/core/ChipSynth.lua | 20 +- src/core/M4ASynth.lua | 29 +- src/core/Music.lua | 3 + src/core/Performance.lua | 2 + src/core/Sound.lua | 191 ++++++++++- src/core/chip_worker.lua | 76 ++++- tools/audio-profile-check/NitroCheck.lua | 160 +++++++++ tools/audio-profile-check/NitroFixture.lua | 93 ++++++ tools/audio-profile-check/conf.lua | 7 + tools/audio-profile-check/main.lua | 358 +++++++++++++++++++++ tools/check_audio_profile.py | 43 +++ 18 files changed, 1855 insertions(+), 62 deletions(-) create mode 100644 docs/audio-workers-p6.md create mode 100644 src/audio/NitroSequence.lua create mode 100644 src/audio/NitroTransport.lua create mode 100644 src/audio/NitroWorker.lua create mode 100644 src/core/AudioPcm.lua create mode 100644 tools/audio-profile-check/NitroCheck.lua create mode 100644 tools/audio-profile-check/NitroFixture.lua create mode 100644 tools/audio-profile-check/conf.lua create mode 100644 tools/audio-profile-check/main.lua create mode 100644 tools/check_audio_profile.py diff --git a/docs/audio-workers-p6.md b/docs/audio-workers-p6.md new file mode 100644 index 000000000..4a5886254 --- /dev/null +++ b/docs/audio-workers-p6.md @@ -0,0 +1,105 @@ +# PCM and audio workers (P6) + +`src.core.Performance.audioWorkerOptimization` defaults to `true`. Set it to +`false` to disable this task independently of the other performance changes. +The live chip-music worker receives flag changes; existing unused prewarm +entries are discarded. New DS players use the legacy four-buffer transport +when disabled, and active worker players materialize their queued state. + +## PCM writes + +`AudioPcm` returns ordinary 16-bit `SoundData`. It acquires a signed PCM pointer +once per buffer, with `setSample` as the fallback. FFI writes require an active +JIT: interpreted FFI was slower on the tested LÖVE 11 build. + +The installed `SoundData.setSample` implementation determines conversion: +the C method takes and multiplies floats, while LÖVE's LuaJIT wrapper multiplies +numbers directly. The independently written converter follows that distinction. +See the official [C implementation](https://github.com/love2d/love/blob/11.5/src/modules/sound/SoundData.cpp) +and [LuaJIT wrapper](https://github.com/love2d/love/blob/11.5/src/modules/sound/wrap_SoundData.lua). +Boundary, mono/stereo and array tests compare complete output bytes. + +## Effects and cries + +`Sound.update`, serviced by `Music.update`, submits at most one speculative job +per frame, with two jobs in flight. The existing synthesis thread returns PCM +on `chipaudio_fx`; only the main thread creates Sources. Music buffers and effect +jobs share the worker fairly. Jobs retain their own stereo/channel mix and +restore the live music mix after rendering. + +Completed Sources enter the existing private `Sound.lua` cache without playing, +ducking music or emitting `sound.played`. Unused speculative audio is limited to +16 MiB. First use rechecks the exact definition, derived cry resolution, audio +input and mix. Changed entries are discarded. Used entries retain the existing +cache behavior. Invalidation, cancellation and audio-session suspension reject +late results. Failed speculative jobs do not mark a sound as permanently bad. +Custom/metatable/cyclic definitions use the existing source constructors. + +A cold cue, unavailable worker or disabled prewarm uses the original synchronous +constructor and logs the fallback once per synthesis kind. This keeps the +existing immediate Source return, playback and callback timing. + +## DS transport and its compatibility boundary + +Eligible SSEQ players share `chip_worker` with the other synthesizers. The source +has 16 buffers, each with up to 8192 stereo frames at 32768 Hz. Worker output is +bounded to two blocks per player; at most 16 worker players and four archive +registrations are retained. Inactive jobs expire after five seconds. One-shots +retain the legacy 512-frame end padding instead of adding an 8192-frame tail. + +Worker samples use signed 16-bit ByteData and private pointers. PCM8, PCM16 and +ADPCM retain their decoded values; PSG square/noise amplitudes have an explicit +scale. Mixing proceeds by voice between sequencer tick boundaries, preserving +phase addition, event-before-sample ordering and voice accumulation order. +Normal main-thread updates transfer completed buffers and state snapshots. + +Sequencer state belongs to the worker while this path is active. Ordinary reads +or writes of `tracks`, `voices`, `samples`, `variables`, `tempo`, `tickPhase`, +`master`, `ended`, `arc`, `seq`, `bytes` or `start` retire the worker, restore the +legacy metatable and materialize the last **queued** snapshot. PCM arrays are +then ordinary Lua tables again. Direct sequencer-method overrides also select +the legacy path. Already queued audio remains queued; no hidden replay occurs. +The normal volume, pitch, looping, pause, play, stop and variable methods remain. + +This is an explicit compatibility boundary: raw table enumeration (`pairs`, +`rawget`) and inspection of the temporary transport metatable bypass ordinary +field access. Consumers that depend on those operations should disable P6 before +constructing a DS player. Snapshots describe queued synthesis, potentially ahead +of audible output. A full 16-block queue represents four seconds of lookahead; +control changes affect future synthesis. A paused player whose worker lease +expires resumes through the logged legacy fallback. + +The conservative instruction walker keeps sequences using the main RNG or shared +variables on the legacy transport. Argument bytes are parsed by opcode shape, +not searched as raw opcode values. Worker errors, expired jobs and disabling the +flag also select the logged legacy renderer. + +## Reproduction and scope + +Run `python3 tools/check_audio_profile.py --love /absolute/path/to/love +--report .local/qa/audio-workers/profile.json` (one shell line). The checker uses +`qa_storage`, an isolated identity, complete source hashes and owned compact +GB/M4A/SDAT programs. It tests actual native SoundData and threads. Source hardware +is a double, with no output device or player save/profile involved. + +The DS comparison checks every PCM byte across all three encodings, PSG voices, +looping and one-shot tails, exact phase and live voice fields, independent players, +variable methods and compatibility fallbacks. FrameProfile uses 120 warmup and +120 measured frames per case: PCM writes, cold/prewarmed first cue and legacy/worker +DS updates under 512-frame-per-frame simulated consumption. These are scoped CPU +measurements, not a game FPS, audio-device recovery or complete playthrough claim. +Mobile/handheld measurements are not available. + +Measured on desktop macOS ARM64, LÖVE 12 / Metal with JIT enabled, using the +owned fixture and Source double described above: + +| Main-thread section | Legacy/cold | P6 | +| --- | ---: | ---: | +| 8192 stereo PCM frames | 0.256 ms | 0.114 ms | +| First effect cue | 0.314 ms | 0.030 ms after prewarm | +| DS update, simulated 512 frames consumed per frame | 0.126 ms | 0.011 ms | + +The native checker passes 358 scoped checks. These values are source-bound to +its local report; rerun the checker for another host/build. They exclude actual +Source-device cost. Interpreted LÖVE 11 keeps `setSample`; a native comparison +must not be presented as a JIT FFI speedup on that build. diff --git a/src/audio/NitroAudio.lua b/src/audio/NitroAudio.lua index 81b0caf87..9e3e32369 100644 --- a/src/audio/NitroAudio.lua +++ b/src/audio/NitroAudio.lua @@ -2,7 +2,9 @@ -- PCM/ADPCM mixing is local; no replacement MIDI soundfont is used. local FrameProfile = require("src.core.FrameProfile") local Sdat=require('src.import.Gen4Sdat') -local Audio={};local Player={};Player.__index=Player +local Transport=require('src.audio.NitroTransport') +local Audio={};local Player={} +Player.__index=Player local archives,players={},setmetatable({}, {__mode="k"}) local RATE,BLOCK=32768,512 local function u8(s,o) return s:byte(o+1) end @@ -28,15 +30,22 @@ function Audio.archive(data) end return arc end -function Audio.new(data,def) - local arc=Audio.archive(data) +local function engineFor(arc,def) local seq=assert(Sdat.sequence(arc,assert(def.nds)),'missing SDAT sequence') local at,size=Sdat.fileAt(arc,seq.file);local bytes=arc.data:sub(at+1,at+size) assert(bytes:sub(1,4)=='SSEQ','not a native SSEQ') local start=assert(u32(bytes,24),'truncated sequence') - local self=setmetatable({arc=arc,seq=seq,bytes=bytes,start=start,tracks={[0]=track(start)}, + return setmetatable({arc=arc,seq=seq,bytes=bytes,start=start,tracks={[0]=track(start)}, voices={},samples={},tempo=120,tickPhase=0,master=(seq.volume or 127)/127, - queue=love.audio.newQueueableSource(RATE,16,2,4),looping=true,running=false},Player) + looping=true,running=false},Player) +end +local hydrate +function Audio.new(data,def) + local self=engineFor(Audio.archive(data),def) + local threaded=Transport.attach(self,{nds=def.nds},function(...)return hydrate(...)end,function(state) + state.tracks={[0]=track(state.start)};state.voices={};state.tickPhase=1;state.variables={};state.ended=false + end) + self.queue=love.audio.newQueueableSource(RATE,16,2,threaded and 16 or 4) players[self]=true return self end @@ -46,12 +55,14 @@ end -- NNS_SndPlayerReadVariable), so nothing outside the sequences writes them. local globalVariables={} function Player:variable(index) + local value,handled=Transport.variable(self,index);if handled then return value end if index>=32 then return 0 end local v if index<16 then v=self.variables and self.variables[index] else v=globalVariables[index-16] end if v==nil then return -1 end;return v end function Player:setVariable(index,v) + if Transport.setVariable(self,index,v)then return end v=math.floor(v)%65536;if v>=32768 then v=v-65536 end if index<16 then self.variables=self.variables or {};self.variables[index]=v elseif index<32 then globalVariables[index-16]=v end @@ -63,15 +74,31 @@ function Player:getPitch() return self.pitch or 1 end function Player:getVolume() return self.queue:getVolume() end function Player:setFilter(...) return self.queue:setFilter(...) end function Player:setLooping(v) self.looping=v end -function Player:isPlaying() return self.running and (not self.ended or #self.voices>0 or self.queue:isPlaying()) end -function Player:stop() self.running=false;self.queue:stop();self.voices={} end +function Player:isPlaying() local value=Transport.isPlaying(self);if value~=nil then return value end;return self.running and (not self.ended or #self.voices>0 or self.queue:isPlaying()) end +function Player:stop() if Transport.stop(self)then return end;self.running=false;self.queue:stop();self.voices={} end function Player:pause() self.paused=true;self.queue:pause() end function Player:play() + if Transport.play(self)then return end if self.paused then self.paused=false;self.queue:play();return end self.queue:stop();self.tracks={[0]=track(self.start)};self.voices={};self.tickPhase=1;self.variables={} self.ended=false;self.running=true;self:update();self.queue:play() end local function signed(v) return v>=128 and v-256 or v end +-- Worker-only PCM storage. The public legacy player still exposes Lua arrays. +local packed=setmetatable({},{__mode='k'}) +local workerEngines=setmetatable({},{__mode='k'}) +local ffiOK,ffi=pcall(require,'ffi') +local function compactSample(sample,key) + if not ffiOK or packed[sample]then return end + local kind=tonumber(key:match('^(%d+):')) + local scale=kind==1 and 1/32768 or kind==2 and .5 or .3 + local pcm=sample.pcm + local bytes=love.data.newByteData(#pcm*2) + local pointer=ffi.cast('int16_t *',bytes:getFFIPointer()) + for n=1,#pcm do pointer[n-1]=pcm[n]/scale end + packed[sample]={bytes=bytes,pointer=pointer,count=#pcm,scale=scale} + sample.pcm=pointer +end function Player:note(t,note,velocity,duration) note=note+t.transpose local ins=Sdat.instrument(self.arc,self.seq.bank,t.program,note) @@ -94,6 +121,7 @@ function Player:note(t,note,velocity,duration) end end if not sample then return end + if workerEngines[self]then compactSample(sample,key)end if #self.voices>=32 then table.remove(self.voices,1) end local root=ins.kind==1 and ins.root or 69 local pan=math.max(0,math.min(127,t.pan+ins.pan-64))/127 @@ -195,6 +223,7 @@ function Player:tick() for _,v in ipairs(self.voices) do v.ticks=v.ticks-1;if v.ticks<=0 and v.release==0 then v.release=math.floor(RATE*0.04) end end end function Player:render() + Transport.detach(self,'explicit render call') local closeRender=FrameProfile.section(" audio: Nitro render") local sound=love.sound.newSoundData(BLOCK,RATE,16,2) for i=0,BLOCK-1 do @@ -221,6 +250,7 @@ function Player:render() return sound end function Player:update() + if Transport.update(self)then return end if not self.running or self.paused then return end if self.ended and #self.voices==0 then if not self.queue:isPlaying() then self.running=false end @@ -229,6 +259,97 @@ function Player:update() while self.queue:getFreeBufferCount()>0 do self.queue:queue(self:render());if self.ended and #self.voices==0 then break end end if not self.queue:isPlaying() then self.queue:play() end end +function Audio._workerNew(arc,def) + local self=engineFor(arc,def) + workerEngines[self]=true + self.tickPhase=1;self.variables={};self.running=true + return self +end +local function workerSnapshot(self,sent) + local state={tracks=self.tracks,voices={},variables=self.variables,tempo=self.tempo, + tickPhase=self.tickPhase,master=self.master,ended=self.ended==true} + local samples,keys={},{} + for key,s in pairs(self.samples)do + keys[s]=key + if not sent[key]then + local p=assert(packed[s],'unpacked worker sample') + samples[key]={bytes=p.bytes,count=p.count,scale=p.scale,loop=s.loop,loopAt=s.loopAt,rate=s.rate} + sent[key]=true + end + end + for n,v in ipairs(self.voices)do + local row={sampleKey=assert(keys[v.sample])} + for key,value in pairs(v)do if key~='sample'then row[key]=value end end + state.voices[n]=row + end + return state,samples +end +hydrate=function(self,state,samples) + local decoded={} + for key,s in pairs(samples)do + local pointer=ffi.cast('int16_t *',s.bytes:getFFIPointer()) + local pcm={};for n=1,s.count do pcm[n]=pointer[n-1]*s.scale end + decoded[key]={pcm=pcm,loop=s.loop,loopAt=s.loopAt,rate=s.rate} + end + local voices={} + for n,v in ipairs(state.voices or {})do + if v.sampleKey then + local row={sample=assert(decoded[v.sampleKey])} + for key,value in pairs(v)do if key~='sampleKey'then row[key]=value end end + voices[n]=row + else voices[n]=v end + end + for key,value in pairs(state)do rawset(self,key,value)end + rawset(self,'voices',voices) + if next(samples)then rawset(self,'samples',decoded)end +end +-- Mix a constant-event span by voice. Phase increments retain the original +-- addition order; tick() runs before the exact same first sample of a tick. +function Audio._workerRender(self,count,sent) + assert(ffiOK,'FFI unavailable') + local buffers=workerEngines[self] + if buffers==true then + buffers={left=ffi.new('double[?]',count+1),right=ffi.new('double[?]',count+1)} + workerEngines[self]=buffers + end + local left,right=buffers.left,buffers.right + ffi.fill(left,(count+1)*8);ffi.fill(right,(count+1)*8) + local at,actual=0,count + while at=1 do self.tickPhase=self.tickPhase-1;self:tick()end + local last=at+1 + local step=self.tempo*48/60/RATE + while last=p.count then + if s.loop and p.count>s.loopAt then v.pos=s.loopAt+(v.pos-s.loopAt)%(p.count-s.loopAt) + else table.remove(self.voices,n);break end + end + local gain=v.gain;local remove=false + if v.release>0 then gain=gain*v.release/(RATE*.04);v.release=v.release-1;remove=v.release==0 end + local index=math.floor(v.pos) + local value=(index>=0 and index= 176 and op <= 189 and op ~= 183 then + assert(op ~= 182, "sequence uses the main RNG") + assert(read() < 16, "sequence uses shared variables") + parameter(2) + elseif op >= 192 and op <= 223 then + parameter(1) + elseif op == 224 or op == 225 or op == 227 then + parameter(2) + elseif op == 254 then + read() + read() + elseif op == 253 or op == 255 then + if not conditional then + return + end + elseif op ~= 252 then + error(("unsupported SSEQ command %02X"):format(op)) + end + end + error("sequence walk budget exceeded") + end + local ok, why = pcall(function() + while #pending > 0 do + walk(table.remove(pending)) + end + end) + return ok, not ok and tostring(why) or nil +end +return M diff --git a/src/audio/NitroTransport.lua b/src/audio/NitroTransport.lua new file mode 100644 index 000000000..669729743 --- /dev/null +++ b/src/audio/NitroTransport.lua @@ -0,0 +1,287 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Main-state queue transport. Ordinary sequencer-field access retires this +-- transport and materializes the last queued snapshot for the legacy player. +local Performance = require("src.core.Performance") +local Sequence = require("src.audio.NitroSequence") +local Logger = require("src.core.Logger") +local M = {} +local entries = setmetatable({}, { __mode = "k" }) +local archiveIds = setmetatable({}, { __mode = "k" }) +local serial, archiveSerial = 0, 0 +local proxies = setmetatable({}, { __mode = "k" }) +local protected = { + arc = true, + seq = true, + bytes = true, + start = true, + tracks = true, + voices = true, + samples = true, + tempo = true, + tickPhase = true, + master = true, + variables = true, + ended = true, +} +local logged = {} +local ffiOK = pcall(require, "ffi") +local function log(reason) + if not logged[reason] then + logged[reason] = true + Logger.info("DS audio: synchronous fallback (%s)", reason) + end +end +local function chip() + return require("src.core.ChipAudio") +end +local function send(e, action, values) + values = values or {} + values.action, values.id, values.gen = action, e.id, e.gen + return chip()._nitroCommand(values) +end +function M.attach(player, def, hydrate, reset) + if Performance.audioWorkerOptimization == false then + return + end + if not ffiOK then + log("FFI unavailable") + return + end + if not (love.thread and love.thread.newThread) then + log("no love.thread") + return + end + local safe, why = Sequence.workerSafe(player.bytes, player.start) + if not safe then + log(why) + return + end + serial = serial + 1 + local meta = getmetatable(player) + local proxy = proxies[meta] + if not proxy then + proxy = { + __index = function(self, key) + return meta[key] or M.read(self, key) + end, + __newindex = M.write, + } + proxies[meta] = proxy + end + local state = {} + for key in pairs(protected) do + state[key] = rawget(player, key) + rawset(player, key, nil) + end + entries[player] = + { id = serial, gen = 0, state = state, def = def, hydrate = hydrate, reset = reset, samples = {}, meta = meta } + setmetatable(player, proxy) + return true +end +function M.detach(player, reason) + local e = entries[player] + if not e then + return + end + if e.active then + send(e, "stop") + end + if e.channel then + e.channel:clear() + end + entries[player] = nil + setmetatable(player, e.meta) + e.hydrate(player, e.state, e.samples) + if reason then + log(reason) + end +end +function M.read(player, key) + if protected[key] and entries[player] then + M.detach(player, "direct sequencer state access: " .. key) + return rawget(player, key) + end +end +function M.write(player, key, value) + local e = entries[player] + if e and (protected[key] or type(e.meta[key]) == "function") then + M.detach(player, "direct sequencer state/method write: " .. key) + end + rawset(player, key, value) +end +function M.play(player) + local e = entries[player] + if not e then + return false + end + if Performance.audioWorkerOptimization == false then + M.detach(player, "optimization disabled") + return false + end + if player.paused and e.active then + player.paused = false + player.queue:play() + return true + end + player.queue:stop() + e.gen = e.gen + 1 + e.done = false + e.reset(e.state) + e.pendingVariables = {} + e.rev = 0 + local arc = e.state.arc + local thread = chip()._nitroReady() + if not thread then + M.detach(player, "worker unavailable") + return false + end + local a = archiveIds[arc] + if not a or a.bytes ~= arc.data or a.thread ~= thread then + archiveSerial = archiveSerial + 1 + a = { id = archiveSerial, bytes = arc.data, thread = thread } + if not chip()._nitroCommand({ action = "archive", archive = a.id, bytes = a.bytes }) then + M.detach(player, "worker unavailable") + return false + end + archiveIds[arc] = a + end + e.channel = love.thread.getChannel("chipaudio_nds_" .. e.id) + e.channel:clear() + if + not send( + e, + "start", + { + archive = a.id, + def = e.def, + pitch = player.pitch, + looping = player.looping, + tempo = e.state.tempo, + master = e.state.master, + audioWorkerOptimization = true, + } + ) + then + M.detach(player, "worker unavailable") + return false + end + e.pitch, e.looping = player.pitch, player.looping + e.touch = 0 + e.active = true + player.running, player.paused = true, false + return true +end +function M.stop(player) + local e = entries[player] + if not e then + return false + end + if e.active then + send(e, "stop") + end + e.active = false + e.done = false + e.state.voices = {} + if e.channel then + e.channel:clear() + end + player.running = false + player.queue:stop() + return true +end +function M.isPlaying(player) + local e = entries[player] + if not e then + return nil + end + return player.running and (not e.done or player.queue:isPlaying()) or false +end +function M.variable(player, index) + local e = entries[player] + if not e or index >= 16 then + return nil, false + end + local value = e.state.variables and e.state.variables[index] + return value == nil and -1 or value, true +end +function M.setVariable(player, index, value) + local e = entries[player] + if not e then + return false + end + if index >= 16 then + M.detach(player, "shared variable write") + return false + end + value = math.floor(value) % 65536 + if value >= 32768 then + value = value - 65536 + end + e.state.variables = e.state.variables or {} + e.state.variables[index] = value + if e.active then + e.rev = e.rev + 1 + e.pendingVariables[index] = { value = value, rev = e.rev } + send(e, "variable", { index = index, value = value, rev = e.rev }) + end + return true +end +function M.update(player) + local e = entries[player] + if not e then + return false + end + if Performance.audioWorkerOptimization == false then + M.detach(player, "optimization disabled") + return false + end + if not player.running or player.paused or not e.active then + return true + end + local now = love.timer.getTime() + if e.pitch ~= player.pitch or e.looping ~= player.looping or now - e.touch >= 1 then + if not send(e, "control", { pitch = player.pitch, looping = player.looping }) then + M.detach(player, "worker unavailable") + return false + end + e.pitch, e.looping, e.touch = player.pitch, player.looping, now + end + while player.queue:getFreeBufferCount() > 0 do + local row = e.channel:pop() + if not row then + break + end + if row.gen == e.gen then + if row.error then + M.detach(player, "worker: " .. row.error) + return false + end + if row.sd then + player.queue:queue(row.sd) + for key, value in pairs(row.state) do + e.state[key] = value + end + -- nil variables/ended values are represented explicitly by the worker. + e.state.variables = row.state.variables + for index, write in pairs(e.pendingVariables) do + if (row.rev or 0) < write.rev then + e.state.variables[index] = write.value + else + e.pendingVariables[index] = nil + end + end + for key, value in pairs(row.samples) do + e.samples[key] = value + end + e.done = row.done + end + end + end + if e.done and not player.queue:isPlaying() and player.queue:getFreeBufferCount() == 16 then + player.running = false + elseif not player.queue:isPlaying() and player.queue:getFreeBufferCount() < 16 then + player.queue:play() + end + return true +end +return M diff --git a/src/audio/NitroWorker.lua b/src/audio/NitroWorker.lua new file mode 100644 index 000000000..6bee65b48 --- /dev/null +++ b/src/audio/NitroWorker.lua @@ -0,0 +1,87 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Worker-side bounded DS players on the existing ChipAudio command loop. +local Sdat = require("src.import.Gen4Sdat") +local Audio = require("src.audio.NitroAudio") +local Performance = require("src.core.Performance") +local M = {} +local archives, archiveOrder, jobs, order = {}, {}, {}, {} +local cursor = 0 +local function channel(id) + return love.thread.getChannel("chipaudio_nds_" .. id) +end +function M.command(cmd) + if cmd.action == "archive" then + if not archives[cmd.archive] then + archiveOrder[#archiveOrder + 1] = cmd.archive + end + archives[cmd.archive] = assert(Sdat.open(cmd.bytes)) + while #archiveOrder > 4 do + archives[table.remove(archiveOrder, 1)] = nil + end + return + end + local job = jobs[cmd.id] + if cmd.action == "start" then + if not job and #order >= 16 then + channel(cmd.id):push({ gen = cmd.gen, error = "DS worker player limit reached" }) + return + end + local ok, engine = pcall(Audio._workerNew, assert(archives[cmd.archive], "DS archive retired"), cmd.def) + if not ok then + channel(cmd.id):push({ gen = cmd.gen, error = tostring(engine) }) + return + end + if not job then + order[#order + 1] = cmd.id + end + engine.pitch, engine.looping = cmd.pitch, cmd.looping + engine.tempo, engine.master = cmd.tempo or engine.tempo, cmd.master or engine.master + jobs[cmd.id] = + { engine = engine, gen = cmd.gen, channel = channel(cmd.id), sent = {}, last = love.timer.getTime(), rev = 0 } + elseif job and job.gen == cmd.gen then + job.last = love.timer.getTime() + if cmd.action == "stop" then + jobs[cmd.id] = nil + for i=#order,1,-1 do if order[i]==cmd.id then table.remove(order,i)end end + job.channel:clear() + elseif cmd.action == "control" then + job.engine.pitch, job.engine.looping = cmd.pitch, cmd.looping + elseif cmd.action == "variable" then + job.engine:setVariable(cmd.index, cmd.value) + job.rev = cmd.rev or job.rev + end + elseif cmd.action == "control" then + channel(cmd.id):push({ gen = cmd.gen, error = "DS worker lease expired" }) + end +end +function M.step() + local now = love.timer.getTime() + for i = #order, 1, -1 do + local id = order[i] + local job = jobs[id] + if not job or now - job.last > 5 then + jobs[id] = nil + table.remove(order, i) + end + end + for _ = 1, #order do + cursor = cursor % #order + 1 + local job = jobs[order[cursor]] + if not job.done and job.channel:getCount() < 2 then + local priorOptimization=Performance.audioWorkerOptimization + Performance.audioWorkerOptimization = true + local ok, sd, state, samples, done = pcall(Audio._workerRender, job.engine, 8192, job.sent) + Performance.audioWorkerOptimization=priorOptimization + if ok then + job.channel:push({ gen = job.gen, sd = sd, state = state, samples = samples, done = done, rev = job.rev }) + job.done = done + else + job.channel:push({ gen = job.gen, error = tostring(sd) }) + job.done = true + end + return true + end + end + return false +end +return M diff --git a/src/core/AudioPcm.lua b/src/core/AudioPcm.lua new file mode 100644 index 000000000..29b371308 --- /dev/null +++ b/src/core/AudioPcm.lua @@ -0,0 +1,80 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Private PCM writes; the returned object is still an ordinary SoundData. +local Performance = require("src.core.Performance") +local ok, ffi = pcall(require, "ffi") +local M = {} +local float = ok and ffi.new("float[1]") or nil +local nativeFloat = false +function M.pointer(sound) + if + Performance.audioWorkerOptimization == false + or not ok + or not jit + or not jit.status() + or not sound.getFFIPointer + then + return nil + end + -- LÖVE's LuaJIT SoundData wrapper multiplies doubles; its C method takes + -- and multiplies floats. Follow the installed method, including on 11.x. + nativeFloat = debug.getinfo(sound.setSample, "S").what == "C" + local found, pointer = pcall(sound.getFFIPointer, sound) + if not found or pointer == nil then + return nil + end + local cast, value = pcall(ffi.cast, "int16_t *", pointer) + return cast and value ~= nil and value or nil +end +function M.sample(value) + -- SoundData takes a float argument and multiplies in float precision. + -- Keep both rounding points before the native signed-integer truncation. + if not nativeFloat then + return value * 32767 + end + float[0] = value + float[0] = float[0] * 32767 + return float[0] +end +function M.render(engine, count, channels, rate) + local sound = love.sound.newSoundData(count, rate, 16, channels) + local pointer = M.pointer(sound) + if pointer then + if channels == 2 then + for n = 0, count - 1 do + local left, right = engine:sampleStereo() + pointer[n * 2], pointer[n * 2 + 1] = M.sample(left), M.sample(right) + end + else + for n = 0, count - 1 do + pointer[n] = M.sample(engine:sample()) + end + end + else + for n = 0, count - 1 do + if channels == 2 then + local left, right = engine:sampleStereo() + sound:setSample(n, 1, left) + sound:setSample(n, 2, right) + else + sound:setSample(n, engine:sample()) + end + end + end + return sound +end +function M.arrays(left, right, count, rate) + local sound = love.sound.newSoundData(count, rate, 16, 2) + local pointer = M.pointer(sound) + if pointer then + for n = 1, count do + pointer[(n - 1) * 2], pointer[(n - 1) * 2 + 1] = M.sample(left[n]), M.sample((right or left)[n]) + end + else + for n = 1, count do + sound:setSample(n - 1, 1, left[n]) + sound:setSample(n - 1, 2, (right or left)[n]) + end + end + return sound +end +return M diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index 288901d82..8352458be 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -13,9 +13,10 @@ -- to start, music falls back to the original synchronous, amortized queue fill -- so behavior is unchanged -- see the `threaded` branch in each entry point. -- --- SFX and cries stay synchronous: they are short one-shots rendered once into --- a static Source, not a per-frame streaming cost. +-- SFX and cries are prewarmed by the same worker into Sound.lua's static +-- source cache; a cold/unavailable path retains a logged synchronous fallback. +local Performance = require("src.core.Performance") local Assets = require("src.render.Assets") local ChipSynth = require("src.core.ChipSynth") @@ -157,6 +158,9 @@ end -- --------------------------------------------------------------------------- local worker, cmdCh, outCh +, fxCh +local fxGeneration, fxSerial, fxPending = 1, 0, 0 +local workerPerformance local workerReady -- nil = untried, true = running, false = unavailable local function ensureWorker() @@ -179,12 +183,14 @@ local function ensureWorker() end cmdCh = love.thread.getChannel("chipaudio_cmd") outCh = love.thread.getChannel("chipaudio_out") + fxCh = love.thread.getChannel("chipaudio_fx") local started = pcall(function() thread:start() end) if not started then workerReady = false return false end worker = thread + workerPerformance = nil workerReady = true return true end @@ -229,7 +235,7 @@ local function slimAudio(data) local audio = data.audio or {} return { programFile = audio.programFile, - programPrefix = require('src.core.WorkerFs').prefix(), + programPrefix = require("src.core.WorkerFs").prefix(), bankOrder = audio.bankOrder, waveBanks = audio.waveBanks, gen2Dialect = audio.gen2Dialect, @@ -238,9 +244,100 @@ local function slimAudio(data) -- the Gen 3 synth reads its bytecode, voicegroups and sampled -- instruments out of one image beside the cache; this is where it is gen3 = audio.gen3, + gen3Cries = audio.gen3Cries, } end +-- Private transport shared with NitroAudio; no second synthesis thread. +function ChipAudio._nitroReady() + if Performance.audioWorkerOptimization==false or not ensureWorker() then return nil end + if not worker or worker:getError() then return nil end + return worker +end +function ChipAudio._nitroCommand(command) + if not ChipAudio._nitroReady() then return false end + command.cmd="nitro" + return pcall(cmdCh.push,cmdCh,command) +end + +-- Private prewarm protocol: no Source, playback or public sound event in the +-- worker. Sound.lua installs completed PCM into its ordinary source cache. +function ChipAudio.effectSpec(data, kind, def, pitch, tempo) + local request = { + audio = slimAudio(data), + stereo = ChipSynth.getStereo(), + volumes = ChipSynth.getChannelVolumes(), + pitches = ChipSynth.getChannelPitches(), + } + if kind == "cry" then + if type(def) ~= "table" then + return nil + end + if def.m4a then + request.kind, request.index = "cry", def.m4a + elseif def.chip or def.header then + request.kind, request.header = "chip", def.chip and def or def.header + request.options = { frequencyOffset = def.pitch, cryLength = def.length } + else + return nil + end + elseif type(def) == "table" and def.m4a then + request.kind = "m4a" + request.header = data.audio.songs and data.audio.songs[("SONG_%03X"):format(def.m4a)] + if not request.header then + return nil + end + else + request.kind, request.header = "chip", def + request.options = { frequencyOffset = pitch or 0, frameTicks = 0x80 + (tempo or 0x80) } + end + return request +end +function ChipAudio.requestEffect(spec) + if Performance.audioWorkerOptimization == false or sessionSuspended or fxPending >= 2 or not ensureWorker() then + return nil + end + -- A failed worker is never asked to accept another speculative job. + if worker:getError() then + return nil + end + fxSerial = fxSerial + 1 + local id = fxSerial + local ok = pcall( + cmdCh.push, + cmdCh, + { cmd = "fx", generation = fxGeneration, id = id, spec = spec, audioWorkerOptimization = true } + ) + if not ok then + return nil + end + fxPending = fxPending + 1 + return id +end +function ChipAudio.pollEffect() + if not fxCh then + return nil + end + local row = fxCh:pop() + while row and row.generation ~= fxGeneration do + row = fxCh:pop() + end + if row then + fxPending = math.max(0, fxPending - 1) + end + return row +end +function ChipAudio.cancelEffects() + fxGeneration = fxGeneration + 1 + fxPending = 0 + if fxCh then + fxCh:clear() + end + if workerReady and cmdCh then + cmdCh:push({ cmd = "fxCancel", generation = fxGeneration }) + end +end + -- If the worker died (a malformed def that errors mid-synth), fall back to the -- synchronous path for the rest of the session instead of going silent. local function workerAlive() @@ -306,7 +403,8 @@ local function playMusicSync(data, header, allowLoops, synthName) ChipAudio.stopMusic() currentMusic = { source = source, engine = engine, threaded = false, synth = synth, synthName = synthName, - started = true, finished = false } + started = true, finished = false , + } fillSync(MUSIC_FILL_INITIAL) if not musicHeld then source:play() end return source @@ -340,17 +438,20 @@ function ChipAudio.playMusic(data, header, allowLoops, synthName) musicGen = musicGen + 1 local gen = musicGen cmdCh:push({ cmd = "play", gen = gen, header = header, synth = synthName, - allowLoops = allowLoops, audio = slimAudio(data), + audioWorkerOptimization = require("src.core.Performance").audioWorkerOptimization ~= false, + allowLoops = allowLoops, audio = slimAudio(data), channelVolumes = ChipSynth.getChannelVolumes(), stereo = ChipSynth.getStereo(), - channelPitches = ChipSynth.getChannelPitches() }) + channelPitches = ChipSynth.getChannelPitches() , + }) currentMusic = { source = source, gen = gen, threaded = true, synth = synth, synthName = synthName, started = false, finished = false, -- kept so a worker that dies before delivering its first -- buffer can be recovered onto the sync path below def = { data = data, header = header, - allowLoops = allowLoops, synth = synthName } } + allowLoops = allowLoops, synth = synthName } , + } -- playback starts in update() once the first buffer arrives (~1 frame) return source end @@ -360,7 +461,8 @@ local function pushChannelMix() cmdCh:push({ cmd = "channelMix", volumes = ChipSynth.getChannelVolumes(), stereo = ChipSynth.getStereo(), - pitches = ChipSynth.getChannelPitches() }) + pitches = ChipSynth.getChannelPitches() , + }) end end @@ -435,6 +537,10 @@ local function updateThreaded() end function ChipAudio.update() + local enabled=Performance.audioWorkerOptimization~=false + if workerReady and cmdCh and workerPerformance~=enabled then + cmdCh:push({cmd="performance",enabled=enabled});workerPerformance=enabled + end local m = currentMusic if not m then return end -- INTERRUPTION: nothing is fed to the device while the OS holds the session @@ -573,6 +679,7 @@ end -- hot reload: the next play re-reads programs.bin (a mod may have swapped the -- file out from under the single-slot bank cache), on both threads function ChipAudio.invalidate() + ChipAudio.cancelEffects() ChipAudio.stopMusic() ChipSynth.invalidateBanks() if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end @@ -582,10 +689,11 @@ end -- process exits and the worker's command loop only returns on "quit", so -- skipping this leaves the process running after the window is gone (#339). function ChipAudio.shutdown() + ChipAudio.cancelEffects() ChipAudio.stopMusic() if workerReady and cmdCh then cmdCh:push({ cmd = "quit" }) end if worker then pcall(function() worker:wait() end) end - worker, cmdCh, outCh = nil, nil, nil + worker, cmdCh, outCh , fxCh = nil, nil, nil, nil workerReady = false end @@ -645,10 +753,22 @@ end Assets.register(ChipAudio.invalidate) -- --------------------------------------------------------------------------- --- one-shot effects (SFX, cries, low-health alarm): synchronous static Sources +-- One-shot source constructors retain their synchronous fallback contract. -- --------------------------------------------------------------------------- +local fallbackLogged = {} +local function effectFallback(kind) + if fallbackLogged[kind] then + return + end + fallbackLogged[kind] = true + require("src.core.Logger").info( + "chip audio: %s synchronous fallback (cold cache, worker unavailable, or prewarm disabled)", + kind + ) +end local function renderEffect(data, header, options) + effectFallback("chip effect") local sd = ChipSynth.renderEffectData(data, header, options) if not sd then return nil end return love.audio.newSource(sd, "static") @@ -668,6 +788,7 @@ end -- A Gen 3 sound effect: the same sequencer as the music, run once to its end -- and handed over as a static Source. function ChipAudio.newM4AEffect(data, def) + effectFallback("sampled effect") local songs = data.audio and data.audio.songs local song = songs and def and def.m4a ~= nil and songs[("SONG_%03X"):format(def.m4a)] @@ -684,6 +805,7 @@ function ChipAudio.newCry(data, species, resolved) -- names an index into the cartridge's cry table and is rendered by the -- sampler next door. Same seam, same Source, different generation. if cry.m4a then + effectFallback("sampled cry") local sd = synthFor("m4a").renderCry(data, cry.m4a) if not sd then return nil end return love.audio.newSource(sd, "static") diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index 3880081c6..8f82292ab 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -1072,17 +1072,7 @@ end -- is available on worker threads, so this is the hand-off unit the worker -- produces and the main thread queues. local function soundData(engine, samples, channels) - local result = love.sound.newSoundData(samples, SAMPLE_RATE, 16, channels) - for index = 0, samples - 1 do - if channels == 2 then - local left, right = engine:sampleStereo() - result:setSample(index, 1, left) - result:setSample(index, 2, right) - else - result:setSample(index, engine:sample()) - end - end - return result + return require('src.core.AudioPcm').render(engine,samples,channels,SAMPLE_RATE) end -- Render a one-shot effect (SFX/cry) to a two-channel SoundData, or nil when @@ -1115,13 +1105,7 @@ local function renderEffectData(data, header, options) values[count] = engine:sample() end if count < math.floor(SAMPLE_RATE / 100) then return nil end - local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 2) - for index = 1, count do - local value = values[index] - result:setSample(index - 1, 1, value) - result:setSample(index - 1, 2, value) - end - return result + return require('src.core.AudioPcm').arrays(values,nil,count,SAMPLE_RATE) end ChipSynth.newEngine = Engine.new diff --git a/src/core/M4ASynth.lua b/src/core/M4ASynth.lua index 7dfa83025..854167bbc 100644 --- a/src/core/M4ASynth.lua +++ b/src/core/M4ASynth.lua @@ -962,6 +962,8 @@ function M4A.renderCry(data, index, options) -- OpenAL spatializes a mono Source at the listener and spreads it over -- every output the device has local result = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 2) + local Pcm=require("src.core.AudioPcm") + local pointer=Pcm.pointer(result) local gain = (options.volume or 1) * CRY_GAIN local frame, left = 0, 0 for i = 0, samples - 1 do @@ -972,8 +974,12 @@ function M4A.renderCry(data, index, options) frame = frame - 1 local value = voice:sample() * (voice.env / 255) * gain if value > 1 then value = 1 elseif value < -1 then value = -1 end - result:setSample(i, 1, value) - result:setSample(i, 2, value) + if pointer then + local sample=Pcm.sample(value);pointer[i*2],pointer[i*2+1]=sample,sample + else + result:setSample(i, 1, value) + result:setSample(i, 2, value) + end left = value end local _ = left @@ -1016,28 +1022,13 @@ function M4A.renderEffect(data, def, options) -- and a Source that is mostly silence blocks a caller waiting on it local length = math.min(n, audible + math.floor(SAMPLE_RATE * 0.05)) if length < 64 then return nil end - local result = love.sound.newSoundData(length, SAMPLE_RATE, 16, 2) - for i = 1, length do - result:setSample(i - 1, 1, left[i]) - result:setSample(i - 1, 2, right[i]) - end - return result + return require('src.core.AudioPcm').arrays(left,right,length,SAMPLE_RATE) end -- The same shape ChipSynth hands ChipAudio, so the queueing path does not -- have to know which generation it is filling buffers for. function M4A.soundData(engine, samples, channels) - local result = love.sound.newSoundData(samples, SAMPLE_RATE, 16, channels) - for index = 0, samples - 1 do - if channels == 2 then - local left, right = engine:sampleStereo() - result:setSample(index, 1, left) - result:setSample(index, 2, right) - else - result:setSample(index, engine:sample()) - end - end - return result + return require('src.core.AudioPcm').render(engine,samples,channels,SAMPLE_RATE) end return M4A diff --git a/src/core/Music.lua b/src/core/Music.lua index 01b2ef2ad..f188fffd0 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -686,6 +686,7 @@ end -- call once per frame: chains a finished intro into its loop body and -- restores the map theme after a one-shot jingle +local prewarmSound function Music.update(data) if data and data.audio and data.audio.ndsArchive then require("src.audio.NitroAudio").update() end -- INTERRUPTION: the OS holds the audio session (an incoming call), every @@ -693,6 +694,8 @@ function Music.update(data) -- in here is worth doing against a device that is not there -- and the -- ChipAudio.update on the next line is the call that crashed. if sessionSuspended then return end + if not prewarmSound then prewarmSound=require("src.core.Sound") end + if prewarmSound.update then prewarmSound.update(data) end -- The other half of the same case: the re-cue in Music.resume can land -- before the OS has handed the audio device back, and nothing else re-cues -- a map theme that never stopped being the map theme. So the retry is diff --git a/src/core/Performance.lua b/src/core/Performance.lua index 8e42d3b93..a9aa53cfd 100644 --- a/src/core/Performance.lua +++ b/src/core/Performance.lua @@ -51,6 +51,8 @@ Performance.hotPathHoist = true Performance.worldDrawHoist = true -- P5: palette upload batching and cropped zone quads. Performance.paletteZoneCache = true +-- P6: PCM bulk writes, speculative effect prewarming and DS worker transport. +Performance.audioWorkerOptimization = true Performance.GC_PAUSE = 120 Performance.GC_STEP_MUL = 300 Performance.GC_BUDGET_SECONDS = 0.0005 diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 7093a32f5..c3fce19dc 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -9,9 +9,99 @@ local Assets = require("src.render.Assets") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") +local Performance = require("src.core.Performance") + local Sound = {} local cache = {} +-- Speculative sources share this cache, but remain revocable until first use. +-- Public definitions and Source objects never acquire private cache fields. +local warmed, pending = {}, {} +local warmBytes, warmAudio, warmCursor, warmKind = 0, nil, nil, "sfx" +local WARM_LIMIT = 16 * 1024 * 1024 +local ChipAudio +local function chipAudio() + if not ChipAudio then + ChipAudio = require("src.core.ChipAudio") + end + return ChipAudio +end +local function copy(value, seen) + if type(value) ~= "table" then + assert( + type(value) == "nil" or type(value) == "string" or type(value) == "number" or type(value) == "boolean", + "custom audio definition requires synchronous fallback" + ) + return value + end + assert(getmetatable(value) == nil, "custom audio definition requires synchronous fallback") + seen = seen or {} + assert(not seen[value], "cyclic audio definition requires synchronous fallback") + local out = {} + seen[value] = out + for k, v in pairs(value) do + out[k] = copy(v, seen) + end + seen[value] = nil + return out +end +local function same(a, b, seen) + if a == b then + return true + end + if type(a) ~= "table" or type(b) ~= "table" then + return false + end + seen = seen or {} + if seen[a] == b then + return true + end + seen[a] = b + for k, v in pairs(a) do + if not same(v, b[k], seen) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end +local function forgetWarm(key, drop) + local row = warmed[key] + if not row then + return + end + warmed[key] = nil + warmBytes = warmBytes - row.bytes + if drop and cache[key] == row.source then + cache[key] = nil + if row.source.release then + pcall(row.source.release, row.source) + end + end +end +local function useWarm(data, key, kind, def, pitch, tempo) + local row = warmed[key] + if not row then + return + end + local spec = chipAudio().effectSpec(data, kind, def, pitch, tempo) + local valid = Performance.audioWorkerOptimization ~= false and same(row.spec, spec) + forgetWarm(key, not valid) +end +local function resetWarm() + for key in pairs(warmed) do + forgetWarm(key, true) + end + pending = {} + warmAudio, warmCursor, warmKind = nil, nil, "sfx" + if ChipAudio then + ChipAudio.cancelEffects() + end +end -- port addition: 0-7 SFX volume from save.options.sfxVol (OptionsMenu), -- scaling the 0.8 base every source gets local BASE_VOLUME = 0.8 @@ -231,6 +321,7 @@ local function playPath(data, key, def, pitch, tempo) -- INTERRUPTION: building a Source now would fail and cache `false` below, -- permanently disabling this effect (see sessionSuspended, case 2) if sessionSuspended then return nil end + useWarm(data, key, "sfx", def, pitch, tempo) local src = cache[key] if src == false then return nil end -- known bad, already logged if not src then @@ -394,7 +485,7 @@ function Sound.seDef(data, key) local direct = nativeDef(sfx[key]) if direct or type(key) ~= "string" then return direct end local base, n = key:match("^(.-)_sseq_(%d+)$") - if not base then base = key:match("^(.-)_sseq$"); n = base and 0 end + if not base then base = key:match("^(.-)_sseq$")n = base and 0 end if not base then return nil end n = tonumber(n) local anchor = nativeDef(sfx[base]) @@ -539,6 +630,95 @@ local function resolveCry(data, def, depth) } end +-- Poll and schedule one bounded job per frame. No prewarm operation plays a +-- Source, ducks music, or emits sound.played. A changed def/mix is rechecked +-- on first use, including in-place edits and derived cry chains. +function Sound.update(data) + if sessionSuspended or not love.audio then + return + end + if Performance.audioWorkerOptimization == false then + if warmAudio then + resetWarm() + end + return + end + local audio = data and data.audio + if not audio then + return + end + if warmAudio ~= audio then + resetWarm() + warmAudio = audio + end + local chip = chipAudio() + local row = chip.pollEffect() + while row do + local job = pending[row.id] + pending[row.id] = nil + if job and row.sd and cache[job.key] == nil and warmBytes + row.sd:getSize() <= WARM_LIMIT then + local ok, source = pcall(love.audio.newSource, row.sd, "static") + if ok and source then + safeAudio("Source:setVolume", source.setVolume, source, volumeFor(job.key)) + cache[job.key] = source + warmed[job.key] = { source = source, spec = job.spec, bytes = row.sd:getSize() } + warmBytes = warmBytes + row.sd:getSize() + end + end + row = chip.pollEffect() + end + local count = 0 + for _ in pairs(pending) do + count = count + 1 + end + if count >= 2 or warmBytes >= WARM_LIMIT or warmKind == "done" then + return + end + -- Skip a handful of unsupported/file/already-cached definitions without + -- walking an unbounded registry during a frame. + for _ = 1, 8 do + local defs = audio[warmKind] or {} + local ok, name, def = pcall(next, defs, warmCursor) + if not ok then + warmCursor = nil + return + end + warmCursor = name + if not name then + if warmKind == "sfx" then + warmKind = "cries" + else + warmKind = "done" + return + end + else + local kind = warmKind == "cries" and "cry" or "sfx" + local key = kind == "cry" and "cry:" .. tostring(name) or tostring(name) + local resolved = kind == "cry" and resolveCry(data, def, 0) or def + if + cache[key] == nil + and type(resolved) == "table" + and (kind == "cry" or isChipDef(resolved) or resolved.m4a) + then + local spec = chip.effectSpec(data, kind, resolved) + if spec then + local plain, frozen = pcall(copy, spec) + if plain then + local id = chip.requestEffect(frozen) + if id then + pending[id] = { key = key, spec = frozen } + else + -- No worker/capability: playback retains its logged cold path. + warmKind = "done" + end + return + end + end + end + end + end +end + local function newCrySource(data, species, def) -- A GEN 3 CRY names a recording rather than a chip program, so there is no -- base chain to resolve and no header to look up: the index IS the def. @@ -614,6 +794,7 @@ function Sound.playCry(data, species, opts) end if not def then return nil end local key = "cry:" .. tostring(species) + useWarm(data, key, "cry", resolveCry(data, def, 0)) local src = cache[key] if src == false then return nil end if not src then @@ -686,9 +867,9 @@ end function Sound.anyPlaying(kind) for key,src in pairs(cache) do - local cry=tostring(key):sub(1,4)=='cry:' - if src and ((kind=='cry' and cry) or (kind=='sfx' and not cry)) then - local ok,playing=pcall(src.isPlaying,src);if ok and playing then return true end + local cry=tostring(key):sub(1,4)=="cry:" + if src and ((kind=="cry" and cry) or (kind=="sfx" and not cry)) then + local ok,playing=pcall(src.isPlaying,src)if ok and playing then return true end end end return false @@ -781,6 +962,7 @@ end -- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo -- variants included) or all of them, so the next play re-resolves the def function Sound.invalidate(name) + resetWarm() local function evict(store, key) local src = store[key] if src then pcall(src.stop, src) end @@ -813,6 +995,7 @@ Assets.register(Sound.invalidate) function Sound.suspend() if sessionSuspended then return end sessionSuspended = true + resetWarm() -- A LOOPING source (the low-health siren, the link tournament theme) is the -- only kind that is still sounding when the call arrives. Ask it to stop -- while the device may still take the call, and forget it, so its owner's diff --git a/src/core/chip_worker.lua b/src/core/chip_worker.lua index 73f6b188d..c970a460f 100644 --- a/src/core/chip_worker.lua +++ b/src/core/chip_worker.lua @@ -20,10 +20,26 @@ require("love.thread") require("love.timer") require("love.sound") +require("love.data") require("love.filesystem") -package.preload['src.core.WorkerFs']=function() - return assert(love.filesystem.load('src/core/WorkerFs.lua'))() +package.preload["src.core.WorkerFs"]=function() + return assert(love.filesystem.load("src/core/WorkerFs.lua"))() end +for _, name in ipairs({ "Performance", "AudioPcm" }) do + local module = name + package.preload["src.core." .. module] = function() + return assert(love.filesystem.load("src/core/" .. module .. ".lua"))() + end +end + +for _,path in ipairs({"src.core.FrameProfile","src.core.Logger","src.import.Gen4Sdat", + "src.audio.NitroSequence","src.audio.NitroTransport","src.audio.NitroAudio","src.audio.NitroWorker"})do + local name=path + package.preload[name]=function() + return assert(love.filesystem.load(name:gsub("%.","/")..".lua"))() + end +end +local Nitro -- Load the synth explicitly via love.filesystem (a fresh thread Lua state does -- not necessarily carry the package searcher that resolves "src.core..."): @@ -51,6 +67,35 @@ end local cmdCh = love.thread.getChannel("chipaudio_cmd") local outCh = love.thread.getChannel("chipaudio_out") +local fxCh = love.thread.getChannel("chipaudio_fx") +local fxJobs, fxGeneration = {}, 1 +local function renderFx(job) + local spec = job.spec + local perf=require("src.core.Performance") + local priorOptimization=perf.audioWorkerOptimization + local volumes, pitches, stereo = ChipSynth.getChannelVolumes(), ChipSynth.getChannelPitches(), ChipSynth.getStereo() + ChipSynth.setChannelVolumes(spec.volumes) + ChipSynth.setChannelPitches(spec.pitches) + ChipSynth.setStereo(spec.stereo) + require("src.core.Performance").audioWorkerOptimization = job.audioWorkerOptimization ~= false + local ok, sd = pcall(function() + local input = { audio = spec.audio } + if spec.kind == "chip" then + return ChipSynth.renderEffectData(input, spec.header, spec.options) + elseif spec.kind == "m4a" then + return synthFor("m4a").renderEffect(input, spec.header) + elseif spec.kind == "cry" then + return synthFor("m4a").renderCry(input, spec.index) + end + error("unknown effect job") + end) + perf.audioWorkerOptimization=priorOptimization + ChipSynth.setChannelVolumes(volumes) + ChipSynth.setChannelPitches(pitches) + ChipSynth.setStereo(stereo) + fxCh:push({ generation = job.generation, id = job.id, sd = ok and sd or nil, error = not ok and tostring(sd) or nil }) +end + local BUF = ChipSynth.MUSIC_BUFFER_SAMPLES -- how many finished buffers may sit in the hand-off channel before the worker -- pauses. The deep (~6s) playback depth lives in the main-thread Source; this @@ -64,7 +109,24 @@ local finished = false -- the current song ran out (non-looping) local data = nil -- { audio = } for ROM bank/wave reads local function handle(cmd) - if cmd.cmd == "play" then + if cmd.cmd == "performance" then + require("src.core.Performance").audioWorkerOptimization=cmd.enabled + elseif cmd.cmd == "nitro" then + if not Nitro then Nitro=require("src.audio.NitroWorker") end + local ok,why=pcall(Nitro.command,cmd) + if not ok and cmd.id then + love.thread.getChannel("chipaudio_nds_"..cmd.id):push({gen=cmd.gen,error=tostring(why)}) + end + elseif cmd.cmd == "fx" then + if cmd.generation == fxGeneration and #fxJobs < 2 then + fxJobs[#fxJobs + 1] = cmd + end + elseif cmd.cmd == "fxCancel" then + fxGeneration = cmd.generation + fxJobs = {} + fxCh:clear() + elseif cmd.cmd == "play" then + require("src.core.Performance").audioWorkerOptimization = cmd.audioWorkerOptimization ~= false ChipSynth.setStereo(cmd.stereo) gen = cmd.gen finished = false @@ -129,8 +191,14 @@ while true do finished = true end end - else + elseif #fxJobs == 0 then -- nothing to do (idle, or the look-ahead is full): yield the core love.timer.sleep(0.001) end + -- At most one effect between music buffers, so prewarming cannot monopolize + -- the worker while a streaming source needs its next buffer. + if #fxJobs > 0 and fxCh:getCount() < 2 then + renderFx(table.remove(fxJobs, 1)) + end + if Nitro then Nitro.step() end end diff --git a/tools/audio-profile-check/NitroCheck.lua b/tools/audio-profile-check/NitroCheck.lua new file mode 100644 index 000000000..b5ba85c52 --- /dev/null +++ b/tools/audio-profile-check/NitroCheck.lua @@ -0,0 +1,160 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +return function(root) + local Audio = require("src.audio.NitroAudio") + local Sdat = require("src.import.Gen4Sdat") + local P = require("src.core.Performance") + local Fixture = assert(loadfile(root .. "/tools/audio-profile-check/NitroFixture.lua"))() + local Sequence = require("src.audio.NitroSequence") + local checks = 0 + local function check(ok, message) + assert(ok, message) + checks = checks + 1 + end + -- Direct engine comparison covers all samples and exact event/phase state. + for _, format in ipairs({ 0, 1, 2 }) do + for _, looping in ipairs({ false, true }) do + local arc = assert(Sdat.open(Fixture(format, looping))) + local before + local after = Audio._workerNew(arc, { nds = 0 }) + -- The baseline renderer retains ordinary arrays; do not compact its samples. + -- Use a separately constructed public legacy instance through its native + -- archive seam, keeping the SDAT decoder and sequencer unmodified. + local oldArchive = Audio.archive + Audio.archive = function() + return arc + end + P.audioWorkerOptimization = false + before = Audio.new({ audio = {} }, { nds = 0 }) + Audio.archive = oldArchive + before.tickPhase = 1 + before.variables = {} + before.looping = looping + after.looping = looping + local sent = {} + for _ = 1, 8 do + local sd, state, _, done = Audio._workerRender(after, 8192, sent) + local bytes = {} + for _ = 1, sd:getSampleCount() / 512 do + bytes[#bytes + 1] = before:render():getString() + end + check(table.concat(bytes) == sd:getString(), "DS PCM differs, format " .. format) + check(before.tickPhase == state.tickPhase and before.tempo == state.tempo, "DS tick phase differs") + check(before:variable(0) == after:variable(0), "DS local variable differs") + check(#before.voices == #state.voices, "DS voice count differs") + for n, v in ipairs(before.voices) do + for key, value in pairs(v) do + if key ~= "sample" then + check(value == state.voices[n][key], "DS voice field differs: " .. key) + end + end + end + if done then + check(before.ended and #before.voices == 0, "DS tail padding changed") + break + end + end + end + end + check(not Sequence.workerSafe(string.char(160, 128, 0, 0, 1, 0), 0), "random prefix admitted") + check(not Sequence.workerSafe(string.char(176, 16, 0, 0, 255), 0), "shared variable admitted") + check(Sequence.workerSafe(string.char(176, 0, 160, 0, 255), 0), "argument mistaken for random opcode") + P.audioWorkerOptimization = true + local arc = assert(Sdat.open(Fixture(0, true))) + local oldArchive = Audio.archive + Audio.archive = function() + return arc + end + local p = Audio.new({ audio = {} }, { nds = 0 }) + local q = Audio.new({ audio = {} }, { nds = 0 }) + Audio.archive = oldArchive + p:play() + q:play() + local deadline = love.timer.getTime() + 10 + repeat + Audio.update() + love.timer.sleep(0.001) + until #p.queue.buffers > 0 and #q.queue.buffers > 0 or love.timer.getTime() > deadline + check(#p.queue.buffers > 0 and #q.queue.buffers > 0, "DS worker did not feed independent players") + check(p.queue.capacity == 16 and p.queue.buffers[1]:getSampleCount() == 8192, "DS queue/block size changed") + check(rawget(p, "voices") == nil, "worker state was not kept private") + p:setVariable(0, 42) + check(p:variable(0) == 42, "DS setter did not update immediate query") + local voices = p.voices + check(type(voices) == "table" and rawget(p, "voices") == voices, "direct state read did not restore legacy fields") + check(type(p.samples) == "table", "legacy sample cache not restored") + for _, sample in pairs(p.samples) do + check(type(sample.pcm) == "table", "private PCM leaked through a state read") + end + p:stop() + q:stop() + check(not p:isPlaying() and not q:isPlaying(), "DS stopped player remained active") + Audio.archive = function() + return arc + end + local custom = Audio.new({ audio = {} }, { nds = 0 }) + local written = Audio.new({ audio = {} }, { nds = 0 }) + Audio.archive = oldArchive + custom.render = function() + return "owned custom renderer" + end + check( + type(rawget(custom, "tracks")) == "table" and custom:render() == "owned custom renderer", + "custom render did not select legacy transport" + ) + written.tempo = 73 + check( + rawget(written, "tempo") == 73 and type(written.tracks) == "table", + "direct state write did not select legacy transport" + ) + + Audio.archive=function()return arc end + local rolled=Audio.new({audio={}},{nds=0}) + Audio.archive=oldArchive + rolled:play();P.audioWorkerOptimization=false;rolled:update() + check(type(rawget(rolled,'voices'))=='table','P6 disable did not restore legacy state') + rolled:stop();P.audioWorkerOptimization=true + P.audioWorkerOptimization = false + Audio.archive = function() + return arc + end + local legacy = Audio.new({ audio = {} }, { nds = 0 }) + P.audioWorkerOptimization = true + local threaded = Audio.new({ audio = {} }, { nds = 0 }) + Audio.archive = oldArchive + legacy:play() + threaded:play() + local deadline2 = love.timer.getTime() + 10 + repeat + threaded:update() + love.timer.sleep(0.001) + until #threaded.queue.buffers == 16 or love.timer.getTime() > deadline2 + check(#threaded.queue.buffers == 16, "DS queue did not reach its bounded depth") + local offsets = { [legacy] = 0, [threaded] = 0 } + local function step(player) + offsets[player] = offsets[player] + 512 + local first = player.queue.buffers[1] + while first and offsets[player] >= first:getSampleCount() do + offsets[player] = offsets[player] - first:getSampleCount() + table.remove(player.queue.buffers, 1) + first = player.queue.buffers[1] + end + player:update() + end + return checks, + { + keepAlive = function() + threaded:update() + end, + legacy = function() + step(legacy) + end, + threaded = function() + assert(rawget(threaded, "voices") == nil, "DS benchmark left the worker") + step(threaded) + end, + close = function() + legacy:stop() + threaded:stop() + end, + } +end diff --git a/tools/audio-profile-check/NitroFixture.lua b/tools/audio-profile-check/NitroFixture.lua new file mode 100644 index 000000000..a6cad46e7 --- /dev/null +++ b/tools/audio-profile-check/NitroFixture.lua @@ -0,0 +1,93 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Owned, minimal SDAT/SSEQ/SBNK/SWAR bytes, with all three voice kinds. +return function(format, looping) + local b = {} + for n = 1, 2048 do + b[n] = 0 + end + local function byte(at, n) + b[at + 1] = n % 256 + end + local function word(at, n, size) + for i = 0, (size or 4) - 1 do + byte(at + i, n) + n = math.floor(n / 256) + end + end + local function text(at, s) + for n = 1, #s do + byte(at + n - 1, s:byte(n)) + end + end + text(0, "SDAT") + word(24, 64) + word(32, 512) + word(40, 1024) + word(64 + 8, 48) + word(64 + 16, 64) + word(64 + 20, 80) + for _, r in ipairs({ { 112, 128 }, { 128, 144 }, { 144, 160 } }) do + word(r[1], 1) + word(r[1] + 4, r[2]) + end + word(192, 0, 2) + word(196, 0, 2) + byte(198, 127) + word(208, 1, 2) + word(212, 0, 2) + word(214, 65535, 2) + word(216, 65535, 2) + word(218, 65535, 2) + word(224, 2, 2) + local events = + { 199, 0, 129, 0, 225, 120, 0, 176, 0, 3, 0, 60, 120, 40, 129, 1, 64, 110, 20, 129, 2, 67, 100, 10, 128, 50, 177, 0, 2, 0 } + if looping then + events[#events + 1] = 148 + events[#events + 1] = 0 + events[#events + 1] = 0 + events[#events + 1] = 0 + else + events[#events + 1] = 255 + end + text(1024, "SSEQ") + word(1024 + 24, 28) + for n, v in ipairs(events) do + byte(1024 + 28 + n - 1, v) + end + text(1200, "SBNK") + word(1200 + 56, 3) + for i = 0, 2 do + byte(1200 + 60 + i * 4, i + 1) + word(1200 + 61 + i * 4, 80 + i * 10, 2) + local at = 1200 + 80 + i * 10 + word(at, i == 0 and 0 or 3, 2) + word(at + 2, 0, 2) + byte(at + 4, 60) + byte(at + 9, 64) + end + text(1400, "SWAR") + word(1400 + 56, 1) + word(1400 + 60, 64) + local at = 1464 + byte(at, format or 0) + byte(at + 1, 1) + word(at + 2, 16384, 2) + word(at + 6, 1, 2) + word(at + 8, 31) + for n = 0, 127 do + byte(at + 12 + n, math.floor(math.sin(n * 0.17) * 100)) + end + if format == 2 then + word(at + 12, 0, 2) + word(at + 14, 0, 2) + end + for i, r in ipairs({ { 1024, 28 + #events }, { 1200, 128 }, { 1400, 204 } }) do + word(512 + 12 + (i - 1) * 16, r[1]) + word(512 + 16 + (i - 1) * 16, r[2]) + end + local out = {} + for n = 1, #b do + out[n] = string.char(b[n]) + end + return table.concat(out) +end diff --git a/tools/audio-profile-check/conf.lua b/tools/audio-profile-check/conf.lua new file mode 100644 index 000000000..7cf937501 --- /dev/null +++ b/tools/audio-profile-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv("AUDIO_PROFILE_QA_GUARDED") == "1", "use the guarded audio checker") + t.identity = assert(os.getenv("POKEPORT_IDENTITY")) + t.window.title = "Native PCM qualification" + t.window.width, t.window.height, t.window.vsync = 480, 240, 1 + t.modules.audio = false +end diff --git a/tools/audio-profile-check/main.lua b/tools/audio-profile-check/main.lua new file mode 100644 index 000000000..b46811e5a --- /dev/null +++ b/tools/audio-profile-check/main.lua @@ -0,0 +1,358 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local root = assert(os.getenv("AUDIO_PROFILE_ROOT")) +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +local Pcm = require("src.core.AudioPcm") +local Performance = require("src.core.Performance") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local names = { "src.core.ChipSynth", "src.core.M4ASynth" } +local function engine() + return { + at = 0, + sample = function(self) + self.at = self.at + 1 + -- Include signed extremes and values near the native float/int boundary. + return self.at % 7 == 0 and -1 or (self.at % 65535 - 32767) / 32767 + end, + sampleStereo = function(self) + local value = self:sample() + return value, -value + end, + } +end +local checks = 0 +local function qualifyEffects() + -- The QA app lives in tools/. Adapt only module file resolution; the + -- actual worker and channel protocol still run in a fresh native Lua state. + local newThread = love.thread.newThread + love.thread.newThread = function(path) + assert(path == "src/core/chip_worker.lua", "unexpected worker") + local boot = ('local root=%q; package.path=root.."/?.lua;"..package.path; require("love.filesystem"); love.filesystem.load=function(path)return loadfile(root.."/"..path)end; assert(loadfile(root.."/src/core/chip_worker.lua"))()'):format( + root + ) + return newThread(love.filesystem.newFileData(boot, "audio_qa_worker.lua")) + end + local Synth = require("src.core.ChipSynth") + local Chip = require("src.core.ChipAudio") + local Sound = require("src.core.Sound") + local Runtime = require("src.mods.Runtime") + local Asm = require("src.audio.ChipAsm") + local effects, played, builds = {}, 0, 0 + love.audio = { + newQueueableSource = function(_, _, _, capacity) + local q = { capacity = capacity, buffers = {}, playing = false, volume = 1 } + function q:stop() + self.buffers = {} + self.playing = false + end + function q:play() + self.playing = true + end + function q:pause() + self.playing = false + end + function q:isPlaying() + return self.playing + end + function q:getFreeBufferCount() + return self.capacity - #self.buffers + end + function q:queue(sd) + assert(#self.buffers < self.capacity) + self.buffers[#self.buffers + 1] = sd + end + function q:setVolume(v) + self.volume = v + end + function q:getVolume() + return self.volume + end + return q + end, + newSource = function(sd) + builds = builds + 1 + local source = { sd = sd, volume = 1, plays = 0 } + function source:setVolume(v) + self.volume = v + end + function source:stop() + self.playing = false + end + function source:play() + self.plays = self.plays + 1 + self.playing = true + end + function source:isPlaying() + return self.playing + end + function source:release() + self.released = true + end + effects[#effects + 1] = source + return source + end, + } + local oldEmit, oldWants = Runtime.emit, Runtime.wants + Runtime.wants = function(name) + return name == "sound.played" + end + Runtime.emit = function(name) + if name == "sound.played" then + played = played + 1 + end + end + local beep = Asm.song({ + tempo = 0x100, + channels = { + { + hw = 1, + program = { + { duty = 2 }, + { notetype = { speed = 1, volume = 12, fade = 0 } }, + { octave = 4 }, + { note = "C", len = 1 }, + }, + }, + }, + }) + local data = { audio = { sfx = { beep = beep }, cries = { MON = { header = beep, pitch = 0, length = 128 } } } } + local function wait(count) + local deadline = love.timer.getTime() + 10 + repeat + Sound.update(data) + love.timer.sleep(0.001) + until builds >= count or love.timer.getTime() > deadline + assert(builds >= count, "prewarm worker did not deliver its PCM") + end + wait(2) + assert(played == 0 and effects[1].plays == 0 and effects[2].plays == 0, "prewarm played/emitted") + Performance.audioWorkerOptimization = false + local expected = Synth.renderEffectData(data, beep, { frequencyOffset = 0, frameTicks = 256 }) + Performance.audioWorkerOptimization = true + local before = builds + local source = assert(Sound.play(data, "beep")) + assert(builds == before and source.sd:getString() == expected:getString(), "prewarm did not reuse exact cached PCM") + assert(played == 1 and source.plays == 1, "real sound event/play count changed") + checks = checks + 4 + Sound.invalidate() + Chip.cancelEffects() + wait(before + 2) + -- A changed mix invalidates an unused speculative source, while used + -- sources keep the original cache semantics. + Chip.setChannelVolume(1, 0.4) + before = builds + assert(Sound.play(data, "beep")) + assert(builds == before + 1, "stale speculative mix was reused") + checks = checks + 1 + Sound.invalidate() + -- A wholly owned tiny M4A image: one sampled voice and a short sequence. + local function word(n) + local out = {} + for i = 1, 4 do + out[i] = string.char(n % 256) + n = math.floor(n / 256) + end + return table.concat(out) + end + local image = string.char(0, 60, 0, 0) .. word(0x08000040) .. string.rep(string.char(255), 4) + image = image .. string.rep("\0", 16 - #image) .. string.char(0xBD, 0, 0xBE, 127, 0xD4, 60, 127, 0x85, 0xB1) + image = image .. string.rep("\0", 64 - #image) .. word(0) .. word(32768 * 1024) .. word(0) .. word(128) + for n = 1, 128 do + image = image .. string.char(math.floor(math.sin(n * 0.17) * 100) % 256) + end + assert(love.filesystem.write("audio-owned-image.bin", image)) + data = { + audio = { + gen3 = { file = "audio-owned-image.bin", base = 0 }, + gen3Cries = { table = 0 }, + songs = { SONG_001 = { voicegroup = 0, tracks = { 16 } } }, + sfx = { sampled = { m4a = 1 } }, + cries = { SAMPLED = { m4a = 0 } }, + }, + } + before = builds + wait(before + 2) + local M4A = require("src.core.M4ASynth") + Performance.audioWorkerOptimization = false + local effect = M4A.renderEffect(data, data.audio.songs.SONG_001) + local cry = M4A.renderCry(data, 0) + Performance.audioWorkerOptimization = true + assert(effect and cry, "owned sampled fixture is silent/invalid") + assert(Sound.play(data, "sampled").sd:getString() == effect:getString(), "sampled worker effect PCM differs") + assert(Sound.playCry(data, "SAMPLED").sd:getString() == cry:getString(), "sampled worker cry PCM differs") + assert(builds == before + 2, "sampled effects did not reuse Sound cache") + checks = checks + 3 + Sound.invalidate() + before = builds + wait(before + 2) + -- Def-local edits cannot reuse speculative bytes made before that edit. + data.audio.sfx.sampled = { m4a = 99 } + before = builds + assert(Sound.play(data, "sampled") == nil and builds == before, "stale edited definition was played") + Sound.invalidate() + -- Cancelling in-flight jobs never admits their late SoundData into cache. + Sound.update(data) + Sound.suspend() + before = builds + local untilTime = love.timer.getTime() + 0.05 + repeat + Sound.update(data) + love.timer.sleep(0.001) + until love.timer.getTime() > untilTime + assert(builds == before, "late cancelled source entered a suspended cache") + checks = checks + 2 + before = builds + Sound.update(data) + assert(builds == before and Sound.play(data, "beep") == nil, "suspended prewarm built/played") + Sound.resume() + Chip.setChannelVolume(1, 1) + checks = checks + 1 + local dsChecks, ds = assert(loadfile(root .. "/tools/audio-profile-check/NitroCheck.lua"))()(root) + checks = checks + dsChecks + Sound.invalidate() + data = { audio = { sfx = {}, cries = {} } } + for i = 1, 240 do + data.audio.sfx["cue" .. i] = beep + end + before = builds + wait(before + 240) + Runtime.emit, Runtime.wants = oldEmit, oldWants + return { + ds = ds, + cold = function() + return Chip.newSfx(data, "cue1") + end, + warm = function(i) + return assert(Sound.play(data, "cue" .. i)) + end, + close = function() + ds.close() + Sound.invalidate() + Chip.shutdown() + love.thread.newThread = newThread + love.audio = nil + end, + } +end +local function qualify() + for _, name in ipairs(names) do + local synth = require(name) + for _, channels in ipairs({ 1, 2 }) do + local a, b = engine(), engine() + Performance.audioWorkerOptimization = false + local before = synth.soundData(a, 65536, channels) + Performance.audioWorkerOptimization = true + local after = synth.soundData(b, 65536, channels) + assert(before:getString() == after:getString(), name .. " PCM differs") + assert(a.at == b.at and a.at == 65536, "sample cadence changed") + assert(not jit.status() or Pcm.pointer(after), "native FFI path is unavailable") + before:release() + after:release() + checks = checks + 1 + end + end + local left, right = {}, {} + for n = 1, 65536 do + left[n], right[n] = (n % 65535 - 32767) / 32767, math.sin(n) * 0.9 + end + for _, stereo in ipairs({ false, true }) do + Performance.audioWorkerOptimization = false + local before = Pcm.arrays(left, stereo and right or nil, 65536, 32768) + Performance.audioWorkerOptimization = true + local after = Pcm.arrays(left, stereo and right or nil, 65536, 32768) + assert(before:getString() == after:getString(), "effect-array PCM differs") + before:release() + after:release() + checks = checks + 1 + end +end +local phase, frame, total = 1, 0, 0 +local results = {} +function love.errorhandler(message) + print(debug.traceback(tostring(message), 2)) + local chip = package.loaded["src.core.ChipAudio"] + if chip then + pcall(chip.shutdown) + end + return function() + return 1 + end +end +function love.run() + qualify() + local fx = qualifyEffects() + local labels = { + "setSample", + "automatic PCM", + "cold effect trigger", + "prewarmed effect trigger", + "legacy DS update", + "worker DS update", + } + return function() + love.event.pump() + for event in love.event.poll() do + if event == "quit" then + return 1 + end + end + if frame == 120 then + Profile.setEnabled(true) + end + Performance.audioWorkerOptimization = true + if phase ~= 6 then + fx.ds.keepAlive() + end + Performance.audioWorkerOptimization = phase >= 2 + local close = Profile.section( + phase <= 2 and "audio.PCM write (8192 stereo)" + or phase <= 4 and "audio.effect trigger (owned Source hardware)" + or "audio.DS update (512 samples/frame consumption)" + ) + local began = love.timer.getTime() + local sound + if phase <= 2 then + sound = Pcm.render(engine(), 8192, 2, 32768) + elseif phase == 3 then + fx.cold() + elseif phase == 4 then + fx.warm(frame + 1) + elseif phase == 5 then + fx.ds.legacy() + else + fx.ds.threaded() + end + if frame >= 120 then + total = total + love.timer.getTime() - began + end + close() + if sound then + sound:release() + end + love.graphics.clear(0.03, 0.04, 0.06) + love.graphics.print(labels[phase], 12, 12) + love.graphics.present() + if frame >= 120 then + Profile.frame() + end + frame = frame + 1 + if frame == 240 then + results[phase] = { mode = labels[phase], frames = 120, meanMs = total / 120 * 1000 } + if phase == 6 then + fx.close() + print("AUDIO_PCM_RESULT " .. Json.encode({ + phases = results, + checks = checks, + renderer = love.graphics.getRendererInfo(), + os = love.system.getOS(), + arch = jit.arch, + jitEnabled = jit.status(), + scope = "Native SoundData/worker, owned sample programs and Source hardware double; no device playback or cartridge music", + })) + return 0 + end + Profile.setEnabled(false) + phase, frame, total = phase + 1, 0, 0 + end + end +end diff --git a/tools/check_audio_profile.py b/tools/check_audio_profile.py new file mode 100644 index 000000000..600c7a770 --- /dev/null +++ b/tools/check_audio_profile.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Qualify native PCM writes without opening a user's audio/save profile.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +import time + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--love',type=Path,required=True) + parser.add_argument('--report',type=Path,required=True) + parser.add_argument('--qa-tools',type=Path) + args=parser.parse_args() + root=Path(__file__).resolve().parents[1] + sys.path.insert(0,str(args.qa_tools or root/'tools')) + from qa_storage import qa_source,qa_cache,qa_profile,qa_run + qa_source(root);qa_source(args.love.resolve()) + qa_cache(args.report.parent);args.report.parent.mkdir(parents=True,exist_ok=True) + names=('src/core/AudioPcm.lua','src/core/ChipSynth.lua','src/core/M4ASynth.lua','src/core/chip_worker.lua','src/core/ChipAudio.lua', + 'src/core/Sound.lua','src/core/Music.lua','src/core/Performance.lua', + 'src/audio/NitroAudio.lua','src/audio/NitroTransport.lua','src/audio/NitroSequence.lua', + 'src/audio/NitroWorker.lua','tools/audio-profile-check/NitroFixture.lua','tools/audio-profile-check/NitroCheck.lua', + 'tools/audio-profile-check/main.lua','tools/audio-profile-check/conf.lua') + hashes={name:hashlib.sha256((root/name).read_bytes()).hexdigest()for name in names} + identity='oasc-audio-pcm-qa-'+str(time.time_ns()) + qa_profile(Path.home()/'Library/Application Support/LOVE'/identity) + run=qa_run([str(args.love.resolve()),str(root/'tools/audio-profile-check')],cwd=root, + env=dict(os.environ,AUDIO_PROFILE_QA_GUARDED='1',AUDIO_PROFILE_ROOT=str(root),POKEPORT_IDENTITY=identity), + capture_output=True,text=True,timeout=90) + args.report.with_suffix('.log').write_text(run.stdout+run.stderr) + records=[line.removeprefix('AUDIO_PCM_RESULT ')for line in run.stdout.splitlines()if line.startswith('AUDIO_PCM_RESULT ')] + if run.returncode or len(records)!=1:raise RuntimeError('native PCM probe failed; see its log') + if any(hashlib.sha256((root/name).read_bytes()).hexdigest()!=value for name,value in hashes.items()): + raise RuntimeError('measured source changed during the probe') + report=json.loads(records[0]);report.update(sha256=hashes,playerProfilesUsed=False) + args.report.write_text(json.dumps(report,indent=2)+'\n');print(json.dumps(report,indent=2)) + + +if __name__=='__main__':main() From 5af8542a384f01beeffc1c1f88f82d9b9a0926c4 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 13:29:56 +0100 Subject: [PATCH 113/138] Document qualified P6 worker and PCM validation --- docs/audio-workers-p6.md | 9 ++-- docs/qa/audio-workers-p6.json | 80 +++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 docs/qa/audio-workers-p6.json diff --git a/docs/audio-workers-p6.md b/docs/audio-workers-p6.md index 4a5886254..c0283cef6 100644 --- a/docs/audio-workers-p6.md +++ b/docs/audio-workers-p6.md @@ -95,11 +95,12 @@ owned fixture and Source double described above: | Main-thread section | Legacy/cold | P6 | | --- | ---: | ---: | -| 8192 stereo PCM frames | 0.256 ms | 0.114 ms | -| First effect cue | 0.314 ms | 0.030 ms after prewarm | -| DS update, simulated 512 frames consumed per frame | 0.126 ms | 0.011 ms | +| 8192 stereo PCM frames | 0.148 ms | 0.065 ms | +| First effect cue | 0.235 ms | 0.019 ms after prewarm | +| DS update, simulated 512 frames consumed per frame | 0.089 ms | 0.010 ms | -The native checker passes 358 scoped checks. These values are source-bound to +The [source-bound validation record](qa/audio-workers-p6.json) retains the native +report and acceptance summary. The native checker passes 358 scoped checks. These values are source-bound to its local report; rerun the checker for another host/build. They exclude actual Source-device cost. Interpreted LÖVE 11 keeps `setSample`; a native comparison must not be presented as a JIT FFI speedup on that build. diff --git a/docs/qa/audio-workers-p6.json b/docs/qa/audio-workers-p6.json new file mode 100644 index 000000000..937b2b72c --- /dev/null +++ b/docs/qa/audio-workers-p6.json @@ -0,0 +1,80 @@ +{ + "runtimeCommit": "d7bbf97d03cc7af1070a4ac909c93f5f60882287", + "native": { + "phases": [ + { + "frames": 120, + "mode": "setSample", + "meanMs": 0.1483236111110712 + }, + { + "frames": 120, + "mode": "automatic PCM", + "meanMs": 0.0648465277777325 + }, + { + "frames": 120, + "mode": "cold effect trigger", + "meanMs": 0.23535000000003414 + }, + { + "frames": 120, + "mode": "prewarmed effect trigger", + "meanMs": 0.01920000000016723 + }, + { + "frames": 120, + "mode": "legacy DS update", + "meanMs": 0.08887118055559048 + }, + { + "frames": 120, + "mode": "worker DS update", + "meanMs": 0.010109027777686444 + } + ], + "renderer": "Metal", + "jitEnabled": true, + "scope": "Native SoundData/worker, owned sample programs and Source hardware double; no device playback or cartridge music", + "checks": 358, + "arch": "arm64", + "os": "OS X", + "sha256": { + "src/core/AudioPcm.lua": "02f0e7f94f2195e1044b3013762705f7a5e6fb8abf2ee292df2c2965e815848f", + "src/core/ChipSynth.lua": "ec359285be3e9bf4b015d2e3a9ff08527793719a489a89bdf2d1c6a0e4bac179", + "src/core/M4ASynth.lua": "0d60dcd9cb91a44f40186b9f1eb55c28c400b28960f5a4a21eb19d19c3c10a77", + "src/core/chip_worker.lua": "c58df4dc639d4d8427527fc1fac8671014ccadbca81238060183523f723eaf38", + "src/core/ChipAudio.lua": "e7e5cde5da08915386269450d822d027d17afab961b0db8e6016010d17717541", + "src/core/Sound.lua": "7473527636d2198916c16ee40322af12805439925dd4ae5b61f594d3190b4eec", + "src/core/Music.lua": "349a213316df07dd293a272d0e91131438c68e2b18286310af755c4a962a6432", + "src/core/Performance.lua": "a66d32858cb1226f81182654f9048a116693b3a5c6838d740d31f36faeb1b257", + "src/audio/NitroAudio.lua": "1d81f278653ce90e5521d9b89d9a75ad24a9d860927e00e9ce87a715137366cf", + "src/audio/NitroTransport.lua": "bc2e6e0f3aae8020bcf06a8d5591f59a03955e05d02b7e355fe02e82ee9fbbd2", + "src/audio/NitroSequence.lua": "5a93a96afebe4945e951fe0cd204924f4e350cc1aa8fa8c348774dc69ca1463a", + "src/audio/NitroWorker.lua": "6ae969b5bc6171060aee2226a2ea536d6a5223d87ee86be43ed2d46067e8a4ab", + "tools/audio-profile-check/NitroFixture.lua": "6b9c147ddfcb0bd33fd401549cc4108a505da362c964851ab703d1a2d839ca67", + "tools/audio-profile-check/NitroCheck.lua": "a18c2772663147a5337af334e3b4e61fca98cdfc52898f0d5f034f76cd5a14ce", + "tools/audio-profile-check/main.lua": "0a73735704bc199f03bee47be68abc23a711a9ddaa0d1679e24bd7f2b3d1c20f", + "tools/audio-profile-check/conf.lua": "58c1fc954bc54b6e07cd13e989ff812bd86d34cdd0df70fcb17d05d50bbcdaa4" + }, + "playerProfilesUsed": false + }, + "acceptance": { + "baseline": "9dac33b1ca38b1c3855ef52dd3bcbcdc6aa3ec62", + "engine": { + "passed": 156, + "total": 167, + "inheritedFailedSuites": 11, + "baselineFailLines": 29, + "candidateFailLines": 29, + "identicalFailureCounter": true + }, + "sdkSuites": 3, + "galleryChecks": 253, + "api3Comparisons": 48, + "isolatedApi3Workers": 96, + "externalFixtureEntrypoints": 17, + "externalFixturePhase": "complete", + "hostedCI": "not established" + } +} From 73ff4d48756f10ae848ee3b745cb67ab2b2fc7bd Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:21:38 +0100 Subject: [PATCH 114/138] perf(P7): anchor static sun shadows and split moving casters --- mods/DRAMATIC_SHAPE/lib/ShadowMap.lua | 88 +++++-- mods/DRAMATIC_SHAPE/lib/Voxel3D.lua | 19 ++ mods/DRAMATIC_SHAPE/lib/VoxelScene.lua | 133 ++++++---- mods/DRAMATIC_SHAPE/lib/WorldShadows.lua | 117 +++++++++ mods/DRAMATIC_SHAPE/main.lua | 2 + tools/check_world_shadow_profile.py | 50 ++++ tools/world-shadow-check/conf.lua | 7 + tools/world-shadow-check/main.lua | 297 +++++++++++++++++++++++ 8 files changed, 649 insertions(+), 64 deletions(-) create mode 100644 mods/DRAMATIC_SHAPE/lib/WorldShadows.lua create mode 100644 tools/check_world_shadow_profile.py create mode 100644 tools/world-shadow-check/conf.lua create mode 100644 tools/world-shadow-check/main.lua diff --git a/mods/DRAMATIC_SHAPE/lib/ShadowMap.lua b/mods/DRAMATIC_SHAPE/lib/ShadowMap.lua index 320dfc268..17cc51b88 100644 --- a/mods/DRAMATIC_SHAPE/lib/ShadowMap.lua +++ b/mods/DRAMATIC_SHAPE/lib/ShadowMap.lua @@ -168,6 +168,8 @@ ShadowMap._source = function() return SHADER end -- named for the suite local shader = nil -- nil = untried, false = unavailable local canvas = nil -- nil = untried, false = unavailable +local dynamicCanvas, dynamicReady = nil, false +local anchored = false local canvasRes = 0 -- the edge `canvas` was made at local blank = nil -- 1x1 stand-in so the sampler is never unbound local drawing = false @@ -353,20 +355,20 @@ end -- continuously with the camera reprojects every shadow edge a fraction of a -- texel every frame and the whole world's shadows crawl and shimmer while -- you walk. -local function fit(cx, cy, vw, vh) +function ShadowMap.groundBounds(cx,cy,vw,vh) + local reach=ShadowMap.HEIGHT*math.max(math.abs(ShadowMap.KX),math.abs(ShadowMap.KZ))+24 + local north=groundReach(vh) + local spread=north*.5 + return cx-vw/2-spread,cx+vw/2+spread+reach,cy-north,cy+vh/2+reach +end +local function fit(cx, cy, vw, vh, bounds) local f = sunDir() local view = Mat4.lookAt({ 0, 0, 0 }, f, { 0, 0, -1 }) - - local reach = ShadowMap.HEIGHT - * math.max(math.abs(ShadowMap.KX), math.abs(ShadowMap.KZ)) + 24 - local north = groundReach(vh) - -- the view widens with distance, so the far ground spans more than the - -- near ground does; half the depth is a serviceable stand-in for the - -- frustum's true spread and costs a good deal less resolution - local spread = north * 0.5 - local xs = { cx - vw / 2 - spread, cx + vw / 2 + spread + reach } - local ys = { -32, ShadowMap.HEIGHT } -- -32 covers recessed water - local zs = { cy - north, cy + vh / 2 + reach } + local x0,x1,z0,z1=ShadowMap.groundBounds(cx,cy,vw,vh) + if bounds then x0,x1,z0,z1=bounds[1],bounds[2],bounds[3],bounds[4]end + local xs={x0,x1} + local ys={-32,ShadowMap.HEIGHT} + local zs={z0,z1} local l, r, b, t, zn, zf for _, x in ipairs(xs) do @@ -428,6 +430,14 @@ local function fit(cx, cy, vw, vh) ShadowMap.clipVP = Mat4.mul(proj, view) ShadowMap.uvVP = Mat4.mul(TO_UNIT, ShadowMap.clipVP) + -- The LÖVE 12 Metal target rasterizes the canvas Y axis opposite to GL. + -- Correct the new anchored lookup, including water's static lookup; the + -- original four-argument pass keeps its existing conversion for rollback. + local major=love.getVersion and love.getVersion()or 11 + local renderer=love.graphics.getRendererInfo and love.graphics.getRendererInfo() + if bounds and major>=12 and renderer=="Metal"then + ShadowMap.uvVP=Mat4.mul({1,0,0,0, 0,-1,0,1, 0,0,1,0, 0,0,0,1},ShadowMap.uvVP) + end -- what the frustum ended up covering, for probes: the lateral extent in -- world pixels divided by RES is how fine a shadow edge can land ShadowMap.extent = { r - l, t - b, far - near } @@ -499,11 +509,11 @@ end -- Begin the sun pass. Returns false when it could not start, in which case -- the caller must not draw into it or call finish. -function ShadowMap.begin(cx, cy, vw, vh) +function ShadowMap.begin(cx, cy, vw, vh, bounds) local sh = getShader() if not sh then return false end -- fit first: it is what decides which resolution rung this view wants - fit(cx, cy, vw, vh) + fit(cx, cy, vw, vh, bounds) local c = getCanvas(ShadowMap.res) if not c then return false end local ok = pcall(love.graphics.setCanvas, { c, depth = true }) @@ -525,11 +535,60 @@ function ShadowMap.begin(cx, cy, vw, vh) -- the world until a cast pass says otherwise, reset per pass so one that -- forgot to put it back cannot leak into the next map's terrain ShaderState.send(sh, "sprite", 0) + anchored=bounds~=nil + dynamicReady=false drawing = true ready = false return true end +-- Optional P7 layer. Both textures use the same world-to-light matrix; +-- water keeps sampling only the static texture, as it already ignores actors. +function ShadowMap.isAnchored()return anchored end +function ShadowMap.useLegacy() + if anchored then ready=false end + anchored=false;dynamicReady=false +end +function ShadowMap.dynamicAvailable() + if dynamicCanvas==false then return false end + if dynamicCanvas then return true end + local ok,c=V.require("PixelCanvas").new(256,256) + if not(ok and c)then dynamicCanvas=false;return false end + c:setFilter("nearest","nearest");pcall(c.setWrap,c,"clamp","clamp") + dynamicCanvas=c + return true +end +function ShadowMap.dynamicActive() + return V.shadowWorldAnchor~=false and anchored and ready and dynamicReady and caps().shadows +end +function ShadowMap.dynamicTexture() + return ShadowMap.dynamicActive()and dynamicCanvas or getBlank() +end +function ShadowMap.beginDynamic() + if not(ready and anchored and ShadowMap.dynamicAvailable())then return false end + local ok=pcall(love.graphics.setCanvas,{dynamicCanvas,depth=true}) + if not ok then + pcall(love.graphics.setCanvas) + if dynamicCanvas.release then pcall(dynamicCanvas.release,dynamicCanvas)end + dynamicCanvas=false;dynamicReady=false + return false + end + prevBlend,prevAlphaMode=love.graphics.getBlendMode() + love.graphics.clear(1,1,0,1,true,true) + love.graphics.setDepthMode("lequal",true);love.graphics.setMeshCullMode("none") + love.graphics.setBlendMode("replace","premultiplied") + love.graphics.setShader(getShader());love.graphics.setColor(1,1,1,1) + ShaderState.send(getShader(),"lightVP","row",ShadowMap.clipVP) + ShaderState.send(getShader(),"sprite",1) + drawing=true;dynamicReady=false + return true +end +function ShadowMap.finishDynamic() + if not drawing then return end + ShadowMap.finish(lastSig) + dynamicReady=true +end + -- Draw one caster. Same signature as Voxel3D.draw minus the camera-ward -- pull, which is a trick for the VIEW's depth buffer and would drag a -- shadow off whatever throws it. @@ -593,6 +652,7 @@ end -- Drop the GPU objects (window resize, hot reload). function ShadowMap.invalidate() canvas, canvasRes, blank = nil, 0, nil + dynamicCanvas,dynamicReady,anchored=nil,false,false drawing, ready, lastSig = false, false, nil end diff --git a/mods/DRAMATIC_SHAPE/lib/Voxel3D.lua b/mods/DRAMATIC_SHAPE/lib/Voxel3D.lua index cc9c52b77..755345086 100644 --- a/mods/DRAMATIC_SHAPE/lib/Voxel3D.lua +++ b/mods/DRAMATIC_SHAPE/lib/Voxel3D.lua @@ -202,6 +202,9 @@ local SHADER = [[ #endif #ifdef PIXEL uniform Image sunMap; + uniform Image sunDynamicMap; + uniform float sunDynamicActive; + uniform vec2 sunDynamicTexel; uniform float sunDark; // how far into black a shadow goes; 0 = off uniform float sunBias; uniform vec2 sunTexel; @@ -212,6 +215,11 @@ local SHADER = [[ return c.r + c.g * (1.0 / 255.0); } + float sunDynamicDepth(vec2 uv) { + vec4 c = Texel(sunDynamicMap, uv); + return c.r + c.g * (1.0 / 255.0); + } + // 1.0 in full sun, 1.0 - sunDark in full shadow. Four taps half a texel // out on the diagonals: a 2x2 box filter, which is what turns the // shadow map's texel staircase into a one-pixel soft edge. @@ -235,6 +243,13 @@ local SHADER = [[ + step(z, sunDepth(p.xy + sunTexel * vec2( 0.5, -0.5))) + step(z, sunDepth(p.xy + sunTexel * vec2(-0.5, 0.5))) + step(z, sunDepth(p.xy + sunTexel * vec2( 0.5, 0.5))); + if (sunDynamicActive > 0.5) { + float moving = step(z, sunDynamicDepth(p.xy + sunDynamicTexel * vec2(-0.5, -0.5))) + + step(z, sunDynamicDepth(p.xy + sunDynamicTexel * vec2( 0.5, -0.5))) + + step(z, sunDynamicDepth(p.xy + sunDynamicTexel * vec2(-0.5, 0.5))) + + step(z, sunDynamicDepth(p.xy + sunDynamicTexel * vec2( 0.5, 0.5))); + lit = min(lit, moving); + } return 1.0 - sunDark * edge * (1.0 - lit * 0.25); } @@ -1018,6 +1033,10 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) ShaderState.send(sh, "sunVP", "row", map and ShadowMap.uvVP or IDENTITY) local tex = ShadowMap.texture() if tex then ShaderState.send(sh, "sunMap", tex) end + local movingTex=ShadowMap.dynamicTexture() + if movingTex then ShaderState.send(sh,"sunDynamicMap",movingTex)end + ShaderState.send(sh,"sunDynamicActive",ShadowMap.dynamicActive()and 1 or 0) + ShaderState.send(sh,"sunDynamicTexel",{1/256,1/256}) ShaderState.send(sh, "sunDark", map and Voxel3D.SHADOW_ALPHA or 0) ShaderState.send(sh, "sunBias", ShadowMap.bias) local texel = 1 / ShadowMap.res diff --git a/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua b/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua index 2da5d6398..264ddc2e5 100644 --- a/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua +++ b/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua @@ -23,6 +23,7 @@ local Tier = V.require("Tier") local Voxel3D = V.require("Voxel3D") local ShadowMap = V.require("ShadowMap") local ChunkMesher = V.require("ChunkMesher") +local WorldShadows = V.require("WorldShadows") local SpriteBillboards = V.require("SpriteBillboards") local TileShape = V.require("TileShape") local Structures = V.require("Structures") @@ -2435,56 +2436,7 @@ end -- Runs BEFORE Voxel3D.beginScene, because canvases do not nest. Grass is -- left out on purpose: thousands of tufts would cast a speckle no bigger -- than the pixels it lands on, at the cost of the mesh being drawn twice. -local shadowAnimations={} -local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, - atlasFor, water, nbWater, battleCards, battleToken) - if not ShadowMap.available() then return end - shadowAnimations[1]=TerrainAtlas.shadowStep(state.map) - local count=1 - for i,nb in ipairs(state.neighbors or {})do - count=count+1;shadowAnimations[count]=nbMesh[i] and TerrainAtlas.shadowStep(nb.map) or 0 - end - for i=count+1,#shadowAnimations do shadowAnimations[i]=nil end - local sig = shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh, battleToken,shadowAnimations) - -- a staged fight's pics move every frame the animation does, and the sun - -- has to follow them (VR frames only; see render) - if not ShadowMap.stale(sig) then return end - - -- THE SUN DOES NOT HAVE TO FOLLOW EVERY FOOTSTEP. - -- - -- The stamp above is honest and it is also why this pass runs on every - -- single frame: it stales on any caster moving, and in a town or a wood - -- somebody is always mid-step. Measured standing still in Petalburg - -- Woods with nothing left to build: 202 sun passes in 202 rendered - -- frames, 53% of VoxelScene.render, to re-record a terrain mesh that had - -- not changed by one vertex. - -- - -- So BALANCED rate-limits the CAST half and only the cast half. The - -- static half (camera, view, sun angle, first-person head, the meshes - -- themselves) still forces a redraw on the frame it changes, because a - -- map fitted to last frame's frustum is a wrong shadow rather than a late - -- one -- see shadowStaticTerms. What is delayed is strictly "somebody - -- took a step", and at the shipped rate of 3 the worst case is a walker's - -- own shadow two frames behind their feet, which at walking pace is under - -- two world pixels. - -- - -- nil at HIGH -- the branch is skipped entirely and this function behaves - -- exactly as it did, redraw for redraw. - local hz = Tier.caps().shadowHz - if hz and hz > 1 then - local st = shadowStaticSignature(terrain, nbMesh, cx, cy, vw, vh,shadowAnimations) - shadowStale = shadowStale + 1 - if ShadowMap.hasFrame() and st == shadowLastStatic and (shadowStale - shadowLastCast) < hz then - Perf.count("shadow.throttled") - return - end - shadowLastStatic, shadowLastCast = st, shadowStale - end - - Perf.count("shadow.cast") - local tSun = Perf.now() - if not ShadowMap.begin(cx, cy, vw, vh) then return end - +local function drawStaticCasters(state,terrain,nbMesh,atlasFor,water,nbWater) ShadowMap.draw(terrain, atlasFor(state.map), nil) for i, nb in ipairs(state.neighbors or {}) do ShadowMap.draw(nbMesh[i], atlasFor(nb.map), @@ -2511,6 +2463,8 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) end +end +local function drawMovingCasters(state,posed,atlasFor,battleCards) -- From here down it is the CAST, marked as such in the map (see -- ShadowMap.sprites) so water can decline them: everything the world casts -- still shades a lake, a silhouette of somebody standing beside it does @@ -2558,6 +2512,85 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, end ShadowMap.sprites(false) +end +local function shadowPoseFrame(p) + return frameFor(p.sprite.def,viewFacing(p),p.phase,p.flip,p.frame) +end +local function castAnchoredShadows(state,terrain,nbMesh,posed,cx,cy,vw,vh,atlasFor,water,nbWater,battleCards,battleToken,animation) + if not ShadowMap.dynamicAvailable()then return false end + local plan=WorldShadows.prepare(state,terrain,nbMesh,water,nbWater,posed,cx,cy,vw,vh,battleToken,animation,FirstPerson.signature(),atlasFor,shadowPoseFrame) + local tSun=Perf.now() + if plan.static then + if not ShadowMap.begin(cx,cy,vw,vh,plan.bounds)then return false end + drawStaticCasters(state,terrain,nbMesh,atlasFor,water,nbWater) + ShadowMap.finish(plan.staticToken);WorldShadows.commitStatic() + Perf.count("shadow.static.cast") + end + if plan.dynamic then + if not ShadowMap.beginDynamic()then return false end + drawMovingCasters(state,posed,atlasFor,battleCards) + ShadowMap.finishDynamic();WorldShadows.commitDynamic() + Perf.count("shadow.dynamic.cast") + end + Perf.add("VoxelScene.castShadows",tSun) + return true +end +local shadowAnimations={} +local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, + atlasFor, water, nbWater, battleCards, battleToken) + if not ShadowMap.available() then return end + shadowAnimations[1]=TerrainAtlas.shadowStep(state.map) + local count=1 + for i,nb in ipairs(state.neighbors or {})do + count=count+1;shadowAnimations[count]=nbMesh[i] and TerrainAtlas.shadowStep(nb.map) or 0 + end + for i=count+1,#shadowAnimations do shadowAnimations[i]=nil end + if V.shadowWorldAnchor~=false and castAnchoredShadows(state,terrain,nbMesh,posed,cx,cy,vw,vh, + atlasFor,water,nbWater,battleCards,battleToken,shadowAnimations)then return end + ShadowMap.useLegacy() + local sig = shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh, battleToken,shadowAnimations) + -- a staged fight's pics move every frame the animation does, and the sun + -- has to follow them (VR frames only; see render) + if not ShadowMap.stale(sig) then return end + + -- THE SUN DOES NOT HAVE TO FOLLOW EVERY FOOTSTEP. + -- + -- The stamp above is honest and it is also why this pass runs on every + -- single frame: it stales on any caster moving, and in a town or a wood + -- somebody is always mid-step. Measured standing still in Petalburg + -- Woods with nothing left to build: 202 sun passes in 202 rendered + -- frames, 53% of VoxelScene.render, to re-record a terrain mesh that had + -- not changed by one vertex. + -- + -- So BALANCED rate-limits the CAST half and only the cast half. The + -- static half (camera, view, sun angle, first-person head, the meshes + -- themselves) still forces a redraw on the frame it changes, because a + -- map fitted to last frame's frustum is a wrong shadow rather than a late + -- one -- see shadowStaticTerms. What is delayed is strictly "somebody + -- took a step", and at the shipped rate of 3 the worst case is a walker's + -- own shadow two frames behind their feet, which at walking pace is under + -- two world pixels. + -- + -- nil at HIGH -- the branch is skipped entirely and this function behaves + -- exactly as it did, redraw for redraw. + local hz = Tier.caps().shadowHz + if hz and hz > 1 then + local st = shadowStaticSignature(terrain, nbMesh, cx, cy, vw, vh,shadowAnimations) + shadowStale = shadowStale + 1 + if ShadowMap.hasFrame() and st == shadowLastStatic and (shadowStale - shadowLastCast) < hz then + Perf.count("shadow.throttled") + return + end + shadowLastStatic, shadowLastCast = st, shadowStale + end + + Perf.count("shadow.cast") + local tSun = Perf.now() + if not ShadowMap.begin(cx, cy, vw, vh) then return end + + drawStaticCasters(state,terrain,nbMesh,atlasFor,water,nbWater) + drawMovingCasters(state,posed,atlasFor,battleCards) + ShadowMap.finish(sig) if V.numericShadowStamps~=false then shadowFrame.commit() end Perf.add("VoxelScene.castShadows", tSun) diff --git a/mods/DRAMATIC_SHAPE/lib/WorldShadows.lua b/mods/DRAMATIC_SHAPE/lib/WorldShadows.lua new file mode 100644 index 000000000..a2307c3f5 --- /dev/null +++ b/mods/DRAMATIC_SHAPE/lib/WorldShadows.lua @@ -0,0 +1,117 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Existing mod copyright and MIT notices remain in ../LICENSE. Separate static/dynamic shadow planning; no engine records. +local V = ... +local Stamp = V.require("RenderStamp") +local Shadow = V.require("ShadowMap") +local Mesher = V.require("ChunkMesher") +local Tier = V.require("Tier") +local Card = { GRID = 256 } +local static, dynamic = Stamp.new(), Stamp.new() +local plan = { bounds = {} } +local staleFrames, lastDynamic = 0, -1000 +function Card.bounds(x0, x1, z0, z1, out) + out = out or {} + out[1] = math.floor(x0 / Card.GRID) * Card.GRID + out[2] = math.ceil(x1 / Card.GRID) * Card.GRID + out[3] = math.floor(z0 / Card.GRID) * Card.GRID + out[4] = math.ceil(z1 / Card.GRID) * Card.GRID + return out +end +function Card.prepare( + state, + terrain, + nbMesh, + water, + nbWater, + posed, + cx, + cy, + vw, + vh, + battleToken, + animation, + firstPerson, + atlasFor, + poseFrame +) + local x0, x1, z0, z1 = Shadow.groundBounds(cx, cy, vw, vh) + Card.bounds(x0, x1, z0, z1, plan.bounds) + static.begin() + local put = static.put + for i = 1, 4 do + put(plan.bounds[i]) + end + put(Shadow.KX) + put(Shadow.KZ) + put(state.map) + put(terrain) + put(water) + if atlasFor then + put(atlasFor(state.map)) + end + put(Mesher.flowers(state.map)) + for i, nb in ipairs(state.neighbors or {}) do + put(nb.map) + put(nb.ox) + put(nb.oy) + put(nbMesh[i]) + put(nbWater and nbWater[i]) + put(Mesher.flowers(nb.map)) + if atlasFor then + put(atlasFor(nb.map)) + end + end + for i = 1, #animation do + put(animation[i]) + end + put(Tier.caps().shadowRes) + plan.staticToken = static.token() + plan.static = not Shadow.hasFrame() or not Shadow.isAnchored() or Shadow.stale(plan.staticToken) + dynamic.begin() + put = dynamic.put + put(plan.staticToken) + put(firstPerson) + put(battleToken) + -- Authored figures can arrive independently of the terrain mesh. + put(Mesher.figures(state.map)) + for _, nb in ipairs(state.neighbors or {}) do + put(Mesher.figures(nb.map)) + end + for _, p in ipairs(posed) do + put(p.sprite.def) + put(p.sprite.def.image) + put(p.px) + put(p.py) + put(p.gh) + put(p.lift or 0) + put(p.facing) + put(p.phase) + put(p.flip) + put(p.frame) + put(p.sprite:resolveImage()) + if poseFrame then + local frame, mirror = poseFrame(p) + put(frame) + put(mirror) + end + end + plan.dynamicToken = dynamic.token() + plan.dynamic = plan.static or not Shadow.dynamicActive() or plan.dynamicToken ~= plan.lastDynamicToken + if plan.dynamic then + staleFrames = staleFrames + 1 + local hz = Tier.caps().shadowHz + if not plan.static and hz and hz > 1 and staleFrames - lastDynamic < hz then + plan.dynamic = false + end + end + return plan +end +function Card.commitStatic() + static.commit() +end +function Card.commitDynamic() + dynamic.commit() + plan.lastDynamicToken = plan.dynamicToken + lastDynamic = staleFrames +end +return Card diff --git a/mods/DRAMATIC_SHAPE/main.lua b/mods/DRAMATIC_SHAPE/main.lua index c9c7b9530..87e6adc93 100644 --- a/mods/DRAMATIC_SHAPE/main.lua +++ b/mods/DRAMATIC_SHAPE/main.lua @@ -45,6 +45,8 @@ local mod = ... -- passed in as its vararg (`local V = ...`). local V = { mod = mod, path = mod.path } +-- P7: independently reversible world-anchored static and 256px actor shadows. +V.shadowWorldAnchor = true -- Repeated Gen 3 hulls share immutable GPU meshes where the driver can draw -- their offsets per instance. Other drivers keep the expanded vertex path. do diff --git a/tools/check_world_shadow_profile.py b/tools/check_world_shadow_profile.py new file mode 100644 index 000000000..77b52775f --- /dev/null +++ b/tools/check_world_shadow_profile.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Run owned native shadow geometry in an isolated QA profile.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--love', type=Path, required=True) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(args.qa_tools or root / 'tools')) + from qa_storage import qa_source, qa_cache, qa_profile, qa_run + qa_source(root) + qa_source(args.love.resolve()) + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True, exist_ok=True) + names = [f'mods/DRAMATIC_SHAPE/lib/{n}.lua' for n in + ('ShadowMap', 'WorldShadows', 'VoxelScene', 'Voxel3D', 'Tier')] + names += ['mods/DRAMATIC_SHAPE/manifest.json', 'mods/DRAMATIC_SHAPE/main.lua', + 'tools/world-shadow-check/main.lua', 'tools/world-shadow-check/conf.lua'] + hashes = {n: hashlib.sha256((root / n).read_bytes()).hexdigest() for n in names} + identity = f'oasc-world-shadow-qa-{time.time_ns()}' + qa_profile(Path.home() / 'Library/Application Support/LOVE' / identity) + result = qa_run([str(args.love.resolve()), str(root / 'tools/world-shadow-check')], + cwd=root, env=dict(os.environ, WORLD_SHADOW_QA_GUARDED='1', + WORLD_SHADOW_QA_ROOT=str(root), WORLD_SHADOW_QA_OUT=str(args.report.parent.resolve()), POKEPORT_IDENTITY=identity), + capture_output=True, text=True, timeout=90) + args.report.with_suffix('.log').write_text(result.stdout + result.stderr) + rows = [s.removeprefix('WORLD_SHADOW_RESULT ') for s in result.stdout.splitlines() + if s.startswith('WORLD_SHADOW_RESULT ')] + if result.returncode or len(rows) != 1: + raise RuntimeError('native shadow probe failed; see its log') + if any(hashlib.sha256((root / n).read_bytes()).hexdigest() != h for n, h in hashes.items()): + raise RuntimeError('measured source changed during the probe') + report = json.loads(rows[0]) + report.update(sha256=hashes, playerProfilesUsed=False) + args.report.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/tools/world-shadow-check/conf.lua b/tools/world-shadow-check/conf.lua new file mode 100644 index 000000000..384f4fb72 --- /dev/null +++ b/tools/world-shadow-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv("WORLD_SHADOW_QA_GUARDED") == "1", "use the guarded shadow checker") + t.identity = assert(os.getenv("POKEPORT_IDENTITY")) + t.window.title = "World anchored shadow qualification" + t.window.width, t.window.height, t.window.vsync = 640, 480, 0 + t.modules.audio = false +end diff --git a/tools/world-shadow-check/main.lua b/tools/world-shadow-check/main.lua new file mode 100644 index 000000000..0c38f758c --- /dev/null +++ b/tools/world-shadow-check/main.lua @@ -0,0 +1,297 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local root = assert(os.getenv("WORLD_SHADOW_QA_ROOT")) +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +require("src.core.GameVersion").set("crystal") +require("src.core.Performance").tier = "high" +local V, libs = { sharedHulls = true }, {} +V.require = function(name) + if not libs[name] then + libs[name] = assert(loadfile(root .. "/mods/DRAMATIC_SHAPE/lib/" .. name .. ".lua"))(V) + end + return libs[name] +end +V.data = function(name) + return assert(loadfile(root .. "/mods/DRAMATIC_SHAPE/data/" .. name .. ".lua"))() +end +-- Geometry is owned. Resource/mesher seams supply it; actual scene casting, +-- shadow shaders, matrices, canvases and the lit scene shader stay native. +libs.ChunkMesher = { + flowers = function(map) + return map.flowers + end, + figures = function(map) + return map.figures + end, +} +local Mat = V.require("Mat4") +local Shadow = V.require("ShadowMap") +local Voxel = V.require("VoxelState") +local G = V.require("Voxel3D") +local Perf = V.require("Perf") +Perf.enabled = true +local Scene = V.require("VoxelScene") +local function up(fn, name) + for i = 1, 100 do + local key, value = debug.getupvalue(fn, i) + if key == name then + return value + end + if not key then + break + end + end + error("missing scene producer " .. name) +end +local cast = up(Scene.render, "castShadows") +local tex, terrain, poses, state, atlas +local results = {} +local phase, frame, total, frameTotal, submitTotal = 1, 0, 0, 0, 0 +local checks = 0 +local function check(ok, message) + assert(ok, message) + checks = checks + 1 +end +local function setup() + local pixels = love.image.newImageData(16, 96) + pixels:mapPixel(function(x, y) + return 0.3, 0.7, 0.4, (x > 2 and x < 13) and 1 or 0 + end) + tex = love.graphics.newImage(pixels) + local verts = {} + -- Many visible ground triangles exercise the repeated static pass. + for z = -16, 31 do + for x = -16, 31 do + local a, b = x * 16, z * 16 + for _, p in ipairs({ + { a, 0, b }, + { a + 16, 0, b }, + { a + 16, 0, b + 16 }, + { a, 0, b }, + { a + 16, 0, b + 16 }, + { + a, + 0, + b + 16, + }, + }) do + verts[#verts + 1] = { p[1], p[2], p[3], 0.5, 0.5, 1 } + end + end + end + terrain = love.graphics.newMesh(G.FORMAT, verts, "triangles", "static") + state = { map = { id = "OWN_SHADOW_MAP", tileset = {} }, neighbors = {} } + local def = + { image = "owned-sprite", billboardTexture = tex, frameWidth = 16, frameHeight = 16, frames = 6, walker = true } + poses = { + { + sprite = { + def = def, + resolveImage = function() + return tex + end, + }, + px = 400, + py = 400, + gh = 0, + facing = "down", + phase = 0, + flip = false, + }, + } + atlas = function() + return tex + end + Voxel.angle = math.rad(35) + check(G.shader(false) ~= nil, "lit scene shader did not compile") + check(G.shader(false, true) ~= nil, "instanced lit scene shader did not compile") + check(Shadow.available() and Shadow.dynamicAvailable(), "native shadow targets unavailable") +end +local function castAt(cx, cy) + cast(state, terrain, {}, poses, cx, cy, 160, 144, atlas, nil, nil, nil, nil) +end +local function qualify() + local Card = V.require("WorldShadows") + local box = Card.bounds(-1, 257, -257, 1) + check(box[1] == -256 and box[2] == 512 and box[3] == -512 and box[4] == 256, "negative world snapping changed") + V.shadowWorldAnchor = true + castAt(400, 400) + local first = Shadow.texture():newImageData():getString() + local vp = Json.encode(Shadow.clipVP) + poses[1].px = 412 + poses[1].phase = 1 + castAt(401, 401) + check(Shadow.texture():newImageData():getString() == first, "walking redrew/changed static texture") + check(Json.encode(Shadow.clipVP) == vp, "walking moved the anchored light matrix") + check(Shadow.dynamicTexture():getWidth() == 256, "moving target is not 256 square") + local moving = Shadow.dynamicTexture():newImageData():getString() + poses[1].px = 448 + castAt(401, 401) + check(Shadow.dynamicTexture():newImageData():getString() ~= moving, "moving actor shadow stayed stale") + local before = Perf.counters["shadow.static.cast"] or 0 + castAt(801, 400) + check( + (Perf.counters["shadow.static.cast"] or 0) == before + 1, + "visible tile-set change did not redraw static shadows" + ) + before = Perf.counters["shadow.static.cast"] or 0 + state.map.flowers = terrain + castAt(801, 400) + check((Perf.counters["shadow.static.cast"] or 0) == before + 1, "arriving geometry did not redraw static shadows") + state.map.flowers = nil + V.shadowWorldAnchor = false + castAt(400, 400) + check(not Shadow.dynamicActive() and not Shadow.isAnchored(), "P7 disable did not restore legacy map") + V.shadowWorldAnchor = true + poses[1].px = 400 + castAt(400, 400) + local function litFloor() + check(G.beginScene(320, 240, 400, 400, 160, 120), "native lit scene did not begin") + G.draw(terrain, tex) + G.endScene() + local image = G.canvas():newImageData() + local tag = Shadow.dynamicActive() and "with-actor" or "without-actor" + local f = assert(io.open(assert(os.getenv("WORLD_SHADOW_QA_OUT")) .. "/" .. tag .. ".png", "wb")) + f:write(image:encode("png"):getString()) + f:close() + local bytes = image:getString() + image:release() + return bytes + end + local withActor = litFloor() + V.shadowWorldAnchor = false + check(litFloor() ~= withActor, "lit shader did not sample the dynamic shadow texture") + V.shadowWorldAnchor = true + castAt(400, 400) + local staticBefore = Perf.counters["shadow.static.cast"] or 0 + local movingBefore = Perf.counters["shadow.dynamic.cast"] or 0 + castAt(400, 400) + check( + (Perf.counters["shadow.static.cast"] or 0) == staticBefore + and (Perf.counters["shadow.dynamic.cast"] or 0) == movingBefore, + "idle layers were redrawn" + ) + local kx = Shadow.KX + Shadow.KX = kx + 0.1 + castAt(400, 400) + check((Perf.counters["shadow.static.cast"] or 0) == staticBefore + 1, "sun change left static shadows stale") + Shadow.KX = kx + castAt(400, 400) + staticBefore = Perf.counters["shadow.static.cast"] or 0 + state.neighbors = { { map = { tileset = {} }, ox = 16, oy = 0 } } + castAt(400, 400) + state.neighbors[1].ox = 32 + castAt(400, 400) + check((Perf.counters["shadow.static.cast"] or 0) == staticBefore + 2, "neighbor arrival/offset was not invalidated") + state.neighbors = {} + require("src.core.Performance").tier = "balanced" + castAt(400, 400) + check(Shadow.res <= 1024, "existing balanced resolution cap changed") + movingBefore = Perf.counters["shadow.dynamic.cast"] or 0 + for i = 1, 6 do + poses[1].px = 400 + i + castAt(400, 400) + end + local casts = (Perf.counters["shadow.dynamic.cast"] or 0) - movingBefore + check(casts == 2, "existing balanced moving-shadow cadence changed") + require("src.core.Performance").tier = "high" + -- An unavailable secondary target must retain the complete legacy pass. + Shadow.invalidate() + V.shadowWorldAnchor = true + local Pixel = V.require("PixelCanvas") + local make = Pixel.new + local denied = 0 + Pixel.new = function(w, h) + if w == 256 and h == 256 then + denied = denied + 1 + return false, "owned failure" + end + return make(w, h) + end + castAt(400, 400) + poses[1].phase = 1 - poses[1].phase + castAt(401, 400) + check( + not Shadow.isAnchored() and not Shadow.dynamicActive() and Shadow.active(), + "missing secondary target lost legacy shadows" + ) + check(denied == 1, "failed secondary target was retried every frame") + Pixel.new = make + Shadow.invalidate() + Perf.counters = {} +end +function love.errorhandler(message) + print(debug.traceback(tostring(message), 2)) + return function() + return 1 + end +end +function love.run() + setup() + qualify() + return function() + local frameBegan = love.timer.getTime() + love.event.pump() + for event in love.event.poll() do + if event == "quit" then + return 1 + end + end + V.shadowWorldAnchor = phase == 2 + poses[1].px = 400 + math.sin(frame * 0.07) * 16 + poses[1].phase = frame % 2 + if frame == 120 then + Profile.setEnabled(true) + Perf.counters = {} + end + local close = Profile.section("voxel.sun submit (owned geometry)") + local t = love.timer.getTime() + castAt(400 + frame % 60 / 4, 400) + local submitted = love.timer.getTime() - t + close() + local closeFence = Profile.section("voxel.sun GPU fence (owned geometry)") + -- Complete this offscreen pass before timing it. Avoid drawable/present + -- throttling; one pixel is read in both modes and no full-frame copy is timed. + local target = Shadow.dynamicActive() and Shadow.dynamicTexture() or Shadow.texture() + local fence = target:newImageData(1, 1, 0, 0, 1, 1) + fence:release() + if frame >= 120 then + total = total + love.timer.getTime() - t + submitTotal = submitTotal + submitted + end + closeFence() + if frame >= 120 then + frameTotal = frameTotal + love.timer.getTime() - frameBegan + Profile.frame() + end + frame = frame + 1 + if frame == 240 then + results[phase] = { + mode = phase == 1 and "legacy" or "world anchored", + frames = 120, + meanMs = total / 120 * 1000, + meanSubmitMs = submitTotal / 120 * 1000, + meanFrameMs = frameTotal / 120 * 1000, + warmupFrames = 120, + staticPasses = Perf.counters["shadow.static.cast"] or 0, + dynamicPasses = Perf.counters["shadow.dynamic.cast"] or 0, + legacyPasses = Perf.counters["shadow.cast"] or 0, + } + if phase == 2 then + check(results[2].staticPasses < results[1].legacyPasses, "static pass was not reused") + print("WORLD_SHADOW_RESULT " .. Json.encode({ + phases = results, + checks = checks, + renderer = love.graphics.getRendererInfo(), + scope = "Offscreen owned geometry, actual scene casting/shaders/targets, one-pixel GPU completion fence; no presentation, ROM or full-game FPS", + })) + return 0 + end + Profile.setEnabled(false) + Shadow.invalidate() + Perf.counters = {} + phase, frame, total, frameTotal, submitTotal = 2, 0, 0, 0, 0 + end + end +end From de1a7e8d827400e48c62975457fa7e9a53be9fe1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:33:28 +0100 Subject: [PATCH 115/138] Document P7 shadow pass validation and baseline comparison --- docs/qa/world-shadows-p7.json | 108 ++++++++++++++++++++++++++++++++++ docs/world-shadows-p7.md | 68 +++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 docs/qa/world-shadows-p7.json create mode 100644 docs/world-shadows-p7.md diff --git a/docs/qa/world-shadows-p7.json b/docs/qa/world-shadows-p7.json new file mode 100644 index 000000000..b0653467c --- /dev/null +++ b/docs/qa/world-shadows-p7.json @@ -0,0 +1,108 @@ +{ + "runtimeCommit": "73ff4d48756f10ae848ee3b745cb67ab2b2fc7bd", + "native": { + "metal": { + "checks": 22, + "scope": "Offscreen owned geometry, actual scene casting/shaders/targets, one-pixel GPU completion fence; no presentation, ROM or full-game FPS", + "renderer": "Metal", + "phases": [ + { + "warmupFrames": 120, + "staticPasses": 0, + "dynamicPasses": 0, + "legacyPasses": 120, + "mode": "legacy", + "frames": 120, + "meanMs": 0.7672239583333287, + "meanSubmitMs": 0.09647534722222184, + "meanFrameMs": 0.8747288194444452 + }, + { + "warmupFrames": 120, + "staticPasses": 4, + "dynamicPasses": 120, + "legacyPasses": 0, + "mode": "world anchored", + "frames": 120, + "meanMs": 0.5632812500000001, + "meanSubmitMs": 0.07356666666667824, + "meanFrameMs": 0.5803350694444542 + } + ], + "sha256": { + "mods/DRAMATIC_SHAPE/lib/ShadowMap.lua": "049897e9f7ac8c791cfd54e22d13161e4d0ac3f70b6000689d1a5e8d118d7f26", + "mods/DRAMATIC_SHAPE/lib/WorldShadows.lua": "121d65657f992b93cf63b2f50455297943efe89d9150822a02b8e89ca160419d", + "mods/DRAMATIC_SHAPE/lib/VoxelScene.lua": "ddea948a3027ad72f2d9d3544e27b30bab3801b3021a795b09bd9975924afc68", + "mods/DRAMATIC_SHAPE/lib/Voxel3D.lua": "ada0f764add2ef7eb0e52660db30d84415b9b80f9b4cf9a3b9b158ea326dda62", + "mods/DRAMATIC_SHAPE/lib/Tier.lua": "fe9ed868da8aa8feb3824d8dd9edd2307df89631500aa4ed69a9e436919de329", + "mods/DRAMATIC_SHAPE/manifest.json": "b94bdb437120be77c014937f0949a17ee81377db570abb46d997e80dd7e52ca3", + "mods/DRAMATIC_SHAPE/main.lua": "9cac22e8385199ce15d43afe458d9a12e4271f2f54a008e1a24242c4da0ed8b3", + "tools/world-shadow-check/main.lua": "d4a495cece9e2ab0d091f1f2d2992f654ebe34cdb1da16a38af3cd5d0cc0579c", + "tools/world-shadow-check/conf.lua": "26f7c0d1ad1cf92a36858450d5778c69bb465fc85b6227d54cd9bc8061e0af99" + }, + "playerProfilesUsed": false + }, + "openGL": { + "phases": [ + { + "meanMs": 0.7941934027777819, + "meanSubmitMs": 0.08953645833333682, + "meanFrameMs": 0.8148767361111123, + "warmupFrames": 120, + "staticPasses": 0, + "dynamicPasses": 0, + "legacyPasses": 120, + "frames": 120, + "mode": "legacy" + }, + { + "meanMs": 0.29765173611111617, + "meanSubmitMs": 0.04534131944444464, + "meanFrameMs": 0.3067003472222209, + "warmupFrames": 120, + "staticPasses": 4, + "dynamicPasses": 120, + "legacyPasses": 0, + "frames": 120, + "mode": "world anchored" + } + ], + "renderer": "OpenGL", + "scope": "Offscreen owned geometry, actual scene casting/shaders/targets, one-pixel GPU completion fence; no presentation, ROM or full-game FPS", + "checks": 22, + "sha256": { + "mods/DRAMATIC_SHAPE/lib/ShadowMap.lua": "049897e9f7ac8c791cfd54e22d13161e4d0ac3f70b6000689d1a5e8d118d7f26", + "mods/DRAMATIC_SHAPE/lib/WorldShadows.lua": "121d65657f992b93cf63b2f50455297943efe89d9150822a02b8e89ca160419d", + "mods/DRAMATIC_SHAPE/lib/VoxelScene.lua": "ddea948a3027ad72f2d9d3544e27b30bab3801b3021a795b09bd9975924afc68", + "mods/DRAMATIC_SHAPE/lib/Voxel3D.lua": "ada0f764add2ef7eb0e52660db30d84415b9b80f9b4cf9a3b9b158ea326dda62", + "mods/DRAMATIC_SHAPE/lib/Tier.lua": "fe9ed868da8aa8feb3824d8dd9edd2307df89631500aa4ed69a9e436919de329", + "mods/DRAMATIC_SHAPE/manifest.json": "b94bdb437120be77c014937f0949a17ee81377db570abb46d997e80dd7e52ca3", + "mods/DRAMATIC_SHAPE/main.lua": "9cac22e8385199ce15d43afe458d9a12e4271f2f54a008e1a24242c4da0ed8b3", + "tools/world-shadow-check/main.lua": "d4a495cece9e2ab0d091f1f2d2992f654ebe34cdb1da16a38af3cd5d0cc0579c", + "tools/world-shadow-check/conf.lua": "26f7c0d1ad1cf92a36858450d5778c69bb465fc85b6227d54cd9bc8061e0af99" + }, + "playerProfilesUsed": false + } + }, + "acceptance": { + "enginePassed": 156, + "engineSuites": 167, + "inheritedFailingSuites": 11, + "unchangedFailureLines": 29, + "baselineCommit": "5af8542a384f01beeffc1c1f88f82d9b9a0926c4", + "sdkSuites": 3, + "galleryChecks": 253, + "api3Comparisons": 48, + "isolatedApi3Workers": 96, + "externalFixtureEntrypoints": 17, + "externalFixturePhase": "complete", + "head": "73ff4d48756f10ae848ee3b745cb67ab2b2fc7bd", + "exitCodes": { + "engine": 1, + "sdk": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + } + } +} diff --git a/docs/world-shadows-p7.md b/docs/world-shadows-p7.md new file mode 100644 index 000000000..3ece5fef7 --- /dev/null +++ b/docs/world-shadows-p7.md @@ -0,0 +1,68 @@ +# World anchored sun shadows (P7) + +`V.shadowWorldAnchor` defaults to `true` in `mods/DRAMATIC_SHAPE/main.lua`. +Set it to `false` to restore the combined, camera-fitted shadow pass independently +of the other performance flags. A failed secondary target also selects that +complete legacy pass, with a sticky failure until shadow resources are invalidated. + +`WorldShadows` is a separate internal card. It rounds the existing visible-ground +bounds outward to a 256-world-pixel grid. The static light projection stays fixed +while that set of cells remains visible. Actual terrain, water and flower meshes, +map/neighbor offsets, atlas identities and alpha-animation steps, sun direction +and the existing resolution cap participate in invalidation. There is one cached +static projection, not an unbounded collection of tile textures. + +Moving sprites, authored figures and battle cards use a second 256-square target +with the same projection. Their poses, resolved images, chosen frames, first-person +view and battle token determine invalidation. The existing BALANCED cadence still +limits moving updates. Static changes update both maps immediately. The lit shader +samples both maps and combines coverage without doubling shadow darkness. Water +continues to sample static casters only, as its previous actor exclusion requires. + +The anchored path corrects render-target lookup Y under the tested LÖVE 12 Metal +backend. Its pixel test compares the same lit scene with only dynamic sampling +enabled/disabled. The original four-argument shadow pass retains its conversion. +`Tier.lua`, the manifest and engine-internals permissions are unchanged. No loader, +public mod interface, hook or engine record changes are needed. + +## Reproduction and measurement + +Run `python3 tools/check_world_shadow_profile.py --love /absolute/path/to/love +--report .local/qa/world-shadows/profile.json` on one shell line. The guarded runner +uses a separate QA identity, owned geometry and texture, actual native shadow/scene +shaders, matrices and targets. It stores source hashes and the lit-image negative +control. It does not use a cartridge, player profile or game save. + +The 13,824-vertex flat grid and one moving alpha-tested card are an isolated pass +fixture. Each mode has 120 warmup and 120 measured frames. FrameProfile separates +`voxel.sun submit (owned geometry)` from `voxel.sun GPU fence (owned geometry)`. +A one-pixel readback completes GPU work in both modes. Presentation is excluded; +these are offscreen pass times, not full-game FPS or an end-to-end speedup claim. + +| Desktop macOS ARM64 host | Combined pass | Anchored layers | +| --- | ---: | ---: | +| LÖVE 11.5 OpenGL, CPU submission | 0.090 ms | 0.045 ms | +| LÖVE 11.5 OpenGL, including GPU fence | 0.794 ms | 0.298 ms | +| LÖVE 12 Metal, CPU submission | 0.096 ms | 0.074 ms | +| LÖVE 12 Metal, including GPU fence | 0.767 ms | 0.563 ms | +| Static passes per 120 measured frames | 120 | 4 | + +The trajectory crosses a grid boundary and wraps, so four static updates are +expected; the moving layer updates all 120 frames at HIGH. GPU fence costs vary +between runs: an earlier Metal fixture run measured 0.547 → 0.732 ms, while the +final source-bound run above improved. The reduction in static work is repeatable; +this small fixture does not establish a universal GPU speedup. A real populated +map and mobile/handheld performance remain unmeasured. + +Both native hosts pass 22 scoped checks: plain/instanced shader compilation, +negative-coordinate snapping, unchanged static pixels/matrix during walking, +moving pixels and their actual lit contribution, cell/geometry/sun/neighbor +invalidation, idle reuse, balanced cap/cadence, live rollback, and sticky allocation +failure with complete legacy shadows. The source-bound record is +[world-shadows-p7.json](qa/world-shadows-p7.json). + +Integrated fork acceptance: 477/477 engine suites, 3/3 SDK suites, 253 gallery +checks, 200 API-3 comparisons / 400 workers, and all 17 unchanged external fixture +entrypoints in complete mode pass. The public stack retains exactly its previous +29 FAIL lines across 11 inherited suites: 156/167, with no new failure. Its SDK, +gallery, 48 API-3 comparisons / 96 workers and 17 fixture entrypoints all pass. From c1906c64a62e13cd3d5585e9c77ef387d483c44d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:52:36 +0100 Subject: [PATCH 116/138] perf(P8): budget world resolution and reflective water passes --- mods/DRAMATIC_SHAPE/lib/RenderScale.lua | 63 +++++ mods/DRAMATIC_SHAPE/lib/ShaderState.lua | 11 +- mods/DRAMATIC_SHAPE/lib/Tier.lua | 3 + mods/DRAMATIC_SHAPE/lib/TileShape.lua | 13 +- mods/DRAMATIC_SHAPE/lib/Voxel3D.lua | 8 +- mods/DRAMATIC_SHAPE/lib/VoxelScene.lua | 57 ++-- mods/DRAMATIC_SHAPE/lib/Water.lua | 92 +++++-- mods/DRAMATIC_SHAPE/lib/WaterBudget.lua | 165 ++++++++++++ mods/DRAMATIC_SHAPE/main.lua | 17 +- tools/check_render_budget.py | 50 ++++ tools/render-budget-check/conf.lua | 7 + tools/render-budget-check/main.lua | 329 ++++++++++++++++++++++++ 12 files changed, 774 insertions(+), 41 deletions(-) create mode 100644 mods/DRAMATIC_SHAPE/lib/RenderScale.lua create mode 100644 mods/DRAMATIC_SHAPE/lib/WaterBudget.lua create mode 100644 tools/check_render_budget.py create mode 100644 tools/render-budget-check/conf.lua create mode 100644 tools/render-budget-check/main.lua diff --git a/mods/DRAMATIC_SHAPE/lib/RenderScale.lua b/mods/DRAMATIC_SHAPE/lib/RenderScale.lua new file mode 100644 index 000000000..bc1cee666 --- /dev/null +++ b/mods/DRAMATIC_SHAPE/lib/RenderScale.lua @@ -0,0 +1,63 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local V = ... +local Card = {} +local Setting = V.require("ModSetting") +Card.setting = Setting.new("renderScale", "RENDER SCALE", { "auto", 1, 2, 3, 4 }, { "AUTO", "1X", "2X", "3X", "4X" }) +function Card.mobile() + local os = love.system and love.system.getOS and love.system.getOS() or "" + local arch = jit and jit.arch or "" + return os == "Android" + or os == "iOS" + or os == "Horizon" + or os == "Switch" + or (os == "Linux" and (arch == "arm" or arch == "arm64")) +end +function Card.factor() + if V.renderBudget == false then + return 1 + end + local value = Card.setting:get() + if value == "auto" then + return Card.mobile() and 2 or 1 + end + return math.max(1, math.min(4, math.floor(tonumber(value) or 1))) +end +function Card.size(w, h) + local f = Card.factor() + return math.max(1, math.ceil(w / f)), math.max(1, math.ceil(h / f)) +end +local canvas, width, height +function Card.present(image, w, h) + if image:getWidth() == w and image:getHeight() == h then + return image + end + if not canvas or width ~= w or height ~= h then + local ok, made = V.require("PixelCanvas").new(w, h) + if not ok then + return nil + end + Card.invalidate() + canvas, width, height = made, w, h + canvas:setFilter("nearest", "nearest") + end + love.graphics.push("all") + love.graphics.setCanvas(canvas) + love.graphics.setShader() + love.graphics.setDepthMode() + love.graphics.origin() + love.graphics.setScissor() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.clear(0, 0, 0, 0) + love.graphics.setBlendMode("replace", "premultiplied") + image:setFilter("nearest", "nearest") + love.graphics.draw(image, 0, 0, 0, w / image:getWidth(), h / image:getHeight()) + love.graphics.pop() + return canvas +end +function Card.invalidate() + if canvas then + canvas:release() + end + canvas, width, height = nil, nil, nil +end +return Card diff --git a/mods/DRAMATIC_SHAPE/lib/ShaderState.lua b/mods/DRAMATIC_SHAPE/lib/ShaderState.lua index 493ccd828..5fb890e0c 100644 --- a/mods/DRAMATIC_SHAPE/lib/ShaderState.lua +++ b/mods/DRAMATIC_SHAPE/lib/ShaderState.lua @@ -3,6 +3,7 @@ local V=... local S={} local values=setmetatable({}, {__mode="k"}) local unpack=table.unpack or unpack +local lastBudget -- Snapshot small plain numeric vectors/matrices. Comparing table identity is -- unsafe: callers mutate camera and model arrays in place between draws. local function numericSize(value) @@ -13,11 +14,14 @@ local function numericSize(value) return n end function S.send(shader,name,...) - if not (V and V.sharedHulls) then return pcall(shader.send,shader,name,...) end + local budget=V and V.renderBudget~=false + if budget~=lastBudget then values=setmetatable({}, {__mode="k"});lastBudget=budget end + if not budget and not (V and V.sharedHulls) then return pcall(shader.send,shader,name,...) end local record=values[shader] if not record then record={};values[shader]=record end local args=record[name] if not args then args={};record[name]=args end + if budget and args.failed then return false,args.failed end local arrays=args.arrays if not arrays then arrays={};args.arrays=arrays end local n=select("#",...) @@ -46,7 +50,10 @@ function S.send(shader,name,...) end for i=n+1,args.n or #args do args[i]=nil;arrays[i]=nil end args.n=n - local ok,err=pcall(shader.send,shader,name,...) + local ok,err + if budget and args.sent then shader:send(name,...);ok=true + else ok,err=pcall(shader.send,shader,name,...) end + if budget and not ok and (not shader.hasUniform or not shader:hasUniform(name)) then args.failed=err end args.sent=ok return ok,err end diff --git a/mods/DRAMATIC_SHAPE/lib/Tier.lua b/mods/DRAMATIC_SHAPE/lib/Tier.lua index 26790bf32..2b8673be8 100644 --- a/mods/DRAMATIC_SHAPE/lib/Tier.lua +++ b/mods/DRAMATIC_SHAPE/lib/Tier.lua @@ -144,10 +144,13 @@ end -- player's OWN choice on, and a ceiling cannot. local CAPS = { high = { shadows = true, shadowRes = nil, shadowHz = nil, + waterRaySteps = 12, waterRayRefine = 3, water = nil, waterCast = nil, waterPlanar = nil, aa = nil }, balanced = { shadows = true, shadowRes = 1024, shadowHz = 3, + waterRaySteps = 12, waterRayRefine = 3, water = 1, waterCast = 0, waterPlanar = 0, aa = 2 }, low = { shadows = false, shadowRes = 1024, shadowHz = 0, + waterRaySteps = 12, waterRayRefine = 3, water = 0, waterCast = 0, waterPlanar = 0, aa = 0 }, } diff --git a/mods/DRAMATIC_SHAPE/lib/TileShape.lua b/mods/DRAMATIC_SHAPE/lib/TileShape.lua index 7ead121da..1edd1925c 100644 --- a/mods/DRAMATIC_SHAPE/lib/TileShape.lua +++ b/mods/DRAMATIC_SHAPE/lib/TileShape.lua @@ -570,8 +570,15 @@ end -- apron, which on Route 119 is about eighty thousand calls per rebuild. local gen3Shapes = {} local function gen3Shape(class, h, pinned) - local key = class .. "\0" .. h .. (pinned and "\0p" or "") - local hit = gen3Shapes[key] + local bucket=gen3Shapes + local key + if V.renderBudget~=false then + local group=bucket[class] + if not group then group={{},{}};bucket[class]=group end + bucket=group[pinned and 2 or 1] + key=h + else key=class .. "\0" .. h .. (pinned and "\0p" or "") end + local hit = bucket[key] if hit then return hit end local art = ART[class] or "upright" local rec = { @@ -593,7 +600,7 @@ local function gen3Shape(class, h, pinned) authored = pinned or (art ~= "upright"), gen3 = true, } - gen3Shapes[key] = rec + bucket[key] = rec return rec end diff --git a/mods/DRAMATIC_SHAPE/lib/Voxel3D.lua b/mods/DRAMATIC_SHAPE/lib/Voxel3D.lua index 755345086..de7275cf6 100644 --- a/mods/DRAMATIC_SHAPE/lib/Voxel3D.lua +++ b/mods/DRAMATIC_SHAPE/lib/Voxel3D.lua @@ -413,7 +413,13 @@ local DEPTH_FORMATS = { "depth24", "depth24stencil8", "depth32f", "depth16" } local function newDepth(w, h) if not (love.graphics and love.graphics.newCanvas) then return nil end local c = nil - for _, format in ipairs(DEPTH_FORMATS) do + local formats=DEPTH_FORMATS + local renderer,version + if love.graphics.getRendererInfo then renderer,version=love.graphics.getRendererInfo()end + if V.renderBudget~=false and ((renderer or ""):find("OpenGL ES",1,true) or (version or ""):find("OpenGL ES",1,true)) then + formats={"depth16","depth24","depth24stencil8","depth32f"} + end + for _, format in ipairs(formats) do local ok, made = pcall(love.graphics.newCanvas, w, h, { format = format, readable = true }) if ok and made then c = made break end diff --git a/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua b/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua index 264ddc2e5..18fab4490 100644 --- a/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua +++ b/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua @@ -24,6 +24,17 @@ local Voxel3D = V.require("Voxel3D") local ShadowMap = V.require("ShadowMap") local ChunkMesher = V.require("ChunkMesher") local WorldShadows = V.require("WorldShadows") +local WaterBudget = V.require("WaterBudget") +local neighborMatrices=setmetatable({}, {__mode="k"}) +local function neighborMatrix(nb) + if V.renderBudget==false then return Mat4.translate(nb.ox,0,nb.oy)end + local entry=neighborMatrices[nb] + if not entry or entry.x~=nb.ox or entry.z~=nb.oy then + entry={x=nb.ox,z=nb.oy,matrix=Mat4.translate(nb.ox,0,nb.oy)} + neighborMatrices[nb]=entry + end + return entry.matrix +end local SpriteBillboards = V.require("SpriteBillboards") local TileShape = V.require("TileShape") local Structures = V.require("Structures") @@ -2186,7 +2197,7 @@ local function drawPlanar(state, terrain, nbMesh, atlasFor, cast, Voxel3D.draw(terrain, atlasFor(state.map), nil) for i, nb in ipairs(state.neighbors or {}) do Voxel3D.draw(nbMesh[i], atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy)) + neighborMatrix(nb)) end -- the bank the water laps at. No camera-ward pull: the pull is a depth -- bias aimed at the FRAME's own buffer, to settle grass against the feet @@ -2196,7 +2207,7 @@ local function drawPlanar(state, terrain, nbMesh, atlasFor, cast, Voxel3D.draw(ChunkMesher.grass(state.map), atlasFor(state.map), nil) Voxel3D.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil) for _, nb in ipairs(state.neighbors or {}) do - local m = Mat4.translate(nb.ox, 0, nb.oy) + local m = neighborMatrix(nb) Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map), m) Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), m) end @@ -2273,12 +2284,14 @@ function VoxelScene.drawWater(draws, cast, planar, planarY) local tW = Perf.now() -- prepass only under the bend; see the header local curved = (Voxel3D.curveK or 0) > 0 - if curved then + local prepass = curved or WaterBudget.enabled() + if prepass then for _, d in ipairs(draws) do Voxel3D.draw(d[1], d[2], d[3]) end end - local plain = not curved + local plain = not prepass + local half, painted if Water.enabled() and Voxel3D.depthReadable() then -- `cast` only where something will actually LOOK at the mirror. Below -- FULL the shader's `rays` is 0 and it never samples reflectTex at all, @@ -2288,16 +2301,19 @@ function VoxelScene.drawWater(draws, cast, planar, planarY) -- draws nobody looked at (see Water.castLevel). With the WATER SPRITES -- row ON at FULL this is exactly the `cast` it always was, and the -- frame is the frame it always drew. - local mirror, depth = Voxel3D.beginWater(Water.reflectCast() and cast + local mirror, depth = Voxel3D.beginWater(Water.reflectCast() and not (WaterBudget.enabled() and planar) and cast or nil) local w, h = Voxel3D.size() + local pw,ph=w,h + if mirror and depth then half,pw,ph=WaterBudget.begin(w,h) end + if not half then pw,ph=w,h end local ok = mirror and depth and Water.begin({ reflect = mirror, depth = depth, vp = Voxel3D.vp, eye = Voxel3D.eye, curve = { Voxel3D.curveX or 0, Voxel3D.curveZ or 0, Voxel3D.curveK or 0 }, - screen = { w, h }, cell = Voxel3D.cell, fov = Voxel3D.fovY, - skyEdge = Voxel3D.skyEdge, grid = VoxelGrid.enabled(), + screen = { pw, ph }, cell = Voxel3D.cell*pw/w, fov = Voxel3D.fovY, + skyEdge = Voxel3D.skyEdge and Voxel3D.skyEdge*ph/h, grid = VoxelGrid.enabled(), lookFlat = Voxel3D.lookFlat, descent = Voxel3D.descent, -- the mirrored scene, already rendered and already released from its -- canvas (see drawPlanar). nil is the answer on every frame the row is @@ -2311,6 +2327,7 @@ function VoxelScene.drawWater(draws, cast, planar, planarY) Water.draw(d[1], d[2], d[3]) end Water.finish() + painted = true plain = false end -- Unconditionally, and OUTSIDE the success branch: beginWater unbinds @@ -2318,7 +2335,11 @@ function VoxelScene.drawWater(draws, cast, planar, planarY) -- so a frame that bails halfway through has to be put back together -- exactly like one that succeeded -- otherwise every pass after it runs -- with no shader and no depth test. + WaterBudget.finish() Voxel3D.endWater() + if half and painted then + WaterBudget.composite(draws,Voxel3D.vp,{Voxel3D.curveX or 0,Voxel3D.curveZ or 0,Voxel3D.curveK or 0}) + end end -- the fallback flat draw -- unless the curve's prepass already put the -- same meshes down, in which case a bailed frame is already whole @@ -2440,7 +2461,7 @@ local function drawStaticCasters(state,terrain,nbMesh,atlasFor,water,nbWater) ShadowMap.draw(terrain, atlasFor(state.map), nil) for i, nb in ipairs(state.neighbors or {}) do ShadowMap.draw(nbMesh[i], atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy)) + neighborMatrix(nb)) end -- The water surface, which the terrain mesh no longer carries (it is its -- own reflective pass now -- see Water). The sun still has to see it, or @@ -2449,7 +2470,7 @@ local function drawStaticCasters(state,terrain,nbMesh,atlasFor,water,nbWater) ShadowMap.draw(water, atlasFor(state.map), nil) for i, nb in ipairs(state.neighbors or {}) do ShadowMap.draw(nbWater and nbWater[i], atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy)) + neighborMatrix(nb)) end -- flower billboards live outside the terrain mesh (they draw after the -- characters, pulled -- see render), but the sun still sees them: a @@ -2461,7 +2482,7 @@ local function drawStaticCasters(state,terrain,nbMesh,atlasFor,water,nbWater) ShadowMap.snug(nil)) for _, nb in ipairs(state.neighbors or {}) do ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), - ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) + ShadowMap.snug(neighborMatrix(nb))) end end local function drawMovingCasters(state,posed,atlasFor,battleCards) @@ -2663,7 +2684,9 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) local g = VoxelScene.glintStep(glint, cx, cy) Voxel3D.glassPhase, Voxel3D.glassGlint = g.phase, g.amp + local atlasMemo = V.renderBudget~=false and {} local function atlasFor(map) + if atlasMemo and atlasMemo[map]~=nil then return atlasMemo[map] or nil end -- Lua evaluates texture arguments even when draw(nil, texture) returns -- immediately. Newly exposed neighbours have no published terrain yet; -- preparing their atlas here bypasses the queued build's time slicing. @@ -2672,7 +2695,9 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) if Gen3.mapIsGen3(map) and not (ChunkMesher.peek(map,false) or ChunkMesher.peek(map,true)) then return nil end - return TerrainAtlas.forMap(map, modeColors(paletteFor, map)) + local image=TerrainAtlas.forMap(map, modeColors(paletteFor, map)) + if atlasMemo then atlasMemo[map]=image or false end + return image end -- sprite palettes only exist in the SGB modes; under RED++ the OBP bake @@ -2774,7 +2799,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) Voxel3D.draw(terrain, atlasFor(state.map), nil) for i, nb in ipairs(state.neighbors or {}) do Voxel3D.draw(nbMesh[i], atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy)) + neighborMatrix(nb)) end closeTerrain() @@ -2871,7 +2896,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) for i, nb in ipairs(state.neighbors or {}) do if nbWater and nbWater[i] then waterDraws[#waterDraws + 1] = { nbWater[i], atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy) } + neighborMatrix(nb) } end end if #waterDraws > 0 then @@ -2981,7 +3006,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) Voxel3D.draw(ChunkMesher.grass(state.map), atlasFor(state.map), nil, pull) for _, nb in ipairs(state.neighbors or {}) do Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy), pull) + neighborMatrix(nb), pull) end -- flower billboards: pulled like the characters and the grass, MINUS -- the depth of 8 world pixels along the view (8 sin a -- the camera @@ -3000,8 +3025,8 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) fpull, ShadowMap.snug(nil)) for _, nb in ipairs(state.neighbors or {}) do Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy), fpull, - ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) + neighborMatrix(nb), fpull, + ShadowMap.snug(neighborMatrix(nb))) end -- The VR pokedex in the player's left hand, last of all: a prop over diff --git a/mods/DRAMATIC_SHAPE/lib/Water.lua b/mods/DRAMATIC_SHAPE/lib/Water.lua index 5a20297f1..0112b37bb 100644 --- a/mods/DRAMATIC_SHAPE/lib/Water.lua +++ b/mods/DRAMATIC_SHAPE/lib/Water.lua @@ -132,6 +132,12 @@ local ShadowMap = V.require("ShadowMap") local Mat4 = V.require("Mat4") local Water = {} +local Budget = V.require("WaterBudget") +local ShaderState = V.require("ShaderState") +local function sendUniform(shader,name,...) + if Budget.enabled()then return ShaderState.send(shader,name,...)end + return pcall(shader.send,shader,name,...) +end -- ------- the row -- @@ -144,8 +150,8 @@ Water.KEY = "water" Water.LABEL = "WATER" Water.setting = ModSetting.new(Water.KEY, Water.LABEL, - { "full", "sky", "off" }, - { "FULL", "SKY", "OFF" }) + { "auto", "full", "sky", "off" }, + { "AUTO", "FULL", "SKY", "OFF" }) -- The PERFORMANCE tier's ceilings (see lib/Tier.lua), resolved lazily for -- the same reason ShadowMap's are. @@ -164,7 +170,7 @@ end function Water.level() local v = Water.setting:get() - local lv = 2 + local lv = v == "auto" and Budget.defaultLevel() or 2 if v == "off" then lv = 0 elseif v == "sky" then lv = 1 end -- A CEILING, NOT A SETTING. The row's own three rungs are the player's @@ -773,6 +779,8 @@ uniform float waveHeight; // the tallest column, in whole world pixels uniform float waveSlope; // how far a column's neighbours tilt its normal uniform float waveSlopeLean; // and how far the horizon lean may open that up uniform float waveT; +uniform Image waveField; +uniform float waveFieldOn; uniform vec4 faceShade; // the mesh's own direction shading: E, W, S, N uniform vec2 atlasSize; // the tileset atlas, in texels uniform float fresnelFloor; @@ -1063,7 +1071,8 @@ vec4 march(vec3 origin, vec3 dir) { // and the reasoning behind their weights live; pasted in rather than sent, // so the speed derived from those same numbers cannot drift from the field // they describe. -float waveRaw(vec2 q) { +float waveRaw(LOVE_HIGHP_OR_MEDIUMP vec2 q) { + if(waveFieldOn>0.5)return Texel(waveField,(q+vec2(0.5))/256.0).r; float h = 0.0; //@TRAINS return h * 0.5 + 0.5; @@ -1475,16 +1484,61 @@ end Water._trainSource = trainSource -- named for the suite +local bakeSource +function Water._waveBakeSource() + if bakeSource then return bakeSource end + local function periodic(value)return math.floor(value*256/(2*math.pi)+0.5)*(2*math.pi)/256 end + local src=trainSource() + src=src:gsub("vec2%(([%d%.%-]+), ([%d%.%-]+)%)",function(x,z) + return ("vec2(%.9f, %.9f)"):format(periodic(tonumber(x)),periodic(tonumber(z))) + end) + bakeSource="float waveRaw(vec2 q){float h=0.0;\n"..src.."\nreturn h*0.5+0.5;}" + return bakeSource +end + local function source(grid) local src = SHADER_SRC:gsub("//@CRATERS", (craterSource():gsub("%%", "%%%%"))) src = src:gsub("//@TRAINS", (trainSource():gsub("%%", "%%%%"))) + if Budget.enabled() then + src=src:gsub("precision highp float;","precision mediump float;") + src=src:gsub("uniform mat4", "uniform LOVE_HIGHP_OR_MEDIUMP mat4") + src=src:gsub("uniform vec3 curve", "uniform LOVE_HIGHP_OR_MEDIUMP vec3 curve") + src=src:gsub("uniform vec3 eye", "uniform LOVE_HIGHP_OR_MEDIUMP vec3 eye") + src=src:gsub("varying vec3 vSun", "varying LOVE_HIGHP_OR_MEDIUMP vec3 vSun") + -- These routines carry world coordinates, projected depth and ray crossings. + -- Color/sky lighting and normalized texture values keep the stage default. + for _,name in ipairs({"bendDrop","project","march","relief","waveAt","waveNormal"})do + local from=src:find("[%w_]+%s+"..name.."%(") + if from then + local open=src:find("{",from,true) + local at,level=open,1 + repeat + at=at+1 + local c=src:sub(at,at) + if c=="{"then level=level+1 elseif c=="}"then level=level-1 end + until level==0 or at>#src + local fn=src:sub(from,at):gsub("(vec[234])(%s+[%w_]+)","LOVE_HIGHP_OR_MEDIUMP %1%2") + :gsub("float(%s+[%w_]+)","LOVE_HIGHP_OR_MEDIUMP float%1") + src=src:sub(1,from-1)..fn..src:sub(at+1) + end + end + for _,name in ipairs({"selfC","selfZ","sheet","view","hit","col","surf"})do + src=src:gsub(" (vec[234]) "..name.."([ ;=])"," LOVE_HIGHP_OR_MEDIUMP %1 "..name.."%2") + src=src:gsub(" float "..name.."([ ;=])"," LOVE_HIGHP_OR_MEDIUMP float "..name.."%1") + end + end + if love.getVersion and love.getVersion()>=12 then + -- LÖVE 12 declares effect's numeric parameters at high precision. + src=src:gsub("effect%(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc%)", + "effect(LOVE_HIGHP_OR_MEDIUMP vec4 color, Image tex, LOVE_HIGHP_OR_MEDIUMP vec2 tc, LOVE_HIGHP_OR_MEDIUMP vec2 sc)") + end local head = ("#define RAY_STEPS %d\n#define RAY_REFINE %d\n" .. "#define WAVE_STEPS %d\n#define WAVE_STRIDE %.1f\n" -- compiled in rather than sent, like the step bounds beside -- it: a uniform that failed to send would silently take the -- slack back to zero, which is the bug this is here to fix .. "#define RAY_BIAS %.6f\n") - :format(Water.RAY_STEPS, Water.RAY_REFINE, Water.WAVE_STEPS, + :format(Budget.steps(Water.RAY_STEPS,Water.RAY_REFINE), select(2,Budget.steps(Water.RAY_STEPS,Water.RAY_REFINE)), Water.WAVE_STEPS, Water.WAVE_STRIDE, Water.RAY_BIAS) if grid then head = head .. "#define VOXEL_GRID 1\n" end return head .. src @@ -1499,10 +1553,12 @@ Water._source = source -- named for the suite local shaders = { [false] = nil, [true] = nil } function Water.shader(grid) + local steps,refine=Budget.steps(Water.RAY_STEPS,Water.RAY_REFINE) + local key=tostring(grid and true or false)..":"..steps..":"..refine..":"..tostring(Budget.enabled()) grid = grid and true or false - if shaders[grid] == nil then + if shaders[key] == nil then if not (love.graphics and love.graphics.newShader) then - shaders[grid] = false + shaders[key] = false else local ok, sh = pcall(love.graphics.newShader, V.require("GraphicsCompat").shader(source(grid))) if not ok and V and V.mod and V.mod.log then @@ -1511,10 +1567,10 @@ function Water.shader(grid) V.mod.log:warn("water shader did not compile: %s -- lakes draw flat", tostring(sh)) end - shaders[grid] = (ok and sh) or false + shaders[key] = (ok and sh) or false end end - return shaders[grid] or nil + return shaders[key] or nil end -- ------- the pass @@ -1566,7 +1622,7 @@ function Water.begin(ctx) love.graphics.setShader(sh) love.graphics.setColor(1, 1, 1, 1) local function send(name, ...) - pcall(sh.send, sh, name, ...) + sendUniform(sh, name, ...) end send("vp", "row", ctx.vp) @@ -1594,7 +1650,7 @@ function Water.begin(ctx) send("sunTexel", { texel, texel }) send("dayTint", Voxel3D.tint or { 1, 1, 1 }) - send("rays", level >= 2 and 1 or 0) + send("rays", level >= 2 and not (Budget.enabled() and ctx.planar) and 1 or 0) -- THE PLANAR MIRROR, or the switch that says there is not one. -- -- planarTex is bound EITHER WAY. A declared sampler left unbound is a @@ -1615,7 +1671,11 @@ function Water.begin(ctx) send("waveHeight", Water.WAVE_HEIGHT) send("waveSlope", Water.WAVE_SLOPE) send("waveSlopeLean", Water.WAVE_SLOPE_LEAN) - send("waveT", waveTime()) + local wt=waveTime() + local field=Budget.waveTexture(wt, Water._waveBakeSource()) + send("waveField",field or ctx.reflect) + send("waveFieldOn",field and 1 or 0) + send("waveT", wt) -- the columns' side faces wear the MESH's own direction shading, sent in -- rather than restated, so a wave crest is lit like every other voxel local fs = Voxel3D.FACE_SHADE @@ -1646,7 +1706,7 @@ end -- back at itself, which is what a pond in a cave does. function Water.sendSky(sh, ctx) local function send(name, ...) - pcall(sh.send, sh, name, ...) + sendUniform(sh, name, ...) end local ramp, count = Sky.ramp() local edge = ctx.skyEdge @@ -1711,7 +1771,7 @@ function Water.draw(mesh, texture, model) if not (active and mesh) then return end local parts = type(mesh) == "table" and mesh.parts if texture and not parts then mesh:setTexture(texture) end - pcall(active.send, active, "model", "row", model or Mat4.identity()) + sendUniform(active, "model", "row", model or Mat4.identity()) -- Per draw rather than per pass, and read off the TEXTURE rather than -- assumed: it is what converts a world pixel of the columns' parallax into -- the texel of art standing on it, and two maps in one frame can be drawn @@ -1719,7 +1779,7 @@ function Water.draw(mesh, texture, model) if texture and texture.getDimensions then local ok, w, h = pcall(texture.getDimensions, texture) if ok and w and h then - pcall(active.send, active, "atlasSize", { w, h }) + sendUniform(active, "atlasSize", { w, h }) end end if parts then V.require("MeshBatch").draw(mesh, texture, nil, model) @@ -1733,6 +1793,8 @@ end -- Drop the compiled shaders (window resize, hot reload): they are GPU -- objects on a context that may not exist any more. function Water.invalidate() + bakeSource=nil + Budget.invalidate() for k, sh in pairs(shaders) do if sh and sh.release then pcall(sh.release, sh) end shaders[k] = nil diff --git a/mods/DRAMATIC_SHAPE/lib/WaterBudget.lua b/mods/DRAMATIC_SHAPE/lib/WaterBudget.lua new file mode 100644 index 000000000..7a6f771be --- /dev/null +++ b/mods/DRAMATIC_SHAPE/lib/WaterBudget.lua @@ -0,0 +1,165 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local V = ... +local Card = {} +local Scale = V.require("RenderScale") +local Mat = V.require("Mat4") +local State = V.require("ShaderState") +local waves, waveShader, lastTime, lastSource +local layer, layerW, layerH, composite, drawing +local COMPOSITE = [[ +#ifdef VERTEX +uniform mat4 vp; +uniform mat4 model; +uniform vec3 curve; +vec4 position(mat4 tp,vec4 p){ + vec4 w=model*p;vec2 d=w.xz-curve.xy; + if(curve.z>0.0)w.y-=dot(d,d)*curve.z; + return vp*w; +} +#endif +#ifdef PIXEL +uniform Image layer; +vec4 effect(vec4 color,Image atlas,vec2 uv,vec2 sc){ + vec4 c=Texel(layer,sc/love_ScreenSize.xy); + if(c.a<0.5)discard; + return c; +} +#endif +]] +function Card.enabled() + return V.renderBudget ~= false +end +function Card.defaultLevel() + V.require("Tier").caps() + return (not Card.enabled() or (not Scale.mobile() and V.require("Tier").name == "high")) and 2 or 1 +end +function Card.steps(steps, refine) + local tier = V.require("Tier").caps() + return Card.enabled() and (tier.waterRaySteps or 12) or steps or 24, + Card.enabled() and (tier.waterRayRefine or 3) or refine or 5 +end +function Card.waveTexture(time, source) + if not Card.enabled() or waves == false then + return nil + end + if not waves then + local ok, made = pcall(love.graphics.newCanvas, 256, 256, { format = "r8", dpiscale = 1, readable = true }) + if not ok then + waves = false + return nil + end + waves = made + waves:setFilter("linear", "linear") + waves:setWrap("repeat", "repeat") + end + if source ~= lastSource then + local ok, made = pcall(love.graphics.newShader, "uniform float waveT;\n" .. source .. [[ +vec4 effect(vec4 c,Image t,vec2 uv,vec2 sc){return vec4(waveRaw(uv*256.0),0.0,0.0,1.0);} +]]) + if not ok then + waves:release() + waves = false + return nil + end + if waveShader then + waveShader:release() + end + waveShader, lastSource, lastTime = made, source, nil + end + if lastTime ~= time then + love.graphics.push("all") + love.graphics.setCanvas(waves) + love.graphics.origin() + love.graphics.setScissor() + love.graphics.setDepthMode() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.setBlendMode("replace", "premultiplied") + love.graphics.setShader(waveShader) + waveShader:send("waveT", time) + love.graphics.rectangle("fill", 0, 0, 256, 256) + love.graphics.pop() + lastTime = time + end + return waves +end +function Card.begin(w, h) + if not Card.enabled() or layer == false then + return false + end + local ww, hh = math.max(1, math.ceil(w / 2)), math.max(1, math.ceil(h / 2)) + if not composite then + local ok, made = pcall(love.graphics.newShader, V.require("GraphicsCompat").shader(COMPOSITE)) + if not ok then + layer = false + return false + end + composite = made + end + if not layer or layerW ~= ww or layerH ~= hh then + local ok, made = V.require("PixelCanvas").new(ww, hh) + if not ok then + if layer then + layer:release() + end + layer = false + return false + end + if layer then + layer:release() + end + layer, layerW, layerH = made, ww, hh + layer:setFilter("nearest", "nearest") + end + love.graphics.push("all") + local ok = pcall(love.graphics.setCanvas, { layer, depth = true }) + if not ok then + love.graphics.pop() + layer:release() + layer = false + return false + end + love.graphics.setScissor() + love.graphics.setDepthMode("lequal", true) + love.graphics.setBlendMode("replace", "premultiplied") + love.graphics.clear(0, 0, 0, 0, true, true) + drawing = true + return true, ww, hh +end +function Card.finish() + if drawing then + love.graphics.pop() + drawing = false + end +end +function Card.composite(draws, vp, curve) + if not (layer and composite) then + return + end + love.graphics.push("all") + love.graphics.setShader(composite) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.setDepthMode("lequal", true) + State.send(composite, "vp", "row", vp) + State.send(composite, "curve", curve) + State.send(composite, "layer", layer) + for _, d in ipairs(draws) do + State.send(composite, "model", "row", d[3] or Mat.identity()) + local mesh = d[1] + if type(mesh) == "table" and mesh.parts then + V.require("MeshBatch").draw(mesh, d[2]) + else + love.graphics.draw(mesh) + end + end + love.graphics.pop() +end +function Card.invalidate() + Card.finish() + for _, object in ipairs({ waves or false, waveShader or false, layer or false, composite or false }) do + if object and object.release then + object:release() + end + end + waves, waveShader, lastTime, lastSource, layer, composite = nil, nil, nil, nil, nil, nil +end +return Card diff --git a/mods/DRAMATIC_SHAPE/main.lua b/mods/DRAMATIC_SHAPE/main.lua index 87e6adc93..b8af9ca80 100644 --- a/mods/DRAMATIC_SHAPE/main.lua +++ b/mods/DRAMATIC_SHAPE/main.lua @@ -47,6 +47,7 @@ local mod = ... local V = { mod = mod, path = mod.path } -- P7: independently reversible world-anchored static and 256px actor shadows. V.shadowWorldAnchor = true +V.renderBudget = true -- Repeated Gen 3 hulls share immutable GPU meshes where the driver can draw -- their offsets per instance. Other drivers keep the expanded vertex path. do @@ -265,6 +266,7 @@ local DayNight = V.require("DayNight") local DayTint = V.require("DayTint") local Water = V.require("Water") local AntiAlias = V.require("AntiAlias") +local RenderScale = V.require("RenderScale") local FirstPerson = V.require("FirstPerson") local FreeMove = V.require("FreeMove") local CamControl = V.require("CamControl") @@ -470,7 +472,8 @@ mod.content.render_pipelines:register("voxel", { -- these two lines knows: every pass in the frame measures itself in the -- canvas it was handed, so the sky's dither, the water's march and the -- camera itself all come out the same picture at a higher sample rate. - local rw, rh = AntiAlias.expand(sw, sh) + local bw, bh = RenderScale.size(sw, sh) + local rw, rh = AntiAlias.expand(bw, bh) local drawStarted=love.timer.getTime() local canvas = VoxelScene.render(ctx.state, rw, rh, ctx.vw, ctx.vh, ctx.paletteFor) @@ -491,18 +494,19 @@ mod.content.render_pipelines:register("voxel", { ctx.drawFx(function(wx, wy) return Voxel3D.project(wx, Voxel3D.groundY or 0, wy) end, - ctx.scale * AntiAlias.factor()) + ctx.scale * AntiAlias.factor() * bw / sw) -- the horde's readout rides the same overlay, over the FX: health, -- ammunition, the crosshair and the banners, sized in the same -- supersampled canvas pixels everything else here is drawn in. A -- headset never reaches this line (drawWorld returns the mirror -- above) -- lib/VR draws the same HUD onto each eye instead. - HordeHud.drawFlat(rw, rh, ctx.scale * AntiAlias.factor()) + HordeHud.drawFlat(rw, rh, ctx.scale * AntiAlias.factor() * bw / sw) Voxel3D.endOverlay() end -- and back to the window's own size, which is what the engine composites -- one canvas pixel to one display pixel. A pass-through when AA is off. - local out = AntiAlias.resolve(canvas, sw, sh, "world") + local out = AntiAlias.resolve(canvas, bw, bh, "world") + out = RenderScale.present(out, sw, sh) -- THE FRAME SEAM. Perf.frame stamps one whole-frame time per RENDERED -- frame and this is the only place in the mod that is reached exactly -- once per rendered world frame -- Pipelines calls drawWorld from @@ -520,6 +524,7 @@ mod.content.render_pipelines:register("voxel", { Voxel3D.invalidate() OverworldBattle.invalidate() AntiAlias.invalidate() + RenderScale.invalidate() ChunkMesher.invalidate() -- no map id = every cached mesh VR.invalidate() -- the mirror, and FBO ids of dead canvases end, @@ -756,6 +761,10 @@ local SETTINGS = { -- machine it happens to be running on, so it neither sets this nor takes -- the row away -- the player decides what their hardware can carry, from -- inside FULL like anywhere else. + { RenderScale.setting, + "Render the world at fewer pixels, then enlarge with nearest filtering. " + .. "AUTO uses 2X on mobile and Linux ARM; desktop uses 1X. UI stays at its own resolution.", + full = true }, { AntiAlias.setting, "Smooth the stair-stepped edges of the 3D world -- roof ridges, ledge " .. "lips, a tree against the sky -- by rendering the diorama larger than " diff --git a/tools/check_render_budget.py b/tools/check_render_budget.py new file mode 100644 index 000000000..fa39a3aa1 --- /dev/null +++ b/tools/check_render_budget.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Run owned native render budgets in an isolated QA profile.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--love', type=Path, required=True) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(args.qa_tools or root / 'tools')) + from qa_storage import qa_source, qa_cache, qa_profile, qa_run + qa_source(root) + qa_source(args.love.resolve()) + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True, exist_ok=True) + names = [f'mods/DRAMATIC_SHAPE/lib/{n}.lua' for n in + ('RenderScale', 'WaterBudget', 'ShaderState', 'Water', 'VoxelScene', 'Voxel3D', 'Tier', 'TileShape')] + names += ['mods/DRAMATIC_SHAPE/manifest.json', 'mods/DRAMATIC_SHAPE/main.lua', + 'tools/render-budget-check/main.lua', 'tools/render-budget-check/conf.lua'] + hashes = {n: hashlib.sha256((root / n).read_bytes()).hexdigest() for n in names} + identity = f'oasc-render-budget-qa-{time.time_ns()}' + qa_profile(Path.home() / 'Library/Application Support/LOVE' / identity) + result = qa_run([str(args.love.resolve()), str(root / 'tools/render-budget-check')], + cwd=root, env=dict(os.environ, RENDER_BUDGET_QA_GUARDED='1', + RENDER_BUDGET_QA_ROOT=str(root), RENDER_BUDGET_QA_OUT=str(args.report.parent.resolve()), POKEPORT_IDENTITY=identity), + capture_output=True, text=True, timeout=90) + args.report.with_suffix('.log').write_text(result.stdout + result.stderr) + rows = [s.removeprefix('RENDER_BUDGET_RESULT ') for s in result.stdout.splitlines() + if s.startswith('RENDER_BUDGET_RESULT ')] + if result.returncode or len(rows) != 1: + raise RuntimeError('native render-budget probe failed; see its log') + if any(hashlib.sha256((root / n).read_bytes()).hexdigest() != h for n, h in hashes.items()): + raise RuntimeError('measured source changed during the probe') + report = json.loads(rows[0]) + report.update(sha256=hashes, playerProfilesUsed=False) + args.report.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/tools/render-budget-check/conf.lua b/tools/render-budget-check/conf.lua new file mode 100644 index 000000000..88c959095 --- /dev/null +++ b/tools/render-budget-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv("RENDER_BUDGET_QA_GUARDED") == "1", "use the guarded render-budget checker") + t.identity = assert(os.getenv("POKEPORT_IDENTITY")) + t.window.title = "Render budget qualification" + t.window.width, t.window.height, t.window.vsync = 640, 480, 0 + t.modules.audio = false +end diff --git a/tools/render-budget-check/main.lua b/tools/render-budget-check/main.lua new file mode 100644 index 000000000..48c9a71dd --- /dev/null +++ b/tools/render-budget-check/main.lua @@ -0,0 +1,329 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local root = assert(os.getenv("RENDER_BUDGET_QA_ROOT")) +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +local Json = require("src.link.Json") +local Profile = require("src.core.FrameProfile") +local Performance = require("src.core.Performance") +Performance.tier = "high" +require("src.core.GameVersion").set("crystal") +local V, libs = { sharedHulls = true }, {} +V.mod = { log = { + warn = function(_, fmt, ...) + print(fmt:format(...)) + end, +} } +V.require = function(name) + if not libs[name] then + libs[name] = assert(loadfile(root .. "/mods/DRAMATIC_SHAPE/lib/" .. name .. ".lua"))(V) + end + return libs[name] +end +V.data = function(name) + return assert(loadfile(root .. "/mods/DRAMATIC_SHAPE/data/" .. name .. ".lua"))() +end +local phase, frame, total, fenced = 1, 0, 0, 0 +libs.TerrainAtlas = { + _animFrame = function() + return frame + end, +} +local Scale = V.require("RenderScale") +local Budget = V.require("WaterBudget") +local Water = V.require("Water") +local G = V.require("Voxel3D") +local Voxel = V.require("VoxelState") +local Scene = V.require("VoxelScene") +local State = V.require("ShaderState") +local Mat = V.require("Mat4") +local checks = 0 +local function check(ok, message) + assert(ok, message) + checks = checks + 1 +end +local function up(fn, name) + for i = 1, 100 do + local n, v = debug.getupvalue(fn, i) + if n == name then + return v + end + if not n then + break + end + end + error("missing upvalue " .. name) +end +local function solid(r, g, b) + local d = love.image.newImageData(2, 2) + d:mapPixel(function() + return r, g, b, 1 + end) + local image = love.graphics.newImage(d) + d:release() + return image +end +local function quad(x0, z0, x1, z1, y) + return love.graphics.newMesh(G.FORMAT, { + { x0, y, z0, 0, 0, 1 }, + { x1, y, z0, 1, 0, 1 }, + { x1, y, z1, 1, 1, 1 }, + { x0, y, z0, 0, 0, 1 }, + { x1, y, z1, 1, 1, 1 }, + { + x0, + y, + z1, + 0, + 1, + 1, + }, + }, "triangles", "static") +end +local water, wall, blue, red +local function draw(read) + check(G.beginScene(640, 480, 400, 400, 160, 144), "scene unavailable") + G.draw(wall, red) + local before + if read then + G.endScene() + before = G.canvas():newImageData() + assert(G.beginScene(640, 480, 400, 400, 160, 144)) + G.draw(wall, red) + end + Scene.drawWater({ { water, blue, nil } }) + G.endScene() + if read then + local after = G.canvas():newImageData() + local count = 0 + for y = 0, 479, 4 do + for x = 0, 639, 4 do + local r, g, b, a = before:getPixel(x, y) + if r > 0.1 and r > g * 2 and r > b * 2 then + local rr, gg, bb = after:getPixel(x, y) + check(r == rr and g == gg and b == bb, "half water covered foreground geometry") + count = count + 1 + end + end + end + check(count > 100, "foreground negative control had no pixels") + local f = assert( + io.open( + assert(os.getenv("RENDER_BUDGET_QA_OUT")) .. "/" .. (V.renderBudget and "budget" or "legacy") .. ".png", + "wb" + ) + ) + f:write(after:encode("png"):getString()) + f:close() + before:release() + local bytes = after:getString() + after:release() + return bytes + end +end +local function qualify() + local osName = love.system.getOS + love.system.getOS = function() + return "Android" + end + check(Scale.factor() == 2, "mobile AUTO is not 2X") + check(Budget.defaultLevel() == 1, "mobile default is not SKY") + love.system.getOS = osName + check(Scale.factor() == 1, "desktop AUTO changed native scale") + Scale.setting:sync(3) + local w, h = Scale.size(641, 481) + check(w == 214 and h == 161, "render scale rounding changed") + V.renderBudget = false + check(Scale.factor() == 1, "P8 rollback still scales") + V.renderBudget = true + Scale.setting:sync("auto") + local source = solid(0.2, 0.6, 0.8) + local out = Scale.present(source, 8, 8) + local d = out:newImageData() + local r, g, b = d:getPixel(7, 7) + check( + math.abs(r - 0.2) < 0.005 and math.abs(g - 0.6) < 0.005 and math.abs(b - 0.8) < 0.005, + "nearest upscale lost extent/color" + ) + d:release() + source:release() + local calls = 0 + local shader = { + send = function(_, name) + calls = calls + 1 + if name == "missing" then + error("unknown uniform") + end + end, + } + local v = { 1, 2, 3 } + State.send(shader, "v", v) + State.send(shader, "v", v) + v[2] = 4 + State.send(shader, "v", v) + check(calls == 2, "uniform vector mutation/reuse changed") + State.send(shader, "missing", 1) + State.send(shader, "missing", 2) + check(calls == 3, "unknown uniform was retried") + V.renderBudget = false + State.send(shader, "v", v) + V.renderBudget = true + State.send(shader, "v", v) + check(calls == 5, "flag changes retained stale uniform state") + local create, info = love.graphics.newCanvas, love.graphics.getRendererInfo + local depthFormat + love.graphics.getRendererInfo = function() + return "OpenGL ES", "OpenGL ES 3.0" + end + love.graphics.newCanvas = function(w, h, opts) + if opts and opts.format and opts.format:find("depth", 1, true) and not depthFormat then + depthFormat = opts.format + end + return create(w, h, opts) + end + check(G.beginScene(32, 32, 0, 0, 160, 144, nil, "gles-policy"), "depth policy target unavailable") + G.endScene() + love.graphics.newCanvas, love.graphics.getRendererInfo = create, info + check(depthFormat == "depth16", "GLES depth16 preference changed") + local neighbor = up(Scene.render, "neighborMatrix") + local nb = { ox = 3, oy = 4 } + local a = neighbor(nb) + check(neighbor(nb) == a, "neighbor matrix was reallocated") + nb.ox = 5 + check(neighbor(nb) ~= a, "edited neighbor offset stayed stale") + Water.setting:sync("auto") + Performance.tier = "balanced" + check(Water.level() == 1, "BALANCED default/cap changed") + Performance.tier = "high" + check(Water.level() == 2, "desktop HIGH default is not FULL") + for _, budget in ipairs({ false, true }) do + V.renderBudget = budget + check(Water.shader(false) ~= nil, "water shader did not compile") + check(Water.shader(true) ~= nil, "water grid shader did not compile") + if love.getVersion() < 12 then + local ok, err = love.graphics.validateShader(true, Water._source(false)) + check(ok, "GLES water validation failed: " .. tostring(err)) + end + end + V.renderBudget = true + local field = assert(Budget.waveTexture(0, Water._waveBakeSource())) + check(field:getFormat() == "r8", "wave field is not R8") + local first = field:newImageData():getString() + check(Budget.waveTexture(0, Water._waveBakeSource()) == field, "stable wave phase reallocated") + Budget.waveTexture(1, Water._waveBakeSource()) + check(field:newImageData():getString() ~= first, "wave phase did not update") + check(Budget.begin(641, 481), "half-resolution layer unavailable") + local c = love.graphics.getCanvas() + check(c:getWidth() == 321 and c:getHeight() == 241, "half layer dimensions changed") + Budget.finish() + Voxel.angle = math.rad(35) + water, wall = quad(144, 144, 656, 656, 0), quad(384, 384, 416, 416, 24) + blue, red = solid(0.1, 0.35, 0.7), solid(0.9, 0.05, 0.03) + assert(G.beginScene(640, 480, 400, 400, 160, 144)) + G.draw(wall, red) + G.draw(water, blue) + G.endScene() + local flat = G.canvas():newImageData():getString() + for _, budget in ipairs({ false, true }) do + V.renderBudget = budget + check(draw(true) ~= flat, "reflective water silently used only the flat fallback") + end + -- Failure must retain a complete full-resolution reflective pass. + Budget.invalidate() + local Pixel = V.require("PixelCanvas") + local make = Pixel.new + Pixel.new = function() + return false, "owned allocation failure" + end + check(not Budget.begin(640, 480), "failed half target was accepted") + Pixel.new = make + check(draw(true) ~= flat, "failed half target lost the full reflective fallback") + Budget.invalidate() + local send = State.send + local rays, planarOn + State.send = function(shader, name, ...) + if name == "rays" then + rays = select(1, ...) + elseif name == "planarOn" then + planarOn = select(1, ...) + end + return send(shader, name, ...) + end + check( + Water.begin({ reflect = blue, depth = blue, planar = blue, vp = Mat.identity(), eye = { 0, 0, 0 }, curve = { 0, 0, 0 }, screen = { + 640, + 480, + } }), + "planar budget did not begin" + ) + Water.finish() + love.graphics.setShader() + State.send = send + check(rays == 0 and planarOn == 1, "planar and ray march ran together") + Budget.invalidate() + local newCanvas = love.graphics.newCanvas + love.graphics.newCanvas = function(w, h, opts) + if opts and opts.format == "r8" then + error("owned R8 refusal") + end + return newCanvas(w, h, opts) + end + check(not Budget.waveTexture(0, Water._waveBakeSource()), "unsupported R8 was accepted") + love.graphics.newCanvas = newCanvas + check(draw(true) ~= flat, "failed R8 target lost reflective water") + Budget.invalidate() +end +local results = {} +function love.errorhandler(message) + print(debug.traceback(tostring(message), 2)) + return function() + return 1 + end +end +function love.run() + qualify() + return function() + love.event.pump() + for event in love.event.poll() do + if event == "quit" then + return 1 + end + end + V.renderBudget = phase == 2 + if frame == 120 then + Profile.setEnabled(true) + end + local close = Profile.section("voxel.water submit (owned geometry)") + local t = love.timer.getTime() + draw(false) + local submitted = love.timer.getTime() - t + close() + -- Equal completion fence outside the CPU-submission section. + local fence = G.canvas():newImageData(1, 1, 0, 0, 1, 1) + fence:release() + if frame >= 120 then + total = total + submitted + fenced = fenced + love.timer.getTime() - t + Profile.frame() + end + frame = frame + 1 + if frame == 240 then + results[phase] = { + mode = phase == 1 and "legacy" or "budget", + frames = 120, + warmupFrames = 120, + meanSubmitMs = total / 120 * 1000, + meanFencedMs = fenced / 120 * 1000, + } + if phase == 2 then + print("RENDER_BUDGET_RESULT " .. Json.encode({ + checks = checks, + phases = results, + renderer = love.graphics.getRendererInfo(), + scope = "Owned water/foreground geometry, native shader/targets/scene producer; CPU submission and pass completion with equal one-pixel GPU fences, no ROM or game FPS", + })) + return 0 + end + Profile.setEnabled(false) + phase, frame, total, fenced = 2, 0, 0, 0 + end + end +end From e93c24be32410797b0e45f170cee185e29b1269e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:57:24 +0100 Subject: [PATCH 117/138] Document P8 render and water budget validation --- docs/qa/render-budget-p8.json | 98 +++++++++++++++++++++++++++++++++ docs/render-budget-p8.md | 101 ++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 docs/qa/render-budget-p8.json create mode 100644 docs/render-budget-p8.md diff --git a/docs/qa/render-budget-p8.json b/docs/qa/render-budget-p8.json new file mode 100644 index 000000000..0ba46a937 --- /dev/null +++ b/docs/qa/render-budget-p8.json @@ -0,0 +1,98 @@ +{ + "runtimeCommit": "c1906c64a62e13cd3d5585e9c77ef387d483c44d", + "native": { + "metal": { + "checks": 4092, + "scope": "Owned water/foreground geometry, native shader/targets/scene producer; CPU submission and pass completion with equal one-pixel GPU fences, no ROM or game FPS", + "phases": [ + { + "meanFencedMs": 1.5684343750000107, + "mode": "legacy", + "frames": 120, + "warmupFrames": 120, + "meanSubmitMs": 0.12469236111114317 + }, + { + "meanFencedMs": 1.0172923611111089, + "mode": "budget", + "frames": 120, + "warmupFrames": 120, + "meanSubmitMs": 0.10366909722220505 + } + ], + "renderer": "Metal", + "sha256": { + "mods/DRAMATIC_SHAPE/lib/RenderScale.lua": "2cff29fdcac5aa5eb69020432cce25dab050a2f3475c20767bbc872573386774", + "mods/DRAMATIC_SHAPE/lib/WaterBudget.lua": "0616d66174acb385a52025e695e10488024ea93e9b28b5df9dd1fb844afdeb49", + "mods/DRAMATIC_SHAPE/lib/ShaderState.lua": "5a43d3be681ffaab6744425a1a6a9964c466b9a8a568e2286947dcc04ac38595", + "mods/DRAMATIC_SHAPE/lib/Water.lua": "47ea97b33cd6b3fe9b8cfb65e763fe04d5f2857e48dd0759c8074ac7405dfa9a", + "mods/DRAMATIC_SHAPE/lib/VoxelScene.lua": "115e2a358d94bdafdbaf6395edd15bc76b678515f36ba2c7c61f90472093e195", + "mods/DRAMATIC_SHAPE/lib/Voxel3D.lua": "8c19dd62281c8a2eff129627e51bf2d50c208c672d76808b9ec9ab31190807ed", + "mods/DRAMATIC_SHAPE/lib/Tier.lua": "a02d73cd3eef5461632a2fc6e6934cb1d2298c15fc199d1500a59d3bb76ab386", + "mods/DRAMATIC_SHAPE/lib/TileShape.lua": "1ded661f0a08c719ce42994d643c43cbffcaa03e27ae260a1ed4d3d444e90931", + "mods/DRAMATIC_SHAPE/manifest.json": "b94bdb437120be77c014937f0949a17ee81377db570abb46d997e80dd7e52ca3", + "mods/DRAMATIC_SHAPE/main.lua": "b9285e7d22948ea731713ae501c43f8a673f1c397053ba8cf0d7120693381012", + "tools/render-budget-check/main.lua": "fdf6ec2c11427b8b465d337c37ca57f676d9ab044ccf8659124698dac7c7ce6d", + "tools/render-budget-check/conf.lua": "05f59ce05bdda5cfa9dbe8b13cadf0179c6ff82e237503e810e8da7ed5ce8e8d" + }, + "playerProfilesUsed": false + }, + "openGL": { + "checks": 4094, + "scope": "Owned water/foreground geometry, native shader/targets/scene producer; CPU submission and pass completion with equal one-pixel GPU fences, no ROM or game FPS", + "phases": [ + { + "mode": "legacy", + "frames": 120, + "warmupFrames": 120, + "meanSubmitMs": 0.14358993055555064, + "meanFencedMs": 1.3747506944444303 + }, + { + "mode": "budget", + "frames": 120, + "warmupFrames": 120, + "meanSubmitMs": 0.1722927083333492, + "meanFencedMs": 0.8823149305555666 + } + ], + "renderer": "OpenGL", + "sha256": { + "mods/DRAMATIC_SHAPE/lib/RenderScale.lua": "2cff29fdcac5aa5eb69020432cce25dab050a2f3475c20767bbc872573386774", + "mods/DRAMATIC_SHAPE/lib/WaterBudget.lua": "0616d66174acb385a52025e695e10488024ea93e9b28b5df9dd1fb844afdeb49", + "mods/DRAMATIC_SHAPE/lib/ShaderState.lua": "5a43d3be681ffaab6744425a1a6a9964c466b9a8a568e2286947dcc04ac38595", + "mods/DRAMATIC_SHAPE/lib/Water.lua": "47ea97b33cd6b3fe9b8cfb65e763fe04d5f2857e48dd0759c8074ac7405dfa9a", + "mods/DRAMATIC_SHAPE/lib/VoxelScene.lua": "115e2a358d94bdafdbaf6395edd15bc76b678515f36ba2c7c61f90472093e195", + "mods/DRAMATIC_SHAPE/lib/Voxel3D.lua": "8c19dd62281c8a2eff129627e51bf2d50c208c672d76808b9ec9ab31190807ed", + "mods/DRAMATIC_SHAPE/lib/Tier.lua": "a02d73cd3eef5461632a2fc6e6934cb1d2298c15fc199d1500a59d3bb76ab386", + "mods/DRAMATIC_SHAPE/lib/TileShape.lua": "1ded661f0a08c719ce42994d643c43cbffcaa03e27ae260a1ed4d3d444e90931", + "mods/DRAMATIC_SHAPE/manifest.json": "b94bdb437120be77c014937f0949a17ee81377db570abb46d997e80dd7e52ca3", + "mods/DRAMATIC_SHAPE/main.lua": "b9285e7d22948ea731713ae501c43f8a673f1c397053ba8cf0d7120693381012", + "tools/render-budget-check/main.lua": "fdf6ec2c11427b8b465d337c37ca57f676d9ab044ccf8659124698dac7c7ce6d", + "tools/render-budget-check/conf.lua": "05f59ce05bdda5cfa9dbe8b13cadf0179c6ff82e237503e810e8da7ed5ce8e8d" + }, + "playerProfilesUsed": false + } + }, + "acceptance": { + "enginePassed": 156, + "engineSuites": 167, + "inheritedFailingSuites": 11, + "unchangedFailureLines": 29, + "baselineCommit": "de1a7e8d827400e48c62975457fa7e9a53be9fe1", + "sdkSuites": 3, + "galleryChecks": 253, + "api3Comparisons": 48, + "isolatedApi3Workers": 96, + "externalFixtureEntrypoints": 17, + "externalFixturePhase": "complete", + "head": "c1906c64a62e13cd3d5585e9c77ef387d483c44d", + "exitCodes": { + "engine": 1, + "sdk": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + } + } +} diff --git a/docs/render-budget-p8.md b/docs/render-budget-p8.md new file mode 100644 index 000000000..4336f9763 --- /dev/null +++ b/docs/render-budget-p8.md @@ -0,0 +1,101 @@ +# World render and water budget (P8) + +`V.renderBudget` defaults to `true` in `mods/DRAMATIC_SHAPE/main.lua`. Set it to +`false` to disable P8 independently: native window resolution, the previous water +algorithm with 24 ray steps / 5 refinements and analytic waves, original sends, uncached neighbor/atlas paths and string shape keys. The LÖVE 12 effect-signature correction is a +shader compatibility repair shared by both modes. + +## RenderScale card + +The RENDER SCALE row stores AUTO or an integer 1–4. AUTO uses two framebuffer +pixels per rendered pixel on Android/iOS, Switch/Horizon and Linux ARM; macOS and +other desktop hosts use one. Explicit choices remain available. The world renders +at ceil(width/factor) × ceil(height/factor), AA resolves at that size, then an owned +full-size canvas receives a nearest-filtered upscale. Projected field FX and the +world HUD use the corresponding scale. Engine UI and headset eye/mirror policy +retain their own existing resolution. Readable depth allocation tries depth16 +first when the renderer reports OpenGL ES; desktop format preference stays intact. + +## WaterBudget card + +The WATER row adds AUTO. Existing FULL/SKY/OFF values retain their meaning and are +not rewritten. AUTO resolves to FULL on desktop HIGH and SKY on mobile/Linux ARM +or capped tiers. Existing tier ceilings still apply. Ray steps/refinements are +additive tier values, 12/3, with the old 24/5 fields retained for rollback. This is +a smaller reflection search budget, with corresponding visual differences. + +A 256-square, repeat-wrapped R8 field stores a periodic approximation of the +existing wave trains at the existing quantized wave time. Spatial frequencies +round to complete cycles across the tile; temporal rates, weights, bend/swell and +height/normal controls stay derived from the authored tables. Wave lookup replaces +repeated sine evaluation in relief and normal sampling. Unsupported R8 rendering +retains analytic waves. Invalidation releases the field and its shader. + +Reflective water shades into a separate half-width/half-height canvas. A flat +prepass preserves thin sheets and establishes surface depth. The expensive shader +reads full-resolution scene depth. A cheap second geometry draw composites the +nearest sampled result against the reattached full-resolution depth buffer; it is +not a rectangle laid over foreground objects. Failed allocation/binding restores +the complete full-resolution reflective path, with sticky target refusal until +invalidation. Planar reflection disables the simultaneous screen-space march and +its otherwise-unused cast copy. Off-plane planar fragments retain sky. + +The GLES fragment default becomes mediump for color/lighting. World positions, +matrices, depth reconstruction and ray/relief calculations retain explicit high +precision. The installed LÖVE 12 parser requires effect parameter precision to +match its declaration; both switch modes use that signature. Plain/grid variants +compile natively; LÖVE 11 also validates both modes as GLES shader source. This is +not a measurement on an actual GLES device. + +## Existing caches + +ShaderState keeps persistent per-shader/per-name records and snapshots small +numeric vectors/matrices. It probes the first send, remembers absent uniforms, +then calls known sends directly when values change. Flag changes clear snapshots; +unknown names are not retried, while a first rejected value for a known name can +recover. Existing instanced-state copies remain supported. Water uses this path +only with P8 enabled. + +Neighbor translations are private weak-key records with live offset validation. +The render-local atlas memo resolves each map/palette once for all passes in that +frame, avoiding repeated TerrainAtlas preparation and palette-key work without +changing its cross-frame invalidation. Gen-3 shape memoization uses class buckets, +separate pinned/unpinned buckets and numeric height keys. Persisted editor-key +formats are unchanged. No engine record or public mod interface receives these +caches. The manifest, permissions, engine-internals use and source notices remain. + +## Reproduction and evidence + +Run `python3 tools/check_render_budget.py --love /absolute/path/to/love --report +.local/qa/render-budget/profile.json` on one shell line. The guarded runner pins +inputs and uses an isolated identity. Its owned water and raised foreground quads +exercise actual scene/water shaders, canvas/depth handling, wave field and producer. +No ROM, imported artwork or player save is used. Readback checks preserve foreground +pixels and distinguish reflection from the flat fallback, including failed half +and R8 targets. Policy, nearest upscale, mutation-aware uniforms, unknown names, +live rollback, neighbor offsets, half-target size and planar/march exclusion are +also checked. Counts include individual foreground pixels and scene opens. + +Each mode warms up 120 frames and measures 120. FrameProfile reports +`voxel.water submit (owned geometry)`; the separate completion value includes an +equal one-pixel GPU readback. Presentation and full-game FPS are excluded. + +| Desktop macOS ARM64 host | Previous path | Budget | +| --- | ---: | ---: | +| LÖVE 11.5 OpenGL, CPU submission | 0.144 ms | 0.172 ms | +| LÖVE 11.5 OpenGL, GPU completion fence included | 1.375 ms | 0.882 ms | +| LÖVE 12 Metal, CPU submission | 0.125 ms | 0.104 ms | +| LÖVE 12 Metal, GPU completion fence included | 1.568 ms | 1.017 ms | + +The added prepass/composite can cost submission time; OpenGL's final run does. +Other Metal runs also showed a small submission increase. The scoped reduction in +completion time is not a universal CPU/FPS claim. Mobile/handheld, populated maps, +VR and long-session behavior remain unmeasured. OpenGL passes 4094 scoped checks +(including two GLES validations), Metal 4092. Source hashes and acceptance are in +[render-budget-p8.json](qa/render-budget-p8.json). + +Integrated fork: 478/478 engine suites, 3/3 SDK suites, 253 gallery checks, +200 API-3 comparisons / 400 workers and all 17 unchanged external fixture +entrypoints in complete mode pass. The public stack retains exactly its previous +29 FAIL lines across 11 inherited suites (156/167), with no new failing suite; +SDK 3/3, gallery 253, API-3 48/96 and all 17 external entrypoints pass. From 688a62a47a0d1274ee31c3b3272ee152cb3eca68 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:14:40 +0100 Subject: [PATCH 118/138] Keep Apple Silicon on the desktop performance tier --- src/core/Performance.lua | 5 +++-- tests/engine/performance_tiers.lua | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/core/Performance.lua b/src/core/Performance.lua index a9aa53cfd..53595fb32 100644 --- a/src/core/Performance.lua +++ b/src/core/Performance.lua @@ -101,7 +101,8 @@ local function processorCount() end -- CPU architecture via LuaJIT's jit.arch when present: "arm"/"arm64" on --- phones and PortMaster handhelds, "x86"/"x64" on desktops. nil under a +-- phones, Apple Silicon Macs and PortMaster handhelds; "x86"/"x64" on +-- Intel/AMD desktops. nil under a -- plain-Lua tool or test with no jit table, which resolves to HIGH (no -- clamping) so tooling is never surprised. local function cpuArch() @@ -122,7 +123,7 @@ function Performance.detect() -- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project -- already ships a build for): the weakest target here. - if isArm and os ~= "Android" and os ~= "iOS" then + if isArm and os ~= "Android" and os ~= "iOS" and os ~= "OS X" then return "low" end -- Phones and tablets: GBC FX is already force-disabled here (issue #136); diff --git a/tests/engine/performance_tiers.lua b/tests/engine/performance_tiers.lua index befdeb2fd..14591e0c5 100644 --- a/tests/engine/performance_tiers.lua +++ b/tests/engine/performance_tiers.lua @@ -42,6 +42,17 @@ T.eq(Performance.detect(), "balanced", "iOS -> balanced") device("OS X", "x64", 8) T.eq(Performance.detect(), "high", "8-core desktop -> high") +device("OS X", "arm64", 8) +T.eq(Performance.detect(), "high", "Apple Silicon is a desktop, not an ARM handheld") +T.eq(Performance.resolve("auto"), "high", "Apple Silicon AUTO avoids the handheld cap") +Performance.applyOptions({ performance = "auto" }) +T.eq(Performance.tier, "high", "Apple Silicon publishes the resolved desktop tier") +T.eq(Performance.resolve("low"), "low", "explicit low remains selectable on Apple Silicon") +T.eq(Performance.resolve("balanced"), "balanced", "explicit balanced remains selectable on Apple Silicon") + +device("OS X", "arm64", 2) +T.eq(Performance.detect(), "balanced", "Apple Silicon retains the desktop core-count floor") + device("Windows", "x64", 2) T.eq(Performance.detect(), "balanced", "dual-core desktop -> balanced") From 455f911e81a1a176615578935e5e430f77275ab2 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:11:40 +0100 Subject: [PATCH 119/138] perf(P9): budget LOW presentation and reuse native UI quads --- src/core/DiscordPresence.lua | 7 + src/core/Game.lua | 6 + src/core/Performance.lua | 32 ++++- src/render/GbcColorCorrection.lua | 2 + src/render/ShaderPresets.lua | 3 + src/render/TextBox.lua | 3 +- src/render/TileRenderer.lua | 9 +- src/render/UiQuad.lua | 24 ++++ src/ui/Gen3BagMenu.lua | 5 +- src/ui/Gen3BoxMenu.lua | 13 +- src/world/OverworldController.lua | 3 +- tests/engine/performance_low_budget.lua | 165 ++++++++++++++++++++++ tools/check_low_tier.py | 49 +++++++ tools/low-tier-check/conf.lua | 7 + tools/low-tier-check/main.lua | 178 ++++++++++++++++++++++++ 15 files changed, 497 insertions(+), 9 deletions(-) create mode 100644 src/render/UiQuad.lua create mode 100644 tests/engine/performance_low_budget.lua create mode 100644 tools/check_low_tier.py create mode 100644 tools/low-tier-check/conf.lua create mode 100644 tools/low-tier-check/main.lua diff --git a/src/core/DiscordPresence.lua b/src/core/DiscordPresence.lua index cc21c85e5..f052a11f0 100644 --- a/src/core/DiscordPresence.lua +++ b/src/core/DiscordPresence.lua @@ -8,6 +8,7 @@ local Json = require("src.link.Json") local Logger = require("src.core.Logger") +local Performance = require("src.core.Performance") local Runtime = require("src.mods.Runtime") local Strings = require("src.core.Strings") @@ -478,6 +479,7 @@ local function activityKey(activity) end local function flush(force) + if Performance.extraCap("discordPresence")==false then return end if not state.enabled or not state.connected then return end local ok = pcall(function() local now = love.timer.getTime() @@ -717,6 +719,7 @@ function DiscordPresence.init(game) end subscribe(game) + if Performance.extraCap("discordPresence")==false then return end if connect() then flush(true) else @@ -734,6 +737,10 @@ function DiscordPresence.init(game) end function DiscordPresence.update(_dt) + if Performance.extraCap("discordPresence")==false then + if state.connected then closeSocket()end + return + end if not state.enabled then return end pcall(function() local now = love.timer.getTime() diff --git a/src/core/Game.lua b/src/core/Game.lua index 9e29487ab..27084d690 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -671,12 +671,14 @@ function Game:update(dt) end do local close = FrameProfile.section(" update: discord") + if Performance.extraCap("discordPresence")~=false then if Performance.hotPathHoist then local ok, Discord = HotModules.optional("src.core.DiscordPresence") if ok and type(Discord) == "table" then pcall(Discord.update, dt) end else pcall(function() require("src.core.DiscordPresence").update(dt) end) end + end close() end -- Keep an already-loaded native GC delegation at its update boundary. @@ -2042,6 +2044,10 @@ function Game:applyOptions(opts) -- default on a normal desktop, and every options.lua predating this -- option) clamps nothing, so it is a no-op for the common case. local caps = require("src.core.Performance").applyOptions(opts) + if Performance.extraCap("discordPresence")==false then + local presence=package.loaded["src.core.DiscordPresence"] + if presence and presence.update then pcall(presence.update,0)end + end if not caps.tilt then require("src.render.Tilt").setLevel(0) end if not caps.gbcfx then require("src.render.GBCFX").setLevel(0) end local Zoom = require("src.render.Zoom") diff --git a/src/core/Performance.lua b/src/core/Performance.lua index 53595fb32..d150a76b7 100644 --- a/src/core/Performance.lua +++ b/src/core/Performance.lua @@ -53,6 +53,8 @@ Performance.worldDrawHoist = true Performance.paletteZoneCache = true -- P6: PCM bulk writes, speculative effect prewarming and DS worker transport. Performance.audioWorkerOptimization = true +-- P9: additional LOW presentation caps, pixel-area AUTO and native UI quads. +Performance.lowTierBudget = true Performance.GC_PAUSE = 120 Performance.GC_STEP_MUL = 300 Performance.GC_BUDGET_SECONDS = 0.0005 @@ -75,7 +77,12 @@ Performance.CAPS = { balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false }, low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 }, } -for _, caps in pairs(Performance.CAPS) do +for name, caps in pairs(Performance.CAPS) do + caps.shaderPresets = name~="low" + caps.colorCorrection = name~="low" + caps.gen3PaletteCycle = name=="low" and "quarter" or "full" + caps.reflections = name~="low" + caps.discordPresence = name~="low" caps.gcPause = Performance.GC_PAUSE caps.gcStepMul = Performance.GC_STEP_MUL caps.gcBudgetSeconds = Performance.GC_BUDGET_SECONDS @@ -115,11 +122,22 @@ end -- multi-core desktop -- the common case, and every existing install whose -- options.lua predates this option -- resolves to HIGH and behaves exactly -- as before. +Performance.AUTO_DESKTOP_PIXELS = 3840 * 2160 +Performance.AUTO_MOBILE_PIXELS = 1920 * 1080 +local function pixelArea() + if not Performance.lowTierBudget then return nil end + local graphics=love and love.graphics + if not (graphics and graphics.getPixelDimensions)then return nil end + local ok,w,h=pcall(graphics.getPixelDimensions) + if ok and type(w)=="number" and type(h)=="number" and w>0 and h>0 + and wPerformance.AUTO_MOBILE_PIXELS then return "low"end return "balanced" end -- Desktop: a dual-core (or single-core) box is the low-end line. if cores and cores <= 2 then + if pixels and pixels>1280*720 then return "low"end return "balanced" end + if pixels and pixels>=Performance.AUTO_DESKTOP_PIXELS then return "balanced"end return "high" end @@ -189,4 +210,13 @@ function Performance.cycle(value, dir) return Performance.TIERS[nextIdx] end +-- Added presentation caps use the live tier without changing the stored choices. +function Performance.extraCap(name) + if not Performance.lowTierBudget then return nil end + return (Performance.CAPS[Performance.tier] or Performance.CAPS.high)[name] +end +function Performance.paletteTick(tick) + if Performance.extraCap("gen3PaletteCycle")=="quarter" then return math.floor(tick/4)*4 end + return tick +end return Performance diff --git a/src/render/GbcColorCorrection.lua b/src/render/GbcColorCorrection.lua index 9250fbfb7..fe48bbdbe 100644 --- a/src/render/GbcColorCorrection.lua +++ b/src/render/GbcColorCorrection.lua @@ -5,6 +5,7 @@ local GameVersion = require("src.core.GameVersion") local PaletteFX = require("src.render.PaletteFX") local Logger = require("src.core.Logger") +local Performance = require("src.core.Performance") local Correction = { mode = "off" } Correction.MODES = { "off", "accurate", "harsh" } @@ -112,6 +113,7 @@ local function resources() end function Correction.active() + if Performance.extraCap("colorCorrection")==false then return false end if failedTarget or Correction.mode == "off" or not GameVersion.isGen2() or PaletteFX.monoMode() then return false end return resources() diff --git a/src/render/ShaderPresets.lua b/src/render/ShaderPresets.lua index 0677e6e7b..1f37c0d5c 100644 --- a/src/render/ShaderPresets.lua +++ b/src/render/ShaderPresets.lua @@ -3,6 +3,7 @@ local Json = require("src.link.Json") local Graph = require("src.render.ShaderGraph") local Logger = require("src.core.Logger") +local Performance = require("src.core.Performance") local Presets = {} Presets.__index = Presets local ROOT, MAX_JSON, MAX_SOURCE = "shader-presets", 16384, 65536 @@ -220,6 +221,7 @@ function Presets:off() self.error = nil end function Presets:active() + if Performance.extraCap("shaderPresets")==false then return false end return self.shader ~= nil or self.graph ~= nil end function Presets:activate(id, params) @@ -353,6 +355,7 @@ function Presets:setParameter(opts, id, n) return nil, "unknown parameter" end function Presets:apply(input, pixelScale) + if Performance.extraCap("shaderPresets")==false then return input end if self.graph then if self.graph:owns(input) then return input diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 7e4e2f7ac..704e3ff9b 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -8,6 +8,7 @@ local Logger = require("src.core.Logger") local Assets = require("src.render.Assets") +local UiQuad = require("src.render.UiQuad") local Font = require("src.render.Font") local UIVisibility = require("src.battle.UIVisibility") local Theme = require("src.ui.Theme") @@ -719,7 +720,7 @@ function TextBox:draw() local seq = { 0, 1, 2, 1 } local frame = math.min(frames - 1, seq[math.floor(self.blink / 9) % 4 + 1]) local iw, ih = self.promptArrowImage:getDimensions() - local quad = love.graphics.newQuad(frame * fw, 0, fw, fh, iw, ih) + local quad = UiQuad.get(self,"prompt",self.promptArrowImage,frame * fw,0,fw,fh,iw,ih) love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(self.promptArrowImage, quad, lastPen, lastY) end diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index c06d6c0a0..6f1fb4b89 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -13,6 +13,8 @@ local Assets = require("src.render.Assets") local Logger = require("src.core.Logger") local Probe = require("src.core.Probe") local PaletteFX = require("src.render.PaletteFX") +local Performance = require("src.core.Performance") +local performancePaletteTicks=setmetatable({}, {__mode="k"}) local TileRenderer = {} TileRenderer.__index = TileRenderer @@ -1181,7 +1183,7 @@ function TileRenderer.gen3SheetsFor(tilesetDef, data, layout) }, layout) -- Legacy records have no paletteCycles: their original bake stays intact. if primary.paletteCycles ~= nil or (tiles.secondary and tiles.secondary.paletteCycles ~= nil) then - tiles:setPaletteTicks(animFrame) + tiles:setPaletteTicks(Performance.paletteTick(animFrame)) end -- ---- WHAT MOVES, AND WHERE ITS OTHER PICTURES GO ------------------------ @@ -1337,7 +1339,10 @@ function TileRenderer:syncGen3Palettes() if not tiles or (tiles.primary.paletteCycles == nil and (not tiles.secondary or tiles.secondary.paletteCycles == nil) and not tiles.paletteState) then return end - local changed = tiles:setPaletteTicks(animFrame) + local tick=Performance.paletteTick(animFrame) + if performancePaletteTicks[record]==tick then return end + performancePaletteTicks[record]=tick + local changed = tiles:setPaletteTicks(tick) if changed and record.recolor then record.recolor() record.paletteRevision = (record.paletteRevision or 0) + 1 diff --git a/src/render/UiQuad.lua b/src/render/UiQuad.lua new file mode 100644 index 000000000..114faaaae --- /dev/null +++ b/src/render/UiQuad.lua @@ -0,0 +1,24 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native immediate draws only; records and hook arguments never contain this cache. +local Performance = require("src.core.Performance") +local M = {} +local owners = setmetatable({}, { __mode = "k" }) +function M.get(owner, slot, image, x, y, w, h, iw, ih) + if not Performance.lowTierBudget then + return love.graphics.newQuad(x, y, w, h, iw, ih) + end + local record = owners[owner] + if not record then + record = {} + owners[owner] = record + end + local q = record[slot] + if not q then + q = love.graphics.newQuad(x, y, w, h, iw, ih) + record[slot] = q + else + q:setViewport(x, y, w, h, iw, ih) + end + return q +end +return M diff --git a/src/ui/Gen3BagMenu.lua b/src/ui/Gen3BagMenu.lua index 76565106d..9a7cfe68e 100644 --- a/src/ui/Gen3BagMenu.lua +++ b/src/ui/Gen3BagMenu.lua @@ -54,6 +54,7 @@ -- the item's own cartridge index. local Assets = require("src.render.Assets") +local UiQuad = require("src.render.UiQuad") local Bag = require("src.inventory.Bag") local Font = require("src.render.Font") local Logger = require("src.core.Logger") @@ -1000,7 +1001,7 @@ function Gen3BagMenu:bagFrame() local fh = math.floor(tonumber(record.frameHeight) or 64) local index = math.min(frames, self.pocket) - 1 local iw, ih = image:getDimensions() - return image, love.graphics.newQuad(index * fw, 0, fw, fh, iw, ih), fw, fh + return image, UiQuad.get(self,"pocket",image,index * fw,0,fw,fh,iw,ih), fw, fh end -- The screen's own background, in the palette this save's player carries. @@ -1038,7 +1039,7 @@ function Gen3BagMenu:itemIcon(id, forcedIndex) local size = math.max(1, math.floor(tonumber(rec.size) or 24)) local cols = math.max(1, math.floor(tonumber(rec.cols) or 1)) local iw, ih = img:getDimensions() - return img, love.graphics.newQuad((index % cols) * size, + return img, UiQuad.get(self,"item",img,(index % cols) * size, math.floor(index / cols) * size, size, size, iw, ih), size end diff --git a/src/ui/Gen3BoxMenu.lua b/src/ui/Gen3BoxMenu.lua index d17cf2180..d0c7d83a3 100644 --- a/src/ui/Gen3BoxMenu.lua +++ b/src/ui/Gen3BoxMenu.lua @@ -47,6 +47,8 @@ local Strings = require("src.core.Strings") local TextBox = require("src.render.TextBox") local Theme = require("src.ui.Theme") +local UiQuad = require("src.render.UiQuad") +local Performance = require("src.core.Performance") local Gen3BoxMenu = {} Gen3BoxMenu.__index = Gen3BoxMenu Gen3BoxMenu.isOpaque = true @@ -685,12 +687,16 @@ function Gen3BoxMenu:drawIcon(mon, cx, cy) and (math.floor(((self.t or 0) % (ICON_PERIOD * frames)) / ICON_PERIOD) % frames) or 0 self.quads = self.quads or {} + local quad + if Performance.lowTierBudget then quad=UiQuad.get(self,"box-icon",img,0,frame*frameH,iw,frameH,iw,ih) + else local key = ("%d:%d:%d"):format(iw, frameH, frame) - local quad = self.quads[key] + quad = self.quads[key] if not quad then quad = love.graphics.newQuad(0, frame * frameH, iw, frameH, iw, ih) self.quads[key] = quad end + end love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(img, quad, math.floor(cx - iw / 2), math.floor(cy - frameH / 2)) @@ -805,6 +811,9 @@ function Gen3BoxMenu:frlgImage(key) end function Gen3BoxMenu:frlgQuad(img, x, y, w, h) + if Performance.lowTierBudget then + return UiQuad.get(self,"frlg",img,x,y,w,h,img:getDimensions()) + end self._quads = self._quads or {} local key = ("%s:%d:%d:%d:%d"):format(tostring(img), x, y, w, h) local q = self._quads[key] @@ -1223,7 +1232,7 @@ function Gen3BoxMenu:drawParty() local panel = self:partyPanel() if panel then local pw, ph = panel:getDimensions() - local quad = love.graphics.newQuad(0, 0, math.min(pw, PARTY.w), + local quad = UiQuad.get(self,"party-panel",panel,0,0,math.min(pw, PARTY.w), math.min(ph, PARTY.h), pw, ph) love.graphics.draw(panel, quad, PARTY.x, PARTY.y) else diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 1b6130a36..2ce73ec88 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -18245,7 +18245,7 @@ function OverworldState:drawWorld() reflectors.busy = true local oldReflectors, reflectorCount = reflectors.n, 0 for _, e in ipairs(self.entities) do - if not e.hidden and e.drawReflection then + if Performance.extraCap("reflections")~=false and not e.hidden and e.drawReflection then e.reflects = self:reflectionSearch(e) if e.reflects then e.reflectStill = self:stillReflection(e.reflects) @@ -18621,6 +18621,7 @@ function OverworldState:drawWorld() closeFx() end + -- The cursor the DECORATE row hands the player: the decoration's own -- footprint, outlined, blinking, anchored at its bottom-left the way the -- save stores it. diff --git a/tests/engine/performance_low_budget.lua b/tests/engine/performance_low_budget.lua new file mode 100644 index 000000000..067059d77 --- /dev/null +++ b/tests/engine/performance_low_budget.lua @@ -0,0 +1,165 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +love = require("tests.love_stub") +local P = require("src.core.Performance") +local originalLove, originalJit = love, jit +local function device(os, arch, cores, w, h) + love = { + system = { + getOS = function() + return os + end, + getProcessorCount = function() + return cores + end, + }, + graphics = { + getPixelDimensions = function() + return w, h + end, + }, + } + jit = { arch = arch } +end +device("OS X", "arm64", 8, 3024, 1964) +T.eq(P.detect(), "high", "desktop ARM keeps native Retina policy") +device("Windows", "x64", 8, 3840, 2160) +T.eq(P.detect(), "balanced", "4K desktop fill receives a budget") +device("Android", "arm64", 8, 2560, 1440) +T.eq(P.detect(), "low", "large physical mobile frame receives LOW") +P.lowTierBudget = false +T.eq(P.detect(), "balanced", "rollback restores old mobile AUTO") +P.lowTierBudget = true +device("Windows", "x64", 2, 1920, 1080) +T.eq(P.detect(), "low", "large dual-core display receives LOW") +device("Windows", "x64", 2, 640, 480) +T.eq(P.detect(), "balanced", "small dual-core display keeps previous tier") +device("OS X", "arm64", 8, 0 / 0, 480) +T.eq(P.detect(), "high", "invalid pixel dimensions fall back") +love, jit = originalLove, originalJit +P.tier = "low" +for _, key in ipairs({ "shaderPresets", "colorCorrection", "reflections", "discordPresence" }) do + T.eq(P.extraCap(key), false, "LOW applies " .. key) +end +for tick = 0, 31 do + T.eq(P.paletteTick(tick), math.floor(tick / 4) * 4, "quarter palette holds sampled tick") +end +P.lowTierBudget = false +T.eq(P.extraCap("reflections"), nil, "rollback removes added live caps") +T.eq(P.paletteTick(7), 7, "rollback restores full palette ticks") +P.lowTierBudget = true +local Presets = require("src.render.ShaderPresets").new() +local input = {} +Presets.shader = {} +Presets.entry = { id = "owned" } +T.eq(Presets:active(), false, "LOW masks shader activity") +T.eq(Presets:apply(input, 1), input, "LOW returns untouched preset input") +T.eq(Presets.entry.id, "owned", "mask keeps selected preset") +P.tier = "high" +T.check(Presets:active(), "raising tier restores selected shader") +local C = require("src.render.GbcColorCorrection") +C.setMode("accurate") +P.tier = "low" +T.eq(C.active(), false, "LOW avoids correction resources") +T.eq(C.apply(input), input, "LOW returns original correction input") +T.eq(C.mode, "accurate", "LOW keeps selected correction mode") +local TR = require("src.render.TileRenderer") +local ticks, recolors = {}, 0 +local tiles = { + primary = { paletteCycles = {} }, + setPaletteTicks = function(_, tick) + ticks[#ticks + 1] = tick + return true + end, +} +local renderer = + setmetatable({ gen3 = { + tiles = tiles, + recolor = function() + recolors = recolors + 1 + end, + } }, TR) +for _ = 1, 12 do + TR.tick(1 / 60) + renderer:syncGen3Palettes() +end +T.same(ticks, { 0, 4, 8, 12 }, "actual sheet refresh samples held native clock without slowing it") +T.eq(recolors, 4, "quarter refresh does not recolor every tick") +T.eq(TR.animFrame(), 12, "native tile animation clock is unchanged") +P.tier = "high" +TR.tick(1 / 60) +renderer:syncGen3Palettes() +T.eq(ticks[#ticks], 13, "raising tier resumes current full-rate palette") +P.tier = "low" +P.lowTierBudget = false +TR.tick(1 / 60) +renderer:syncGen3Palettes() +T.eq(ticks[#ticks], 14, "rollback restores full palette refresh at LOW") +P.lowTierBudget = true +local Version = require("src.core.GameVersion") +Version.set("emerald") +local f = require("tests.world_draw.fixture").new(8) +P.tier = "high" +f.world:drawWorld() +local high = f.trace() +f.reset() +P.tier = "low" +f.world:drawWorld() +local function essentials(trace) + local rows = {} + for _, row in ipairs(trace) do + if not row:match("^reflection:") and not row:match("^drawReflectionCover:") then + rows[#rows + 1] = row + end + end + return rows +end +T.same(essentials(f.trace()), essentials(high), "LOW preserves entity, terrain, ripple and FX order/coordinates") +for _, row in ipairs(f.trace()) do + T.check(not row:match("^reflection:"), "LOW skips native actor reflections") +end +f.reset() +P.tier = "high" +f.world:drawWorld() +T.same(f.trace(), high, "raising tier restores full world reflection trace") +local Discord = require("src.core.DiscordPresence") +local state +for i = 1, 100 do + local name, value = debug.getupvalue(Discord.update, i) + if name == "state" then + state = value + break + end +end +T.check(state ~= nil, "presence state seam exists") +state.enabled = true +state.connected = true +state.socket = nil +P.tier = "low" +Discord.update(0) +T.eq(state.connected, false, "LOW retires connected IPC without polling") +local allocations, viewports = 0, {} +love.graphics.newQuad = function(x, y, w, h, iw, ih) + allocations = allocations + 1 + local q = {} + function q:setViewport(...) + self.viewport = { ... } + viewports[#viewports + 1] = self.viewport + end + q:setViewport(x, y, w, h, iw, ih) + return q +end +local Ui = require("src.render.UiQuad") +local a, b = {}, {} +local qa = Ui.get(a, "slot", nil, 0, 0, 8, 8, 64, 64) +T.eq(Ui.get(a, "slot", nil, 8, 0, 16, 8, 128, 64), qa, "owner slot reuses its quad") +T.same(qa.viewport, { 8, 0, 16, 8, 128, 64 }, "viewport tracks geometry and source extent") +T.check(Ui.get(b, "slot", nil, 0, 0, 8, 8, 64, 64) ~= qa, "owners do not share scratch quads") +T.check(Ui.get(a, "other", nil, 0, 0, 8, 8, 64, 64) ~= qa, "draw sites do not share scratch quads") +T.eq(allocations, 3, "stable owner/site has bounded quad allocation") +P.lowTierBudget = false +T.check(Ui.get(a, "slot", nil, 0, 0, 8, 8, 64, 64) ~= qa, "rollback restores fresh native quads") +P.lowTierBudget = true +P.tier = "high" +T.finish("LOW presentation caps, pixel policy and private UI quads") diff --git a/tools/check_low_tier.py b/tools/check_low_tier.py new file mode 100644 index 000000000..6c0b89b7f --- /dev/null +++ b/tools/check_low_tier.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Run owned native UI quad draws in an isolated QA profile.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--love', type=Path, required=True) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(args.qa_tools or root / 'tools')) + from qa_storage import qa_source, qa_cache, qa_profile, qa_run + qa_source(root) + qa_source(args.love.resolve()) + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True, exist_ok=True) + names = ['src/core/Performance.lua', 'src/render/UiQuad.lua', 'src/render/TextBox.lua', + 'src/ui/Gen3BagMenu.lua', 'src/ui/Gen3BoxMenu.lua', + 'tools/low-tier-check/main.lua', 'tools/low-tier-check/conf.lua'] + hashes = {n: hashlib.sha256((root / n).read_bytes()).hexdigest() for n in names} + identity = f'oasc-low-tier-qa-{time.time_ns()}' + qa_profile(Path.home() / 'Library/Application Support/LOVE' / identity) + result = qa_run([str(args.love.resolve()), str(root / 'tools/low-tier-check')], + cwd=root, env=dict(os.environ, LOW_TIER_QA_GUARDED='1', + LOW_TIER_QA_ROOT=str(root), LOW_TIER_QA_OUT=str(args.report.parent.resolve()), POKEPORT_IDENTITY=identity), + capture_output=True, text=True, timeout=90) + args.report.with_suffix('.log').write_text(result.stdout + result.stderr) + rows = [s.removeprefix('LOW_TIER_RESULT ') for s in result.stdout.splitlines() + if s.startswith('LOW_TIER_RESULT ')] + if result.returncode or len(rows) != 1: + raise RuntimeError('native LOW tier probe failed; see its log') + if any(hashlib.sha256((root / n).read_bytes()).hexdigest() != h for n, h in hashes.items()): + raise RuntimeError('measured source changed during the probe') + report = json.loads(rows[0]) + report.update(sha256=hashes, playerProfilesUsed=False) + args.report.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/tools/low-tier-check/conf.lua b/tools/low-tier-check/conf.lua new file mode 100644 index 000000000..c5ca95861 --- /dev/null +++ b/tools/low-tier-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv("LOW_TIER_QA_GUARDED") == "1", "use the guarded render-budget checker") + t.identity = assert(os.getenv("POKEPORT_IDENTITY")) + t.window.title = "LOW tier qualification" + t.window.width, t.window.height, t.window.vsync = 640, 480, 0 + t.modules.audio = false +end diff --git a/tools/low-tier-check/main.lua b/tools/low-tier-check/main.lua new file mode 100644 index 000000000..bb4ff6867 --- /dev/null +++ b/tools/low-tier-check/main.lua @@ -0,0 +1,178 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local root = assert(os.getenv("LOW_TIER_QA_ROOT")) +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +local P = require("src.core.Performance") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local Assets = require("src.render.Assets") +local Bag = require("src.ui.Gen3BagMenu") +local Box = require("src.ui.Gen3BoxMenu") +local Text = require("src.render.TextBox") +local phase, frame, total, allocations = 1, 0, 0, 0 +local function art(w, h) + local data = love.image.newImageData(w, h) + data:mapPixel(function(x, y) + return ((x * 13 + y * 3) % 256) / 255, ((x * 7 + y * 17) % 256) / 255, ((x * 19 + y * 11) % 256) / 255, 1 + end) + local image = love.graphics.newImage(data) + image:setFilter("nearest", "nearest") + data:release() + return image +end +local sheet, icon, arrow = art(128, 64), art(16, 64), art(30, 12) +Assets.image = function(path) + if path == "arrow" then + return arrow + end + return sheet +end +local bag = setmetatable( + { + pocket = 1, + game = { + save = { player = {} }, + data = { + items = {}, + constants = { + gen3Bag = { male = "owned", frames = 8, frameWidth = 16, frameHeight = 16 }, + gen3ItemIcons = { image = "owned", size = 16, cols = 8, count = 32 }, + }, + }, + }, + }, + Bag +) +local box = setmetatable({ + t = 0, + iconFor = function() + return icon, 16 + end, +}, Box) +local text = setmetatable( + { + boxTx = 0, + boxTy = 0, + boxTw = 20, + boxTh = 6, + drawFrame = false, + shown = { {} }, + line1Y = 184, + line2Y = 200, + textX = 12, + waiting = true, + promptArrowAtPen = true, + promptArrow = { path = "arrow", width = 10, height = 12, frames = 3 }, + promptArrowImage = arrow, + blink = 0, + }, + Text +) +local target = love.graphics.newCanvas(320, 240, { dpiscale = 1 }) +local originalQuad = love.graphics.newQuad +love.graphics.newQuad = function(...) + allocations = allocations + 1 + return originalQuad(...) +end +local function draw() + love.graphics.setCanvas(target) + love.graphics.clear(0, 0, 0, 1) + love.graphics.setColor(1, 1, 1, 1) + for i = 0, 63 do + bag.pocket = i % 8 + 1 + local img, q = bag:bagFrame() + love.graphics.draw(img, q, (i % 16) * 20, math.floor(i / 16) * 20) + img, q = bag:itemIcon(nil, i % 32) + love.graphics.draw(img, q, (i % 16) * 20, 90 + math.floor(i / 16) * 20) + box.t = i * 0.1 + box:drawIcon({}, (i % 16) * 20 + 8, 166) + local crop = box:frlgQuad(sheet, (i % 8) * 16, 16, 16, 16) + love.graphics.draw(sheet, crop, (i % 16) * 20, 210) + end + text.blink = frame % 36 + text:draw() + love.graphics.setCanvas() +end +local checks, results = 0, {} +local function check(ok, msg) + assert(ok, msg) + checks = checks + 1 +end +local function qualify() + for _, tick in ipairs({ 0, 9, 18, 27 }) do + frame = tick + P.lowTierBudget = false + draw() + local before = target:newImageData():getString() + P.lowTierBudget = true + draw() + check(target:newImageData():getString() == before, "native UI pixels changed after quad reuse") + end + local owner = require("src.render.UiQuad") + local a, b = {}, {} + local qa = owner.get(a, "test", sheet, 0, 0, 16, 16, 128, 64) + local qb = owner.get(b, "test", sheet, 16, 16, 16, 16, 128, 64) + check(qa ~= qb, "native quad owner storage overlaps") +end +function love.errorhandler(message) + print(debug.traceback(tostring(message), 2)) + return function() + return 1 + end +end +function love.run() + qualify() + frame, allocations = 0, 0 + return function() + love.event.pump() + for e in love.event.poll() do + if e == "quit" then + return 1 + end + end + P.lowTierBudget = phase == 2 + if frame == 120 then + Profile.setEnabled(true) + allocations = 0 + end + local close = Profile.section("native UI quads (owned art)") + local began = love.timer.getTime() + draw() + if frame >= 120 then + total = total + love.timer.getTime() - began + end + close() + local fence = target:newImageData(1, 1, 0, 0, 1, 1) + fence:release() + if frame >= 120 then + Profile.frame() + end + frame = frame + 1 + if frame == 240 then + results[phase] = { + mode = phase == 1 and "legacy" or "cached", + frames = 120, + warmupFrames = 120, + meanMs = total / 120 * 1000, + quadAllocations = allocations, + } + if phase == 2 then + check( + results[1].quadAllocations > 0 and results[2].quadAllocations == 0, + "native quad allocations were not eliminated after warmup" + ) + print( + "LOW_TIER_RESULT " + .. Json.encode({ + phases = results, + checks = checks, + renderer = love.graphics.getRendererInfo(), + scope = "Actual bag, box and textbox draw producers with owned artwork; CPU draw submission, no ROM or full-game FPS", + }) + ) + return 0 + end + Profile.setEnabled(false) + phase, frame, total, allocations = 2, 0, 0, 0 + end + end +end From f8e04669fe0cef8c9a3e86a104fb78eb8a90cb38 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:25:30 +0100 Subject: [PATCH 120/138] Document P9 policy, dependency reuse and native validation --- docs/low-tier-p9.md | 101 ++++++++++++++++++++++++++++++++ docs/qa/low-tier-p9.json | 122 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 docs/low-tier-p9.md create mode 100644 docs/qa/low-tier-p9.json diff --git a/docs/low-tier-p9.md b/docs/low-tier-p9.md new file mode 100644 index 000000000..599a127ef --- /dev/null +++ b/docs/low-tier-p9.md @@ -0,0 +1,101 @@ +# LOW presentation budget and UI quads (P9) + +`require("src.core.Performance").lowTierBudget` defaults to `true`. Set it to +`false` to disable P9 independently: the previous AUTO heuristic and presentation +policy return, palette refresh uses full native ticks, and the affected UI paths +use their previous quad behavior. Existing P1–P8 flags remain independent. + +## Additive caps and live behavior + +| Cap | HIGH / BALANCED | LOW | +| --- | --- | --- | +| shaderPresets | true | false | +| colorCorrection | true | false | +| gen3PaletteCycle | full | quarter | +| reflections | true | false | +| discordPresence | true | false | + +`Performance.extraCap(name)` reads these added live caps and returns nil with P9 +disabled. Existing CAPS rows, methods and identities remain. Shader preset entries, +parameters and the selected correction mode stay intact while their actual passes +are masked; raising the tier restores them. Ordinary mod-owned pipelines remain +available. Game's Discord section skips work at LOW; applying LOW retires an +already loaded connection. Presence initialization keeps the same subscriptions, +but skips connection, flush and polling under the cap. Raising the tier resumes +through the existing reconnect path. No real IPC/network operation is used in QA. + +The ordinary native world pass and owned field capture omit actor reflections +under LOW while retaining terrain, actors, ripples and FX. Other tiers restore the +same draw trace. Existing entity records are not replaced with cache records. + +Gen-3 tile sheets sample palette ticks at floor(nativeTick/4)*4 under LOW. The +native tile animation clock keeps advancing at full speed; this reduces visual +refresh frequency without slowing the animation or changing gameplay. A private +weak record table avoids repeated sampling/recoloring during held ticks. Explicit +public palette-reader queries retain their requested ticks. + +## Physical pixel policy + +AUTO still recognizes ARM handhelds, mobile OS and the desktop core-count floor. +With usable `love.graphics.getPixelDimensions` data it also selects LOW for mobile +frames above 1920×1080 pixels and dual-core frames above 1280×720, and BALANCED for +other desktop frames at or above 3840×2160. These are area comparisons, so portrait +and landscape agree. Missing/invalid dimensions preserve the earlier decision. +Explicit HIGH/BALANCED/LOW selections bypass detection. Apple Silicon retains the +existing desktop exemption, including ordinary Retina-sized windows. Detection is +resolved through the existing applyOptions lifecycle, not a per-frame policy loop. + +## Native UI reuse + +`src/render/UiQuad.lua` owns weak, private records by menu/textbox instance and draw +site. Immediate native draws reuse a Quad and update its viewport, including source +extent. Bag pocket/item images, box icons/FRLG crops/party panel and the animated +text prompt use this path. Separate owners/sites do not share scratch Quads. +Existing box-cache fields and rollback paths remain. No hook payload, asset record, +option-row signature or require path changes; `ui.options.rows` stays in place. + +## Reproduction and measurements + +`tests/engine/performance_low_budget.lua` exercises real policy consumers: native +palette-sheet cadence, preset and correction masks with retained selections, the +world producer's draw trace, live restoration, a connection-state double, source +size/viewport changes and owner isolation. It passes 86 checks. Existing tier, +correction and world-hot-path regressions also pass. + +Run `python3 tools/check_low_tier.py --love /absolute/path/to/love --report +.local/qa/low-tier/profile.json` on one shell line. The guarded runner uses an +isolated identity and owned asymmetric artwork. Actual bag, box and textbox draw +producers render four exact full-RGBA comparisons across prompt animation phases; +separate native ownership/allocation checks bring each backend to six scoped +checks. The loop draws 64 bag/item/icon/crop groups plus the prompt per frame, +with 120 warmup and 120 measured frames in each flag mode. + +| Desktop macOS ARM64 | Previous UI draws | Cached UI draws | +| --- | ---: | ---: | +| LÖVE 11.5 OpenGL, FrameProfile CPU submission | 0.635 ms | 0.521 ms | +| LÖVE 12 Metal, FrameProfile CPU submission | 0.291 ms | 0.173 ms | +| New Quads / 120 measured frames | 15,480 | 0 | + +An equal one-pixel GPU completion fence runs outside the CPU section. Presentation, +ROM artwork, ordinary game/menu usage frequency and full-game FPS are outside this +fixture. Mobile/handheld and long-session measurements are unavailable. These UI +numbers do not quantify the savings from disabled presets, correction, reflection +or presence. Source hashes and acceptance are in [low-tier-p9.json](qa/low-tier-p9.json). + +The public stack reuses #89 and #100/#101/#125 for the existing color/shader +features, #259 for native palette programs/caches, plus the desktop ARM policy already offered in #243. P9 adds their LOW +policy rather than republishing those features as a separate implementation. + +Integrated acceptance: 480/480 engine suites, SDK 3/3, gallery 253/253, +200 API-3 comparisons / 400 isolated workers and all 17 unchanged external +fixture entrypoints in complete mode pass. Public candidate: 167/178 engine +suites versus 166/177 on composed baseline 3bd33cb3, with exactly the same +29 FAIL lines across eleven inherited suites. Public SDK 3/3, gallery 253, +API-3 48/96 and all 17 fixture entrypoints pass; its new 86-check suite passes. + +The public Metal branch separately passes the same six native checks. Its UI CPU +measurement is 0.386 → 0.222 ms during concurrent acceptance work; its own source +hashes are retained separately. Its TextBox lacks the fork's unrelated Gen-2 prose +substitution repair, and its world has no owned field-capture API; P9 does not add +that separate API. Existing correction/preset merge order, private native viewport +and window-UI guard, sheet pinning and profiler sections are retained in the stack. diff --git a/docs/qa/low-tier-p9.json b/docs/qa/low-tier-p9.json new file mode 100644 index 000000000..777cf8d6b --- /dev/null +++ b/docs/qa/low-tier-p9.json @@ -0,0 +1,122 @@ +{ + "runtimeCommit": "455f911e81a1a176615578935e5e430f77275ab2", + "native": { + "forkMetal": { + "renderer": "Metal", + "phases": [ + { + "warmupFrames": 120, + "meanMs": 0.29062048611111596, + "quadAllocations": 15480, + "mode": "legacy", + "frames": 120 + }, + { + "warmupFrames": 120, + "meanMs": 0.17250069444444496, + "quadAllocations": 0, + "mode": "cached", + "frames": 120 + } + ], + "checks": 6, + "scope": "Actual bag, box and textbox draw producers with owned artwork; CPU draw submission, no ROM or full-game FPS", + "sha256": { + "src/core/Performance.lua": "def2c6b3d5e93ce8a10c10f03b40f30ac05997a1330f3ed49b8d1395399b4877", + "src/render/UiQuad.lua": "8d7715e0bbfc21d2408c6652def9e01474a7dedcbc5bc9e15f0cf39b60aa73b3", + "src/render/TextBox.lua": "ba2c8e0203d6b039d88e3224186722c1065894488896f3dba39a9b4572240d8c", + "src/ui/Gen3BagMenu.lua": "98c15a587096e342f052605dc2dac3b03ebb420b39812dfd9a45316fc7701bae", + "src/ui/Gen3BoxMenu.lua": "b56c3829b9f82d4c01d278ef6745e8cec3a3cc7ce111151ecf5c3db0c72478fd", + "tools/low-tier-check/main.lua": "79600f553d50494af156caeb3351448180b8e869281f1b60e55c65c5cf42472b", + "tools/low-tier-check/conf.lua": "322a7089a908d54dad4c6b13ccad4fec62400433c436e54eed401930ada7e44b" + }, + "playerProfilesUsed": false + }, + "forkOpenGL": { + "scope": "Actual bag, box and textbox draw producers with owned artwork; CPU draw submission, no ROM or full-game FPS", + "renderer": "OpenGL", + "phases": [ + { + "warmupFrames": 120, + "meanMs": 0.6349649305555677, + "quadAllocations": 15480, + "mode": "legacy", + "frames": 120 + }, + { + "warmupFrames": 120, + "meanMs": 0.5209718749999889, + "quadAllocations": 0, + "mode": "cached", + "frames": 120 + } + ], + "checks": 6, + "sha256": { + "src/core/Performance.lua": "def2c6b3d5e93ce8a10c10f03b40f30ac05997a1330f3ed49b8d1395399b4877", + "src/render/UiQuad.lua": "8d7715e0bbfc21d2408c6652def9e01474a7dedcbc5bc9e15f0cf39b60aa73b3", + "src/render/TextBox.lua": "ba2c8e0203d6b039d88e3224186722c1065894488896f3dba39a9b4572240d8c", + "src/ui/Gen3BagMenu.lua": "98c15a587096e342f052605dc2dac3b03ebb420b39812dfd9a45316fc7701bae", + "src/ui/Gen3BoxMenu.lua": "b56c3829b9f82d4c01d278ef6745e8cec3a3cc7ce111151ecf5c3db0c72478fd", + "tools/low-tier-check/main.lua": "79600f553d50494af156caeb3351448180b8e869281f1b60e55c65c5cf42472b", + "tools/low-tier-check/conf.lua": "322a7089a908d54dad4c6b13ccad4fec62400433c436e54eed401930ada7e44b" + }, + "playerProfilesUsed": false + }, + "publicMetal": { + "checks": 6, + "scope": "Actual bag, box and textbox draw producers with owned artwork; CPU draw submission, no ROM or full-game FPS", + "renderer": "Metal", + "phases": [ + { + "quadAllocations": 15480, + "frames": 120, + "mode": "legacy", + "warmupFrames": 120, + "meanMs": 0.38583715277779407 + }, + { + "quadAllocations": 0, + "frames": 120, + "mode": "cached", + "warmupFrames": 120, + "meanMs": 0.22241388888885977 + } + ], + "sha256": { + "src/core/Performance.lua": "def2c6b3d5e93ce8a10c10f03b40f30ac05997a1330f3ed49b8d1395399b4877", + "src/render/UiQuad.lua": "8d7715e0bbfc21d2408c6652def9e01474a7dedcbc5bc9e15f0cf39b60aa73b3", + "src/render/TextBox.lua": "6ccdc8b5b4622502e3b3036a935d98f9e2ca14fbb37c0d92a0ffe7a2920732e2", + "src/ui/Gen3BagMenu.lua": "98c15a587096e342f052605dc2dac3b03ebb420b39812dfd9a45316fc7701bae", + "src/ui/Gen3BoxMenu.lua": "b56c3829b9f82d4c01d278ef6745e8cec3a3cc7ce111151ecf5c3db0c72478fd", + "tools/low-tier-check/main.lua": "79600f553d50494af156caeb3351448180b8e869281f1b60e55c65c5cf42472b", + "tools/low-tier-check/conf.lua": "322a7089a908d54dad4c6b13ccad4fec62400433c436e54eed401930ada7e44b" + }, + "playerProfilesUsed": false + } + }, + "acceptance": { + "enginePassed": 167, + "engineSuites": 178, + "baselinePassed": 166, + "baselineSuites": 177, + "inheritedFailingSuites": 11, + "unchangedFailureLines": 29, + "baselineCommit": "3bd33cb3fd2a813ff9cd7c6289f7e89fc8bc5b48", + "sdkSuites": 3, + "galleryChecks": 253, + "api3Comparisons": 48, + "isolatedApi3Workers": 96, + "externalFixtureEntrypoints": 17, + "externalFixturePhase": "complete", + "focusedChecks": 86, + "head": "455f911e81a1a176615578935e5e430f77275ab2", + "exitCodes": { + "engine": 1, + "sdk": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + } + } +} From 6edaa8295c5da7b0b59c00fc2ff56b51acaffe78 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:20:15 +0200 Subject: [PATCH 121/138] test: align updater fixtures with Gen2 release assets --- tests/engine/update_check_tests.lua | 28 ++++++++++++++++++++------- tests/engine/update_tests.lua | 30 +++++++++++++++++++++-------- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/tests/engine/update_check_tests.lua b/tests/engine/update_check_tests.lua index 698c5c9f7..4df8fec72 100644 --- a/tests/engine/update_check_tests.lua +++ b/tests/engine/update_check_tests.lua @@ -13,22 +13,36 @@ local Json = require("src.link.Json") -- releaseUrl is the fixed public landing page the UI links on needs_full eq(Check.releaseUrl(), - "https://github.com/bryanthaboi/gen1recomp/releases/latest", + "https://github.com/UNDERdecoded/Gen2Recomped/releases/latest", "releaseUrl points at the repo's latest release") -- parseRelease: a well-formed release with the .love payload and its sums local body = Json.encode({ tag_name = "v1.4.2", assets = { - { name = "gen1recomp-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, - { name = "gen1recomp-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, + { name = "Gen2Recomped-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, + { name = "Gen2Recomped-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, { name = "sha256sums.txt", browser_download_url = "http://x/sums", size = 99 }, }, }) +-- A same-version Gen1 payload is not a fallback for a missing Gen2 asset. +local foreign = Check.parseRelease(Json.encode({tag_name="v1.4.2", assets={ + {name="gen1recomp-1.4.2.love",browser_download_url="http://x/foreign",size=88}, + {name="Gen2Recomped-1.4.2-macos.zip",browser_download_url="http://x/zip",size=99}, +}})) +check(foreign ~= nil, "a release without a matching game payload still parses") +eq(foreign.payload,nil,"Gen1 and platform archives never substitute for the Gen2 payload") +local competing = Check.parseRelease(Json.encode({tag_name="v1.4.2", assets={ + {name="gen1recomp-1.4.2.love",browser_download_url="http://x/foreign",size=88}, + {name="Gen2Recomped-1.4.2.love",browser_download_url="http://x/correct",size=123}, +}})) +check(competing.payload ~= nil,"exact Gen2 payload wins among same-version assets") +eq(competing.payload and competing.payload.url,"http://x/correct","foreign game asset is ignored even when listed first") + local rel = Check.parseRelease(body) check(rel ~= nil, "parseRelease accepts a valid release") eq(rel.version, "1.4.2", "leading v stripped from tag_name") -eq(rel.payloadName, "gen1recomp-1.4.2.love", "payload name derived from version") +eq(rel.payloadName, "Gen2Recomped-1.4.2.love", "payload name derived from version") eq(rel.payload.url, "http://x/love", "payload asset url picked") eq(rel.payload.size, 12345, "payload asset size picked") eq(rel.sums.url, "http://x/sums", "sums asset url picked") @@ -50,13 +64,13 @@ eq(Check.parseRelease(Json.encode({ foo = 1 })), nil, "missing tag_name rejected -- parseSums: shasum -a 256 format, tolerating the '*' binary marker, a './' -- prefix and CRLF line endings; unrelated lines are skipped local sums = - "aaaa1111 gen1recomp-1.4.2.love\n" .. + "aaaa1111 Gen2Recomped-1.4.2.love\n" .. "BBBB2222 *./sha256sums.txt\r\n" .. "not a checksum line\n" local map = Check.parseSums(sums) -eq(map["gen1recomp-1.4.2.love"], "aaaa1111", "bare-name sum parsed") +eq(map["Gen2Recomped-1.4.2.love"], "aaaa1111", "bare-name sum parsed") eq(map["sha256sums.txt"], "bbbb2222", "* marker and ./ prefix stripped, lowered") -eq(Check.parseSums(sums, "gen1recomp-1.4.2.love"), "aaaa1111", "targeted lookup returns the hash") +eq(Check.parseSums(sums, "Gen2Recomped-1.4.2.love"), "aaaa1111", "targeted lookup returns the hash") eq(Check.parseSums(sums, "missing.love"), nil, "targeted lookup misses cleanly") -- pickAsset guards a non-table assets field diff --git a/tests/engine/update_tests.lua b/tests/engine/update_tests.lua index 9bf67171e..d150e43f6 100644 --- a/tests/engine/update_tests.lua +++ b/tests/engine/update_tests.lua @@ -193,11 +193,11 @@ end -- --------------------------------------------------------------------------- local assets = { - { name = "gen1recomp-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, - { name = "gen1recomp-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, + { name = "Gen2Recomped-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, + { name = "Gen2Recomped-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, { name = "sha256sums.txt", browser_download_url = "http://x/sums", size = 99 }, } -local picked = Check.pickAsset(assets, "gen1recomp-1.4.2.love") +local picked = Check.pickAsset(assets, "Gen2Recomped-1.4.2.love") check(picked ~= nil, "pickAsset finds an asset by exact name") eq(picked.url, "http://x/love", "pickAsset returns the download url") eq(picked.size, 12345, "pickAsset returns the numeric size") @@ -223,10 +223,24 @@ local body = Json.encode({ tag_name = "v1.4.2", assets = assets, }) +-- A same-version Gen1 payload is not a fallback for a missing Gen2 asset. +local foreign = Check.parseRelease(Json.encode({tag_name="v1.4.2", assets={ + {name="gen1recomp-1.4.2.love",browser_download_url="http://x/foreign",size=88}, + {name="Gen2Recomped-1.4.2-macos.zip",browser_download_url="http://x/zip",size=99}, +}})) +check(foreign ~= nil, "a release without a matching game payload still parses") +eq(foreign.payload,nil,"Gen1 and platform archives never substitute for the Gen2 payload") +local competing = Check.parseRelease(Json.encode({tag_name="v1.4.2", assets={ + {name="gen1recomp-1.4.2.love",browser_download_url="http://x/foreign",size=88}, + {name="Gen2Recomped-1.4.2.love",browser_download_url="http://x/correct",size=123}, +}})) +check(competing.payload ~= nil,"exact Gen2 payload wins among same-version assets") +eq(competing.payload and competing.payload.url,"http://x/correct","foreign game asset is ignored even when listed first") + local rel = Check.parseRelease(body) check(rel ~= nil, "parseRelease accepts a valid release") eq(rel.version, "1.4.2", "parseRelease strips the leading v from tag_name") -eq(rel.payloadName, "gen1recomp-1.4.2.love", "parseRelease derives the payload name from the version") +eq(rel.payloadName, "Gen2Recomped-1.4.2.love", "parseRelease derives the payload name from the version") eq(rel.payload.url, "http://x/love", "parseRelease picks the payload asset url") eq(rel.payload.size, 12345, "parseRelease picks the payload asset size") eq(rel.sums.url, "http://x/sums", "parseRelease picks the sums asset url") @@ -268,18 +282,18 @@ eq(literal, nil, "parseRelease returns nil on a non-object JSON literal") -- standard " " lines, the '*' binary marker, a './' prefix, CRLF -- endings and mixed hash case; junk lines are skipped local sums = - "aaaa1111 gen1recomp-1.4.2.love\n" .. + "aaaa1111 Gen2Recomped-1.4.2.love\n" .. "BBBB2222 *./sha256sums.txt\r\n" .. "deadBEEF ./nested.love\n" .. "not a checksum line at all\n" local map = Check.parseSums(sums) -eq(map["gen1recomp-1.4.2.love"], "aaaa1111", "parseSums reads a bare-name line") +eq(map["Gen2Recomped-1.4.2.love"], "aaaa1111", "parseSums reads a bare-name line") eq(map["sha256sums.txt"], "bbbb2222", "parseSums strips the * marker and ./ prefix and lowercases") eq(map["nested.love"], "deadbeef", "parseSums lowercases a mixed-case hash and strips ./") eq(map["not a checksum line at all"], nil, "parseSums skips lines that are not checksums") -- the target form returns just that file's hash (hit / miss) -eq(Check.parseSums(sums, "gen1recomp-1.4.2.love"), "aaaa1111", "parseSums(target) returns the matching hash") +eq(Check.parseSums(sums, "Gen2Recomped-1.4.2.love"), "aaaa1111", "parseSums(target) returns the matching hash") eq(Check.parseSums(sums, "missing.love"), nil, "parseSums(target) misses cleanly on an unknown file") -- degenerate inputs: empty text yields an empty map, a targeted miss is nil, @@ -294,7 +308,7 @@ eq(Check.parseSums(nil, "anything"), nil, "parseSums(nil, target) returns nil wi -- --------------------------------------------------------------------------- eq(Check.releaseUrl(), - "https://github.com/bryanthaboi/gen1recomp/releases/latest", + "https://github.com/UNDERdecoded/Gen2Recomped/releases/latest", "releaseUrl points at the repo's latest release") T.finish("update") From e48df326303c418d309cb993e8293cc5d5dcd543 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:21:42 +0200 Subject: [PATCH 122/138] test: cover twelve-action rebinding without fixed row indices --- tests/engine/rebind_capture_bug510.lua | 41 ++++++++++++++++------ tests/engine/rebind_swap_clear_bug589.lua | 42 +++++++++++++++-------- 2 files changed, 58 insertions(+), 25 deletions(-) diff --git a/tests/engine/rebind_capture_bug510.lua b/tests/engine/rebind_capture_bug510.lua index e5d13d311..f36306d15 100644 --- a/tests/engine/rebind_capture_bug510.lua +++ b/tests/engine/rebind_capture_bug510.lua @@ -48,8 +48,13 @@ local function openMenu(game) return bm end --- rows are BindingsMenu's BUTTONS order; 5 = A, 6 = B -local ROW_A, ROW_B = 5, 6 +-- Find actions by their public menu row metadata, not by a generation's order. +local function row(menu, action) + for i, item in ipairs(menu.items) do + if item.button and item.button.id == action then return i end + end + error("missing controls row: " .. action) +end -- The mechanism, pinned so a future "just apply it immediately" revert -- fails here: a rebind overwrites whatever the key used to do, so B = Z @@ -73,14 +78,14 @@ check(game.save.options.bindings == nil, "escaping a capture writes no binding") eq(game.wroteOptions, 0, "and does not touch options on disk") -bm.index = ROW_B +bm.index = row(bm, "b") press(bm, "a") bm:onKeyPressed("z") check(game.save.options.bindings == nil, "a capture holds its press; nothing stores before the release (#589)") bm:onKeyReleased("z") eq(game.save.options.bindings.b.key, "z", "releasing the press stores B = Z") -eq(bm.items[ROW_B].right, "Z/B", "the row shows the new key straight away") +eq(bm.items[row(bm, "b")].right, "Z/B", "the row shows the new key straight away") eq(game.wroteOptions, 1, "the choice persists immediately") eq(Input.keyBindings["z"], "a", "but the live map still reads Z as A while the screen is open (#510)") @@ -90,12 +95,12 @@ eq(#game.stack.states, 1, "capturing Z does not close the screen") -- inherits B's previous key and no key serves two rows (#589) eq(game.save.options.bindings.a.key, "x", "capturing A's key for B hands A the old B key") -eq(bm.items[ROW_A].right, "X/A", "and the A row redraws with it") +eq(bm.items[row(bm, "a")].right, "X/A", "and the A row redraws with it") -- the next Z the player presses is still confirm, so the A row can be armed -bm.index = ROW_A +bm.index = row(bm, "a") press(bm, "a") -eq(bm.capture, bm.items[ROW_A], "the A row arms instead of the screen closing") +eq(bm.capture, bm.items[row(bm, "a")], "the A row arms instead of the screen closing") bm:onKeyPressed("x") bm:onKeyReleased("x") eq(game.save.options.bindings.a.key, "x", "re-capturing A's own key keeps it") @@ -117,16 +122,18 @@ eq(Input.keyBindings["z"], "b", "a reload reaches the same map as the close") Input:init() local padGame = newGame() local padBm = openMenu(padGame) -padBm.index = ROW_B +padBm.index = row(padBm, "b") press(padBm, "a") padBm:onGamepadPressed("y") padBm:onGamepadReleased("y") eq(padGame.save.options.bindings.b.pad, "y", "a pad capture stores") -eq(Input.padBindings["y"], nil, "and stays out of the live pad map until close") +eq(Input.padBindings["y"], "x", "pad Y keeps its default DS X action until close") +eq(padGame.save.options.bindings.x.pad, "b", "DS X inherits the captured row's pad B") +eq(Input.padBindings["b"], "b", "the donor swap is also deferred until close") -- a second input going down while the first is held backs the capture out -- with no keyboard in reach, the pad's Escape (#589) -padBm.index = ROW_A +padBm.index = row(padBm, "a") press(padBm, "a") padBm:onGamepadPressed("x") padBm:onGamepadPressed("b") @@ -139,6 +146,20 @@ eq(padGame.save.options.bindings.a, nil, press(padBm, "b") eq(Input.padBindings["y"], "b", "closing commits the pad half too") +eq(Input.padBindings["b"], "x", "closing commits the DS X donor half") + +-- A genuinely unbound input must also remain absent until the screen closes. +Input:init() +local spareGame = newGame() +local spareBm = openMenu(spareGame) +spareBm.index = row(spareBm, "a") +press(spareBm, "a") +spareBm:onGamepadPressed("leftstick") +spareBm:onGamepadReleased("leftstick") +eq(spareGame.save.options.bindings.a.pad, "leftstick", "unbound pad capture stores") +eq(Input.padBindings.leftstick, nil, "unbound pad remains absent while the menu is open") +press(spareBm, "b") +eq(Input.padBindings.leftstick, "a", "closing activates the newly bound pad") Input:init() T.finish("rebind_capture_bug510") diff --git a/tests/engine/rebind_swap_clear_bug589.lua b/tests/engine/rebind_swap_clear_bug589.lua index 0e49d03d0..7ccca5fe0 100644 --- a/tests/engine/rebind_swap_clear_bug589.lua +++ b/tests/engine/rebind_swap_clear_bug589.lua @@ -54,8 +54,12 @@ local function settleChoice(state) for _ = 1, Timing.YES_NO_ANSWER do state:update(1 / 60) end end --- rows are BindingsMenu's BUTTONS order -local ROW_A, ROW_B, ROW_SELECT = 5, 6, 8 +local function row(menu, action) + for i, item in ipairs(menu.items) do + if item.button and item.button.id == action then return i end + end + error("missing controls row: " .. action) +end -- ---- (a) pad capture swaps with the row that owns the button -------------- @@ -64,9 +68,9 @@ local game = newGame() local bm = BindingsMenu.new(game) game.stack:push(bm) -bm.index = ROW_A +bm.index = row(bm, "a") press(bm, "a") -eq(bm.capture, bm.items[ROW_A], "A arms the A row") +eq(bm.capture, bm.items[row(bm, "a")], "A arms the A row") bm:onGamepadPressed("b") check(game.save.options.bindings == nil, "a pad capture holds its press; nothing stores before the release") @@ -75,8 +79,8 @@ local bindings = game.save.options.bindings eq(bindings.a.pad, "b", "releasing pad B stores it on the A row") eq(bindings.b.pad, "a", "and the B row, which owned pad B, inherits the A row's old pad (#589)") -eq(bm.items[ROW_A].right, "Z/B", "the A row redraws with the new pad") -eq(bm.items[ROW_B].right, "X/A", "so does the B row") +eq(bm.items[row(bm, "a")].right, "Z/B", "the A row redraws with the new pad") +eq(bm.items[row(bm, "b")].right, "X/A", "so does the B row") -- after applying, every row's effective pad is unique and none is lost Input:applyBindings(bindings) @@ -89,13 +93,21 @@ for button, action in pairs(Input.padBindings) do seen[action] = button actions[#actions + 1] = action end -eq(#actions, 8, "all eight actions still have exactly one pad button") +eq(#actions, 12, "all twelve actions still have exactly one pad button") +local unchanged = { + up = "dpup", down = "dpdown", left = "dpleft", right = "dpright", + x = "y", y = "x", start = "start", select = "back", + l = "leftshoulder", r = "rightshoulder", +} +for action, button in pairs(unchanged) do + eq(seen[action], button, "A/B swap preserves the binding for " .. action) +end Input:init() -- ---- (b) a second input while the first is held cancels ------------------- -- key then key: the straggling release of the first press writes nothing -bm.index = ROW_SELECT +bm.index = row(bm, "select") press(bm, "a") bm:onKeyPressed("q") bm:onKeyPressed("w") @@ -121,17 +133,17 @@ check(bindings.select == nil, "press alone commits nothing (#589)") eq(game.wroteOptions, writesAfterSwap, "and touches nothing on disk") bm:onKeyReleased("q") eq(bindings.select.key, "q", "the release is the commit") -eq(bm.items[ROW_SELECT].right, "Q/BACK", "the row shows the new key") +eq(bm.items[row(bm, "select")].right, "Q/BACK", "the row shows the new key") -- ---- (d) SELECT on a row forgets its rebind ------------------------------- press(bm, "select") check(bindings.select == nil, "SELECT drops the row's overlay entry (#589)") -eq(bm.items[ROW_SELECT].right, "TAB/BACK", "the row falls back to the default") +eq(bm.items[row(bm, "select")].right, "TAB/BACK", "the row falls back to the default") -- the swapped B row clears the same way; a second SELECT on the now -- default row is a no-op, not a write local writes = game.wroteOptions -bm.index = ROW_B +bm.index = row(bm, "b") press(bm, "select") eq(game.wroteOptions, writes + 1, "clearing the swapped B row writes once") check(game.save.options.bindings.b == nil, "and drops its overlay entry") @@ -141,7 +153,7 @@ eq(game.wroteOptions, writes + 1, "clearing an already-default row writes nothin -- ---- (e) START confirms, then drops the whole overlay --------------------- -- rebuild a dirty overlay to reset -bm.index = ROW_A +bm.index = row(bm, "a") press(bm, "a") bm:onKeyPressed("p") bm:onKeyReleased("p") @@ -158,7 +170,7 @@ press(box, "a") settleChoice(box) eq(game.stack:top(), bm, "answering pops the box") check(game.save.options.bindings ~= nil, "NO keeps the bindings (defaultNo)") -eq(bm.items[ROW_A].right, "P/B", "and the rows keep showing them") +eq(bm.items[row(bm, "a")].right, "P/B", "and the rows keep showing them") -- again, flip to YES: the overlay goes away and every row reads default press(bm, "start") @@ -167,8 +179,8 @@ press(box, "up") press(box, "a") settleChoice(box) check(game.save.options.bindings == nil, "YES clears options.bindings (#589)") -eq(bm.items[ROW_A].right, "Z/A", "the A row reads its default again") -eq(bm.items[ROW_B].right, "X/B", "so does the B row the swap had touched") +eq(bm.items[row(bm, "a")].right, "Z/A", "the A row reads its default again") +eq(bm.items[row(bm, "b")].right, "X/B", "so does the B row the swap had touched") -- closing after the reset leaves the live map at the defaults press(bm, "b") From f409d43d80278b6e399d15d901100c83dd1d1e57 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:22:34 +0200 Subject: [PATCH 123/138] test: exercise profiled Game methods with complete fixtures --- tests/engine/soft_reset_bug563.lua | 26 ++++++++++++++++++++++---- tests/engine/tool_mod_hooks.lua | 15 ++++++++------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/tests/engine/soft_reset_bug563.lua b/tests/engine/soft_reset_bug563.lua index 65fb22445..fe8fdcf7d 100644 --- a/tests/engine/soft_reset_bug563.lua +++ b/tests/engine/soft_reset_bug563.lua @@ -106,8 +106,9 @@ check(stepChord(), "reaching 16 again from the restart") -- before stack:update, so it fires from a battle, a menu or a cutscene and -- not just from the overworld (#563). local function fakeGame() - local game = { input = Input, save = {}, returned = 0 } + local game = setmetatable({ input = Input, save = {}, returned = 0 }, { __index = Game }) game.stack = { + states = { {} }, -- a live state, so the real empty-stack guard does not recover updates = 0, update = function(self) self.updates = self.updates + 1 end, } @@ -117,7 +118,23 @@ end local game = fakeGame() holdKeys("z", "x", "escape", "tab") +-- Observe the real profiling wrapper as well as its real _step body. A reset +-- returns early from _step, but must still close the outer profiling scope. +local FrameProfile = require("src.core.FrameProfile") +local savedSection = FrameProfile.section +local opened, closed = 0, 0 +FrameProfile.section = function(name) + local close = savedSection(name) + if name == " step: whole" then opened = opened + 1 end + return function() + if name == " step: whole" then closed = closed + 1 end + close() + end +end for _ = 1, HOLD_STEPS do Game.step(game, 1 / 60) end +FrameProfile.section = savedSection +eq(opened, HOLD_STEPS, "every logic step enters the real profiling wrapper") +eq(closed, HOLD_STEPS, "the reset early return closes its profiling scope") eq(game.returned, 1, "Game:step falls back to the title on the 16th step") eq(game.stack.updates, HOLD_STEPS - 1, "and the state on top never gets that step, whatever it was") @@ -131,16 +148,17 @@ local bare = fakeGame() bare.input = { step = function() end, isDown = function() return false end } for _ = 1, HOLD_STEPS * 2 do Game.step(bare, 1 / 60) end eq(bare.returned, 0, "an input with no chord bookkeeping never resets") +eq(bare.stack.updates, HOLD_STEPS * 2, "an input stand-in still updates the real state stack") -- ---- the on-screen overlay --------------------------------------------- -- Force the overlay live the way a phone would have it. img is only ever --- tested for truthiness on the input path (drawing is what reads it), so a --- placeholder keeps this suite off love.graphics.newImage. +-- checked per button on the input path. Provide each available control +-- without asking love.graphics to load actual image assets. Input:init() TouchControls:init() TouchControls.active, TouchControls.enabled = true, true -TouchControls.img = { stub = true } +TouchControls.img = { a = {}, b = {}, start = {}, select = {}, dpad = {} } local L = TouchControls:layout() -- One finger, one control: touchpressed returns on its first hit, so no diff --git a/tests/engine/tool_mod_hooks.lua b/tests/engine/tool_mod_hooks.lua index 36f1f2a01..d92ac0aad 100644 --- a/tests/engine/tool_mod_hooks.lua +++ b/tests/engine/tool_mod_hooks.lua @@ -8,6 +8,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") local Hooks = require("src.mods.Hooks") local Runtime = require("src.mods.Runtime") +local Game = require("src.core.Game") local savedEvents, savedHooks, savedErrors = Runtime.events, Runtime.hooks, Runtime.errors @@ -19,14 +20,14 @@ Runtime.hooks = hooks -- ordering rather than merely checking that a hook was registered. do local order = {} - local fake = { + local fake = setmetatable({ input = { step = function() order[#order + 1] = "input" end }, - stack = { update = function(_, dt) + stack = { states = { {} }, update = function(_, dt) order[#order + 1] = "world" T.eq(dt, 1 / 60, "the world receives the fixed-step dt") end }, save = {}, - } + }, { __index = Game }) hooks:wrap("input.step", function(nextFn, game, dt) T.check(game == fake, "input.step receives the live Game object") T.eq(dt, 1 / 60, "input.step receives the fixed-step dt") @@ -71,10 +72,10 @@ end -- the live snapshot in preparation for disk IO. do local captured = false - local fake = { + local fake = setmetatable({ save = {}, overworld = { captureSave = function() captured = true end }, - } + }, { __index = Game }) hooks:wrap("save.write", function(nextFn, game) T.check(game == fake, "save.write receives the live Game object") return false @@ -112,14 +113,14 @@ do -- Game:draw iterates stack.states and calls each state's draw (it no -- longer goes through stack:draw), so the fixture must put a drawable -- state on the stack to observe composition order. - local fake = { + local fake = setmetatable({ overworld = {}, stack = { states = { { draw = function() order[#order + 1] = "states" end }, }, }, - } + }, { __index = Game }) function fake.stack:visibleBase() return 1 end function fake.stack:top() return self.states[#self.states] end From 7e01aadfcd55f248fc5bacac55546967ce2f53be Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:23:35 +0200 Subject: [PATCH 124/138] test: execute quit-event veto and Android shutdown paths --- tests/engine/quit_thread_shutdown.lua | 85 +++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 13 deletions(-) diff --git a/tests/engine/quit_thread_shutdown.lua b/tests/engine/quit_thread_shutdown.lua index a0dff35be..19cfd28ac 100644 --- a/tests/engine/quit_thread_shutdown.lua +++ b/tests/engine/quit_thread_shutdown.lua @@ -192,18 +192,77 @@ check(quitHook:find('package.loaded["src.update.Check"].shutdown', 1, true) ~= n -- so the quit event exits the process outright. It has to sit after the -- love.quit() veto test, or the editor's abort-quit path would die on a quit -- it refused. -local quitBranch = mainSrc:match('if name == "quit" then(.-)\n%s*end\n') -check(quitBranch ~= nil, "love.run still handles the quit event") -quitBranch = quitBranch or "" -local vetoAt = quitBranch:find("not love%.quit()") -local osAt = quitBranch:find("os%.exit") -local androidAt = quitBranch:find('getOS() == "Android"', 1, true) -check(vetoAt ~= nil and osAt ~= nil and vetoAt < osAt, - "the process exit runs only after love.quit declined to veto") -check(androidAt ~= nil and androidAt < osAt, - "the process exit is gated on Android") -local exits = 0 -for _ in mainSrc:gmatch("os%.exit") do exits = exits + 1 end -eq(exits, 1, "os.exit appears once, at the quit event") +-- Execute the actual run-loop function in an isolated environment. Scanning +-- nested `end`s or counting os.exit in comments cannot prove veto behavior. +local runAt = assert(mainSrc:find("\nfunction love.run()", 1, true)) +local runSource = mainSrc:sub(runAt + 1) +local function quitFrame(platform, answer, code, noCallback) + local calls, exits = {}, {} + local function record(name) calls[#calls + 1] = name end + local env = setmetatable({ + BootTrace = { mark = function() end, frame = function() record("frame") end }, + Frames = { tick = function() record("tick") end }, + FrameProfile = { section = function() return function() end end }, + pacingEnabled = function() return false end, + os = { exit = function(value) record("exit"); exits[#exits + 1] = value end }, + love = { + handlers = { quit = function() record("dispatch") end }, + update = function() record("update") end, + event = { + pump = function() end, + poll = function() + local sent = false + return function() + if not sent then sent = true; return "quit", code end + end + end, + }, + }, + }, { __index = _G }) + if platform then env.love.system = { getOS = function() return platform end } end + if not noCallback then + env.love.quit = function() record("quit"); return answer end + end + local compile = loadstring or load + local chunk + if setfenv then + chunk = assert(compile(runSource, "@main.lua:love.run")) + setfenv(chunk, env) + else + chunk = assert(load(runSource, "@main.lua:love.run", "t", env)) + end + chunk() + local result = env.love.run()() + return result, table.concat(calls, ","), exits +end + +for _, platform in ipairs({ "Android", "Windows", "OS X", "Linux", "NX", false }) do + local label = tostring(platform) + for _, code in ipairs({ 0, 7, false }) do + -- false is a table-friendly sentinel for an omitted exit status. + local expected = code or 0 + for _, noCallback in ipairs({ false, true }) do + for _, answer in ipairs({ false, "nil" }) do + local callbackAnswer = answer + if answer == "nil" then callbackAnswer = nil end + local result, order, exits = quitFrame(platform, callbackAnswer, code or nil, noCallback) + local prefix = noCallback and "" or "quit" + eq(result, expected, label .. ": accepted quit returns its exit status") + if platform == "Android" then + eq(#exits, 1, "Android exits exactly once after accepting quit") + eq(exits[1], expected, "Android forwards the requested exit status") + eq(order, prefix == "" and "exit" or "quit,exit", "shutdown precedes process exit") + else + eq(#exits, 0, label .. ": accepted quit does not force a process exit") + eq(order, prefix, label .. ": no further frame work after accepted quit") + end + end + end + end + local result, order, exits = quitFrame(platform, true, 7) + eq(result, nil, label .. ": veto keeps the frame running") + eq(#exits, 0, label .. ": veto never exits the process") + eq(order, "quit,dispatch,update,frame,tick", label .. ": veto resumes event and frame processing") +end T.finish("quit thread shutdown") From 9dac838c37311ca1ef6072f1099a1d3328e19e1b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:24:10 +0200 Subject: [PATCH 125/138] test: pin continuous wide-battle animation anchor mapping --- tests/engine/wide_battle_layout.lua | 39 +++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/tests/engine/wide_battle_layout.lua b/tests/engine/wide_battle_layout.lua index 5571af399..dadf125cf 100644 --- a/tests/engine/wide_battle_layout.lua +++ b/tests/engine/wide_battle_layout.lua @@ -38,13 +38,38 @@ T.eq(WideBattle.navigate(1, 4, pressing("right")), 2, T.eq(WideBattle.navigate(1, 4, pressing("a")), nil, "navigate leaves A / B / SELECT to the battle engine") --- animation frames shift as one rigid group toward the side they play on -local px, py = WideBattle.animationOffset({ { x = 24 }, { x = 64 } }) -T.eq(px, 20, "a player-side frame lands on the player anchor") -T.eq(py, 8, "a player-side frame lands on the player baseline") -local ex, ey = WideBattle.animationOffset({ { x = 104 }, { x = 144 } }) -T.eq(ex, 136, "an enemy-side frame lands on the enemy anchor") -T.eq(ey, 0, "an enemy-side frame lands on the enemy baseline") +-- OAM x is the right edge of an 8px sprite. These groups are centered on +-- the authored player (26) and enemy (124) anchors, rather than merely +-- falling somewhere on their half of the 160px screen. +local px, py = WideBattle.animationOffset({ { x = 14 }, { x = 46 } }) +T.eq(px, 20, "the player anchor maps to its wide-layout offset") +T.eq(py, 8, "the player anchor maps to its wide-layout baseline") +local ex, ey = WideBattle.animationOffset({ { x = 112 }, { x = 144 } }) +T.eq(ex, 136, "the enemy anchor maps to its wide-layout offset") +T.eq(ey, 0, "the enemy anchor maps to its wide-layout baseline") + +local function offset(x, wantX, wantY, label) + local dx, dy = WideBattle.animationOffset({ { x = x } }) + T.eq(dx, wantX, label .. " horizontal offset") + T.eq(dy, wantY, label .. " vertical offset") +end +offset(30, 20, 8, "single sprite at the player anchor") +offset(128, 136, 0, "single sprite at the enemy anchor") +offset(79, 78, 4, "midpoint between anchors") +-- Wobble must continue across the anchor instead of sticking at it. +offset(29, 19, 8, "one pixel before the player anchor") +offset(31, 21, 8, "one pixel after the player anchor") +offset(127, 135, 0, "one pixel before the enemy anchor") +offset(129, 137, 0, "one pixel after the enemy anchor") +-- Bounded overshoot lets balls leave the picture without unbounded offsets. +offset(-1000, -3, 10, "far left overshoot") +offset(1000, 159, -2, "far right overshoot") +local rx, ry = WideBattle.animationOffset({ { x = 46 }, { x = 14 } }) +T.eq(rx, px, "sprite ordering does not change the rigid frame offset") +T.eq(ry, py, "sprite ordering does not change the baseline") +local emptyX, emptyY = WideBattle.animationOffset({}) +T.eq(emptyX, 0, "an empty frame has no horizontal offset") +T.eq(emptyY, 0, "an empty frame has no vertical offset") -- the surface: a request outside the bounds falls back to the GB screen, -- and the canvas is only reallocated when the size actually changes From 719f5641b9567e90398e18f4bafbad4a75bd85ed Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:28:05 +0200 Subject: [PATCH 126/138] test: audit and pin the current Gen2 link fingerprint surface --- tests/engine/gate_fingerprint.lua | 38 ++++++++++++++++++- tests/goldens/fixture_fingerprint.md | 36 ++++++++++++++++++ tests/goldens/fixture_fingerprint.txt | 2 +- .../goldens/fixture_fingerprint_sections.txt | 7 ++++ 4 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 tests/goldens/fixture_fingerprint.md create mode 100644 tests/goldens/fixture_fingerprint_sections.txt diff --git a/tests/engine/gate_fingerprint.lua b/tests/engine/gate_fingerprint.lua index 731996016..85fb65a72 100644 --- a/tests/engine/gate_fingerprint.lua +++ b/tests/engine/gate_fingerprint.lua @@ -9,8 +9,8 @@ -- itself, and would silently make two builds of the same engine refuse to -- link. Here it flips one hex string and fails. -- --- Regenerate deliberately with scripts/test.sh --bless after recording the --- intended parity change; never automatically. +-- Regenerate deliberately after auditing each changed surface section; see +-- tests/goldens/fixture_fingerprint.md. Never update expected values automatically. package.path = "./?.lua;./?/init.lua;" .. package.path @@ -37,6 +37,40 @@ local golden = readGolden(GOLDEN) T.check(golden ~= nil, "the committed fixture fingerprint golden exists: " .. GOLDEN) T.eq(actual, golden, "fixture fingerprint matches the committed golden") +-- Section pins make a registry addition reviewable without treating a whole +-- digest change as permission to bless unrelated species or move changes. +local sectionFile = assert(io.open("tests/goldens/fixture_fingerprint_sections.txt", "r")) +local expectedSections = {} +for line in sectionFile:lines() do + local name, expected = line:match("^(%S+)%s+(%x+)$") + if name then expectedSections[name] = expected end +end +sectionFile:close() +local sectionCount = 0 +for name, body in Fingerprint.surface(data, {}):gmatch("%[([^%]]+)%]([^%[]*)") do + sectionCount = sectionCount + 1 + T.eq(Fingerprint.digest("[" .. name .. "]" .. body), expectedSections[name], + "audited fixture surface section: " .. name) + expectedSections[name] = nil +end +T.eq(sectionCount, 7, "the fixture has seven populated fingerprint sections") +T.eq(next(expectedSections), nil, "all committed section pins were checked") + +-- Platinum's four dynamic-power effects were added in 3252aec. They are +-- native full handlers, and removing any one must change link compatibility. +local sinnoh = { + "POWER_BASED_ON_LOW_SPEED", "INCREASE_POWER_WITH_MORE_HP", + "DOUBLE_POWER_WHEN_BELOW_HALF", "DOUBLE_POWER_HEAL_SLEEP", +} +for _, id in ipairs(sinnoh) do + T.eq(data.move_effects[id].kind, "full", "Sinnoh effect is registered: " .. id) + local copy = T.fixtures.fresh() + local copyRun = T.sdk.loadNone({ data = copy }) + copy.move_effects[id] = nil + T.neq(Fingerprint.compute(copy, {}), actual, "omitting " .. id .. " changes compatibility") + copyRun.release() +end + -- determinism: same data, same digest. A fingerprint that folded a table -- address or a pairs() order would pass once and fail in CI. T.eq(Fingerprint.compute(data, {}), actual, "the fingerprint is stable within a process") diff --git a/tests/goldens/fixture_fingerprint.md b/tests/goldens/fixture_fingerprint.md new file mode 100644 index 000000000..d8cc8e062 --- /dev/null +++ b/tests/goldens/fixture_fingerprint.md @@ -0,0 +1,36 @@ +# Fixture link fingerprint audit + +The previous `7de3051a7b3c108e` pin was already stale at Gen2Recomped's +`0b8c9c4` import: that checkout computes `8c94d595cd60ca69` against its own +ROM-free fixtures. It is not a baseline that current Gen2 data can satisfy. + +At upstream `13af468b97dc37015d14ad5c71d7f067c3ee810d`, the same production +serializer and digest compute `41e69caf4afa35ab`. Comparing the canonical +`Fingerprint.surface(data, {})` strings against `0b8c9c4` changes **only** +`move_effects`: commit `3252aec` adds these four full-handler registry records: + +- `POWER_BASED_ON_LOW_SPEED` (Gyro Ball) +- `INCREASE_POWER_WITH_MORE_HP` (Wring Out / Crush Grip) +- `DOUBLE_POWER_WHEN_BELOW_HALF` (Brine) +- `DOUBLE_POWER_HEAL_SLEEP` (Wake-Up Slap) + +No records were removed or altered in the serialized surface comparison. +The current surface has 198 effect records. Species, moves, type chart, +statuses, constants and mod list are byte-identical to the imported checkout. +An independent Python implementation using integer XOR, multiplication by +16777619 and modulo 2**32 in both lanes also produces the current digest. +No fingerprint algorithm, allowlist, cache or production registry is changed +by this test repair. + +`fixture_fingerprint_sections.txt` pins each populated section separately so +future intentional registry additions cannot hide changes in another section. +`gate_fingerprint.lua` retains all prior mutation and determinism assertions, +and additionally proves that removing any of the four Sinnoh registrations +changes compatibility. An empty `link_fields` registry has no serialized +section; a future nonempty one requires a deliberate new section pin. + +To audit a future change, load a fresh `tests.modkit` fixture with +`T.sdk.loadNone({data=data})`, compare `Fingerprint.surface(data,{})` section +by section, and then update only reviewed pins. Run the gate again and keep +the mutation assertions enabled. The ROM-dependent vanilla golden is separate +and was not re-blessed by this update. diff --git a/tests/goldens/fixture_fingerprint.txt b/tests/goldens/fixture_fingerprint.txt index 3ab0752dc..77ad32cd7 100644 --- a/tests/goldens/fixture_fingerprint.txt +++ b/tests/goldens/fixture_fingerprint.txt @@ -1 +1 @@ -7de3051a7b3c108e +41e69caf4afa35ab diff --git a/tests/goldens/fixture_fingerprint_sections.txt b/tests/goldens/fixture_fingerprint_sections.txt new file mode 100644 index 000000000..2e9081fa0 --- /dev/null +++ b/tests/goldens/fixture_fingerprint_sections.txt @@ -0,0 +1,7 @@ +pokemon 0ad4d8a59c0daa59 +moves 54b3cb2c1776ed48 +type_chart cc5bd80d003bd459 +statuses 68247ced2ed19a21 +move_effects 3bef37494ca4a845 +constants e4cf6f91ff122fa5 +mods 6a9ee9de68f78902 From 496a3415f949492c49261c4b67165c87cff972d0 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:29:42 +0200 Subject: [PATCH 127/138] test: cover six native producers through the public mod API --- tests/engine/native_mod_seams.lua | 234 ++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 tests/engine/native_mod_seams.lua diff --git a/tests/engine/native_mod_seams.lua b/tests/engine/native_mod_seams.lua new file mode 100644 index 000000000..6d3794059 --- /dev/null +++ b/tests/engine/native_mod_seams.lua @@ -0,0 +1,234 @@ +-- Native producers, observed through a sandboxed mod loaded by the real SDK. +-- Do not emit these events from the test: a missing call site must fail here. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Runtime = require("src.mods.Runtime") +local Game = require("src.core.Game") +local GameVersion = require("src.core.GameVersion") +local Overworld = require("src.world.OverworldController") +local BirchSpeech = require("src.ui.BirchSpeech") +local Gen4Battle = require("src.battle.Gen4Battle") +local Pokemon = require("src.pokemon.Pokemon") + +local savedVersion = GameVersion.get() +local savedGame = {} +for _, key in ipairs({ "data", "save", "stack", "input", "overworld", "world", "renderer" }) do + savedGame[key] = { Game[key] } +end +GameVersion.set("red") +local data = T.fixtures.fresh() +local run = T.sdk.loadMods( + { "mods/seam_probe" }, + { + data = data, + fs = T.sdk.memfs({ + ["mods/seam_probe/manifest.json"] = [[{ + "id":"seam_probe", "name":"Native seam probe", "version":"1.0.0", + "api":2, "entry":"main.lua", "affects_link":false + }]], + ["mods/seam_probe/main.lua"] = [[ + local mod = ... + local seen = {} + mod.exports.seen = seen + local names = { + "intro.birch_speech.started", "intro.birch_speech.finished", + "pokemon.egg_hatched", "world.blocks_replaced", "world.tick", + } + for _, name in ipairs(names) do + seen[name] = {} + mod.events:on(name, function(payload) + local events = seen[name] + events[#events + 1] = payload + end) + end + mod.exports.hideHUD = true + mod.exports.hudCalls = 0 + mod.hooks:wrap("battle.status_hud_visible", function(nextFn, battle) + mod.exports.hudCalls = mod.exports.hudCalls + 1 + mod.exports.battle = battle + if mod.exports.hideHUD then return false end + return nextFn(battle) + end) + ]], + }), + } +) +T.eq(#run.errors, 0, "the public-API probe loads without errors") +local exports = assert(run.loader.exports.seam_probe) +local seen = exports.seen +local stack = { states = {} } +function stack:push(state) + self.states[#self.states + 1] = state +end +function stack:pop() + return table.remove(self.states) +end +function stack:top() + return self.states[#self.states] +end +Game.data, Game.stack = data, stack +Game.save = { player = { map = "FIX_TOWN", name = "TEST", id = 123 }, options = {} } +Game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, +} +Game.renderer = { + worldViewSize = function() + return 160, 144 + end, +} + +-- enter() establishes the native module's Game reference and real runner. +-- Map loading is the ROM/graphics boundary; provide a tiny idle map there. +local map = { + id = "FIX_TOWN", + def = { width = 2, height = 2 }, + blockPatch = {}, + renderer = { + rebuilds = 0, + rebuild = function(self) + self.rebuilds = self.rebuilds + 1 + end, + }, + warpAtCell = function() + return nil + end, +} +function map:blockAt(bx, by) + return self.blockPatch[by * 2 + bx + 1] or 0 +end +local world = setmetatable({ + setMap = function(self) + self.map = map + self.player = { + cellX = 0, + cellY = 0, + px = 0, + py = 0, + update = function() + return false + end, + } + self.npcs, self.ghosts, self.entities, self.cast = {}, {}, {}, {} + end, +}, { __index = Overworld }) +world:enter("FIX_TOWN", 0, 0, "down") + +-- Birch start and finish reach subscribers at their real lifecycle boundary. +GameVersion.set("emerald") +data.constants.gen3Intro = { lines = { "WELCOME", "WORLD", "PLAYER", "GENDER" } } +data.text.WELCOME, data.text.GENDER = "Welcome!", "Boy or girl?" +local done = false +local speech = BirchSpeech.new(Game, function() + done = true +end) +stack:push(speech) +speech:enter() +T.eq(#seen["intro.birch_speech.started"], 1, "enter publishes one Birch start") +T.eq(seen["intro.birch_speech.started"][1].speech, speech, "start identifies the live speech") +T.check(stack:top() ~= speech, "start advances into the first real text box") +stack:pop() +speech.answers = { name = "TEST", gender = "girl" } +speech:finish() +T.eq(#seen["intro.birch_speech.finished"], 1, "finish publishes one Birch completion") +local finished = seen["intro.birch_speech.finished"][1] +T.eq(finished.speech, speech, "finish identifies the same speech") +T.eq(finished.answers, speech.answers, "finish carries the recorded answers") +T.check(done and #stack.states == 0, "finish then closes the speech and calls its continuation") + +-- Batch block changes publish only actual writes, clipped to map dimensions. +GameVersion.set("red") +T.eq(world:replaceBlocks({ 1, 0, 2, 0, 99 }), true, "native batch changes map blocks") +T.eq(#seen["world.blocks_replaced"], 1, "batch emits one event") +T.eq(seen["world.blocks_replaced"][1].mapId, "FIX_TOWN", "batch reports the map") +T.eq(seen["world.blocks_replaced"][1].count, 2, "batch counts changed cells only") +T.eq(map.blockPatch[1], 1, "first block is written") +T.eq(map.blockPatch[3], 2, "second changed block is written") +T.eq(map.blockPatch[5], nil, "extra input cannot extend the map") +T.eq(map.renderer.rebuilds, 1, "batch rebuilds once") +T.eq(world:replaceBlocks({ 1, 0, 2, 0 }), false, "identical batch does nothing") +T.eq(world:replaceBlocks(nil), false, "missing batch does nothing") +T.eq(#seen["world.blocks_replaced"], 1, "no-op batches publish no event") + +-- A headless logic update emits even though draw() was never called. +world:update(1 / 60) +T.eq(#seen["world.tick"], 1, "native update publishes one simulation tick") +local tick = seen["world.tick"][1] +T.eq(tick.dt, 1 / 60, "tick carries the simulation dt") +T.eq(tick.mapId, "FIX_TOWN", "tick carries the active map") +T.eq(tick.overworld, world, "tick identifies the native world") +T.eq(tick.scripted, false, "an idle runner is reported as idle") +world.runner:run({ { "wait", 5 } }) +T.eq(world.runner:isRunning(), true, "the native wait command holds a script open") +world:update(1 / 30) +T.eq(#seen["world.tick"], 2, "scripted updates still publish exactly one tick") +T.eq(seen["world.tick"][2].scripted, true, "tick reports the real running script") +T.eq(seen["world.tick"][2].dt, 1 / 30, "tick does not hardcode the frame interval") + +-- Hatching is announced after the native dialogue callback, with a healed +-- ordinary Pokemon rather than an egg. Use Emerald to avoid Johto's second +-- nickname prompt; the native hatch method itself remains under test. +GameVersion.set("emerald") +local egg = Pokemon.new(data, "FIXMON_A", 5, function(a) + return a +end) +egg.isEgg, egg.eggSteps, egg.eggCycles = true, 0, 0 +egg.hp, egg.status = 1, "PSN" +T.eq(world:hatchEgg(egg), true, "native hatch accepts the egg") +T.eq(#seen["pokemon.egg_hatched"], 0, "hatch is not announced before dialogue finishes") +local box = stack:pop() +T.check(box ~= nil, "hatch opens its native text box") +box.onDone() +T.eq(#seen["pokemon.egg_hatched"], 1, "dialogue completion publishes the hatch") +T.eq(seen["pokemon.egg_hatched"][1].mon, egg, "hatch identifies the live Pokemon") +T.eq(egg.isEgg, nil, "the announced Pokemon is no longer an egg") +T.eq(egg.eggSteps, nil, "hatch clears egg step bookkeeping") +T.eq(egg.eggCycles, nil, "hatch clears egg cycle bookkeeping") +T.eq(egg.hp, egg.stats.hp, "hatch heals HP before publication") +T.eq(egg.status, nil, "hatch cures status before publication") +T.eq(egg.happiness, 120, "hatch applies base happiness before publication") +T.eq(world:hatchEgg(nil), false, "missing egg is rejected") +T.eq(#seen["pokemon.egg_hatched"], 1, "rejected hatch does not publish") + +-- A public wrapper can suppress native Sinnoh/Unova healthboxes before any +-- battler/graphics access. Returning true prevents fallback Game Boy panels. +GameVersion.set("platinum") +local battle = {} +T.eq(Gen4Battle.drawHealthboxes(battle), true, "hidden native HUD is handled") +T.eq(exports.hudCalls, 1, "native healthbox draw consults the public hook") +T.eq(exports.battle, battle, "the hook receives the live battle") +exports.hideHUD = false +T.eq( + Gen4Battle.drawHealthboxes(battle), + false, + "delegating preserves the native fallback when healthbox art is absent" +) +T.eq(exports.hudCalls, 2, "delegation still goes through the public wrapper") +exports.hideHUD = true +GameVersion.set("black") +T.eq(Gen4Battle.drawHealthboxes(battle), true, "the shared native path also hides Unova boxes") +T.eq(exports.hudCalls, 3, "Unova consults the same public wrapper") + +T.eq(#run.errors, 0, "native callbacks produce no attributed mod errors") +run.loader.events:removeOwner("seam_probe") +run.loader.hooks:removeOwner("seam_probe") +for _, name in ipairs({ + "intro.birch_speech.started", + "intro.birch_speech.finished", + "pokemon.egg_hatched", + "world.blocks_replaced", + "world.tick", +}) do + T.eq(Runtime.wants(name), false, "unloading removes subscription: " .. name) +end +T.eq(Runtime.wantsHook("battle.status_hud_visible"), false, "unloading removes the HUD hook") +run.release() +GameVersion.set(savedVersion) +for key, value in pairs(savedGame) do + Game[key] = value[1] +end +T.finish("native public mod seams") From 592739908e9148c2d569b8414d7cc5fa0fe9a413 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 04:19:27 +0100 Subject: [PATCH 128/138] Translate new transfer, hatch and athlete fallback messages at display time --- src/pokemon/Gen45Hatch.lua | 7 ++++--- src/ui/Gen5PokeTransferScreen.lua | 13 +++++++------ src/ui/HgssAthleteShop.lua | 9 +++++---- src/ui/HgssPokeathlonTeamInfo.lua | 3 ++- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/pokemon/Gen45Hatch.lua b/src/pokemon/Gen45Hatch.lua index d03ff8bf1..edc23cab0 100644 --- a/src/pokemon/Gen45Hatch.lua +++ b/src/pokemon/Gen45Hatch.lua @@ -6,6 +6,7 @@ -- bank (Pt 357, HGSS 187). Black / White keep all three in one bank, 260. -- Each falls back to the engine's English when the cache lacks the line. +local Strings = require("src.core.Strings") local Gen45Hatch = {} Gen45Hatch.LINES = { @@ -37,9 +38,9 @@ end -- The three lines, with `name` (the species) filled in. function Gen45Hatch.lines(game, name) local rows = Gen45Hatch.LINES[family() or "platinum"] - return line(game, rows.oh, name, "Oh?"), - line(game, rows.hatched, name, ("%s hatched from the Egg!"):format(name)), - line(game, rows.ask, name, ("Would you like to nickname the newly\nhatched %s?"):format(name)) + return line(game, rows.oh, name, Strings("Oh?")), + line(game, rows.hatched, name, Strings("%s hatched from the Egg!", name)), + line(game, rows.ask, name, Strings("Would you like to nickname the newly\nhatched %s?", name)) end return Gen45Hatch diff --git a/src/ui/Gen5PokeTransferScreen.lua b/src/ui/Gen5PokeTransferScreen.lua index 0f4be03ad..a710de122 100644 --- a/src/ui/Gen5PokeTransferScreen.lua +++ b/src/ui/Gen5PokeTransferScreen.lua @@ -103,11 +103,12 @@ function S:picked(e) for i, p in ipairs(self.picks) do if p == e then return i end end end -local REFUSE = { - egg = "An Egg cannot be transferred.", - item = "A Pokémon holding an item cannot\nbe transferred.", - hm = "This Pokémon knows a hidden move.\nIt cannot be transferred.", -} +local function refusal(reason) + if reason == "egg" then return Strings("An Egg cannot be transferred.") end + if reason == "item" then return Strings("A Pokémon holding an item cannot\nbe transferred.") end + if reason == "hm" then return Strings("This Pokémon knows a hidden move.\nIt cannot be transferred.") end + return Strings("This Pokémon cannot be transferred.") +end function S:toggle(e) local at = self:picked(e) @@ -115,7 +116,7 @@ function S:toggle(e) if #self.picks >= T().MONS then return end if not e.walker then local why = T().refusal(self.srcData, e.raw) - if why then return self:say(Strings(REFUSE[why] or "This Pokémon cannot be transferred.")) end + if why then return self:say(refusal(why)) end end self.picks[#self.picks + 1] = e if #self.picks == T().MONS then diff --git a/src/ui/HgssAthleteShop.lua b/src/ui/HgssAthleteShop.lua index d9d51199d..7db88187b 100644 --- a/src/ui/HgssAthleteShop.lua +++ b/src/ui/HgssAthleteShop.lua @@ -30,6 +30,7 @@ -- (overlay 31's line for that case was not traced); a taken prize is drawn -- like any other (whether overlay 31 dims it was not read). +local Strings = require("src.core.Strings") local T = require("src.import.Gen4Text") local M = {} @@ -101,7 +102,7 @@ local function choose(self) return end self.mode, self.yesNo = "confirm", 1 - self.message = bank(self, 46, "Would you like\n" .. (def.name or "") .. "?", def.name or "") + self.message = bank(self, 46, Strings("Would you like\n%s?", def.name or ""), def.name or "") end local function transact(self) @@ -115,15 +116,15 @@ local function transact(self) if self.athlon.kind == "daily" then if isApricorn(id) then require("src.world.HgssApricorns").give(save, id - M.APRICORN_FIRST, 1) - self.message = bank(self, 49, "All right, here you go.\nCome back anytime!") + self.message = bank(self, 49, Strings("All right, here you go.\nCome back anytime!")) else require("src.inventory.Bag").add(save, id, 1, self.game.data) - self.message = self:line(6, "Here you are!\nThank you!", def.name or "", self:pocketName(def)) + self.message = self:line(6, Strings("Here you are!\nThank you!"), def.name or "", self:pocketName(def)) end S.takeDaily(save, self.cursor - 1) else S.giveCard(save, (tonumber(id) or 0) - M.DATA_CARD_01) - self.message = bank(self, 49, "All right, here you go.\nCome back anytime!") + self.message = bank(self, 49, Strings("All right, here you go.\nCome back anytime!")) end -- MartData_SubCurrency (PokeathlonSave_SubAthletePoints: not below 0) and -- GAME_STAT_CURRENCY_SPENT diff --git a/src/ui/HgssPokeathlonTeamInfo.lua b/src/ui/HgssPokeathlonTeamInfo.lua index 305a812e7..96ea40fb8 100644 --- a/src/ui/HgssPokeathlonTeamInfo.lua +++ b/src/ui/HgssPokeathlonTeamInfo.lua @@ -48,6 +48,7 @@ -- * the OBJ set's sequence 5 (a sparkle, created hidden at the Medals and -- never shown by ov97) and 6 are not drawn +local Strings = require("src.core.Strings") local Font = require("src.render.Font") local T = require("src.import.Gen4Text") @@ -198,7 +199,7 @@ function M.draw(game, opts) elseif r.gender == "female" then text(game, line(game, 3, "♀"), 76, 120, 3, 4) end for _, l in ipairs(M.LABELS) do text(game, line(game, l[1], ""), 104, l[2]) end local sp = speciesName(game, mon) - text(game, line(game, 9, "Medals won by\n" .. sp, sp), 8, 152) + text(game, line(game, 9, Strings("Medals won by\n%s", sp), sp), 8, 152) -- the OBJs: the picture, the stars and arrows, the Medals local pic = picture(game, mon) if pic then From a620d0c761dcb7bd23df99e8e1ad3d45015f187e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:16:57 +0100 Subject: [PATCH 129/138] Expose the existing native RTC subscription to the literal coverage gate --- tests/engine/gen2_clock_lifecycle.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/engine/gen2_clock_lifecycle.lua b/tests/engine/gen2_clock_lifecycle.lua index f9f7f36a6..1815bf1da 100644 --- a/tests/engine/gen2_clock_lifecycle.lua +++ b/tests/engine/gen2_clock_lifecycle.lua @@ -83,7 +83,7 @@ for _,edition in ipairs({'gold','silver','crystal'})do ['mods/day/main.lua']=[[ local mod=...;mod.exports.events={} local Clock=require('src.core.gen2.Clock') - mod.events:on('clock.day_changed',function(e) + mod.events:on("clock.day_changed",function(e) mod.exports.events[#mod.exports.events+1]={day=e.day,previous=e.previous,reason=e.reason} assert(Clock.weekday(mod.exports.save)==e.day) e.day=99 From 00131e0d45e5d518232e95c318cca1d9e2b2ab13 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:54:27 +0100 Subject: [PATCH 130/138] Provide guarded full-game FrameProfile qualification for independent performance switches --- tools/check_full_game_performance.py | 129 +++++++++++++++++++ tools/full_game_performance_driver.lua | 172 +++++++++++++++++++++++++ 2 files changed, 301 insertions(+) create mode 100644 tools/check_full_game_performance.py create mode 100644 tools/full_game_performance_driver.lua diff --git a/tools/check_full_game_performance.py b/tools/check_full_game_performance.py new file mode 100644 index 000000000..26bb7250e --- /dev/null +++ b/tools/check_full_game_performance.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Profile independent optimization switches in a fresh native Emerald game.""" +# Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +import argparse +import atexit +import hashlib +import io +import json +import os +from pathlib import Path +import sys +import tarfile +import time + +BOOTSTRAP = r''' +local identity=assert(os.getenv("POKEPORT_IDENTITY")) +assert(identity:match("^oasc%-gen2recomp%-qa%-perf%-"),"owned test identity required") +assert(love.filesystem.getIdentity()==identity,"isolated identity mismatch") +assert(love.filesystem.write("migrated-from-pokemon-love2d.txt","Isolated performance QA; migration bypassed.\n")) +local setIdentity=love.filesystem.setIdentity +love.filesystem.setIdentity=function(value,...) + assert(value==identity,"test rejected identity switch") + return setIdentity(value,...) +end +assert(require("src.core.SaveData").portableBaseDir()==nil,"portable saves forbidden") +assert(require("src.import.CacheFs").root()==nil,"external imports forbidden") +io.stdout:setvbuf("no") +love.errorhandler=function(err) + print(debug.traceback(tostring(err),2)) + return function()return 1 end +end +assert(loadfile("engine_main.lua"))() +''' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--love", type=Path, required=True) + parser.add_argument("--emerald", type=Path, required=True) + parser.add_argument("--backend", choices=("opengl", "metal"), required=True) + parser.add_argument("--out", type=Path, required=True, help="New output directory") + parser.add_argument("--qa-tools", type=Path, help="Shared qa_storage.py directory") + parser.add_argument("--map", default="MAP_G00_N15") + parser.add_argument("--x", type=int, default=10) + parser.add_argument("--y", type=int, default=10) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(args.qa_tools or root / "tools")) + from qa_storage import Storage + storage = Storage(root) + atexit.register(storage.close) + binary, rom = args.love.resolve(strict=True), args.emerald.resolve(strict=True) + driver = root / "tools/full_game_performance_driver.lua" + for path in (root, binary, rom, driver): + storage.source(path) + digest = hashlib.sha1(rom.read_bytes()).hexdigest() + expected = json.loads((root / "tools/rom_manifest_emerald.json").read_text())["romSha1"] + if digest != expected: + parser.error("Unsupported Emerald ROM revision") + run = args.out.resolve() + storage.cache(run) + source = run / "source" + storage.copy(source) + run.mkdir(parents=True, exist_ok=False) + source.mkdir() + identity = "oasc-gen2recomp-qa-perf-" + str(time.time_ns()) + storage.identity(identity) + head = storage.run(["git", "rev-parse", "HEAD"], cwd=root, check=True, + capture_output=True, text=True).stdout.strip() + assert not storage.run(["git", "status", "--porcelain"], cwd=root, check=True, + capture_output=True, text=True).stdout.strip(), "Commit the candidate first" + files = [p for folder in ("src", "mods/DRAMATIC_SHAPE") for p in (root / folder).rglob("*") + if p.is_file() and p.suffix in (".lua", ".json")] + hashes = {str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() for p in files} + archive = storage.run(["git", "archive", head, "main.lua", "conf.lua", "src", + "assets", "data", "tests", "tools", "mods/DRAMATIC_SHAPE"], + cwd=root, check=True, capture_output=True).stdout + with tarfile.open(fileobj=io.BytesIO(archive)) as tar: + for entry in tar.getmembers(): + assert not entry.name.startswith("/") and ".." not in Path(entry.name).parts + assert not entry.issym() and not entry.islnk(), "Frozen inputs must not alias player data" + tar.extractall(source) + (source / "main.lua").rename(source / "engine_main.lua") + (source / "main.lua").write_text(BOOTSTRAP) + env = {k: v for k, v in os.environ.items() + if not k.startswith(("POKEPORT_", "PERF_", "PALETTE_GPU_"))} + env.update(POKEPORT_IDENTITY=identity, POKEPORT_VERSION="emerald", + POKEPORT_GRAPHICS_BACKEND=args.backend, PERF_BACKEND=args.backend, + POKEPORT_NO_BOOT_REPORT="1", POKEPORT_NO_DISCORD="1", + POKEPORT_NOTIFY_ONLY_UPDATES="1", POKEPORT_TOUCH="0", + PERF_MAP=args.map, PERF_X=str(args.x), PERF_Y=str(args.y)) + print("Fresh owned performance run:", run, flush=True) + + def child(label, extra, timeout=900): + with (run / (label + ".log")).open("w") as log: + storage.run([str(binary), str(source)], cwd=source, env=dict(env, **extra), + stdout=log, stderr=log, timeout=timeout, check=True) + + child("import", dict(POKEPORT_IMPORT_ROM=str(rom), POKEPORT_IMPORT_ONLY="1", + POKEPORT_NO_MODS="1", PERF_OUT=str(run))) + print("Fresh import passed", flush=True) + report = dict(schema=1, sourceCommit=head, romSha1=digest, backend=args.backend, + sourceSha256=hashes, driverSha256=hashlib.sha256(driver.read_bytes()).hexdigest(), + minimumFreeGiB=20, games={}, freshIdentity=True, legacyMigrationBypassed=True) + for mode, count in (("native", 14), ("voxel", 4)): + out = run / mode + out.mkdir() + child(mode, dict(POKEPORT_DRIVER=str(source / "tools/full_game_performance_driver.lua"), + PERF_MODE=mode, PERF_OUT=str(out), + **({"POKEPORT_NO_MODS": "1"} if mode == "native" else {}))) + data = json.loads((out / "report.json").read_text()) + assert data["passed"] and len(data["phases"]) == count + assert all(p["windows"] == 2 and p["present"]["samples"] == 240 for p in data["phases"]) + report["games"][mode] = data + print(mode, "passed:", count, "measured switch phases", flush=True) + assert all(hashlib.sha256((root / path).read_bytes()).hexdigest() == value + for path, value in hashes.items()) + assert digest == hashlib.sha1(rom.read_bytes()).hexdigest() + assert head == storage.run(["git", "rev-parse", "HEAD"], cwd=root, check=True, + capture_output=True, text=True).stdout.strip() + assert not storage.run(["git", "status", "--porcelain"], cwd=root, check=True, + capture_output=True, text=True).stdout.strip() + report["passed"] = True + (run / "result.json").write_text(json.dumps(report, indent=2) + "\n") + print("FULL_GAME_PERFORMANCE_PASS", run / "result.json", flush=True) + + +if __name__ == "__main__": + main() diff --git a/tools/full_game_performance_driver.lua b/tools/full_game_performance_driver.lua new file mode 100644 index 000000000..15f4ff03b --- /dev/null +++ b/tools/full_game_performance_driver.lua @@ -0,0 +1,172 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Real imported content and native Game/Overworld; fixed 60 Hz simulation. +return function(game) + local U = require("tests.drivers.util") + local Json = require("src.link.Json") + local FP = require("src.core.FrameProfile") + local P = require("src.core.Performance") + local Logger = require("src.core.Logger") + local mode = assert(os.getenv("PERF_MODE")) + local out = assert(os.getenv("PERF_OUT")) + assert(love.filesystem.getIdentity():match("^oasc%-gen2recomp%-qa%-perf%-")) + local renderer, api, vendor, device = love.graphics.getRendererInfo() + assert(renderer:lower() == (os.getenv("PERF_BACKEND") or "metal") and not device:lower():find("software")) + local report = { + passed = false, + mode = mode, + edition = "emerald", + runtime = { love.getVersion() }, + architecture = jit.arch, + gpu = { renderer = renderer, api = api, vendor = vendor, device = device }, + simulationHz = 60, + phases = {}, + timing = "FrameProfile wall time; GPU submission, no completion fence; no screenshots inside intervals", + limitations = { + "sequential off/on samples; no causal whole-game speedup claim", + "Emerald does not exercise DS Nitro synthesis or GB palette uniforms", + "LOW native UI quad menus are covered by their separate allocation fixture", + }, + measurement = { warmupNative = 240, warmupVoxel = 1500, perSwitchWarmup = 180, samples = 240, profileWindow = 120 }, + } + U.freshSave(game) + game.save.options.performance = "high" + game.save.options.fpsCap = 240 + game.save.options.pipelines = game.save.options.pipelines or {} + game.save.options.pipelines.voxel = mode == "voxel" and 3 or 0 + game:applyOptions() + local _, _, flags = love.window.getMode() + flags.vsync = 0 + flags.highdpi = true + assert(love.window.setMode(640, 480, flags)) + require("src.core.FrameCap").apply(240) + U.teleport( + game, + os.getenv("PERF_MAP") or "MAP_G00_N15", + tonumber(os.getenv("PERF_X")) or 10, + tonumber(os.getenv("PERF_Y")) or 10, + "down" + ) + local V + if mode == "voxel" then + V = assert(_G.__DRAMATIC_SHAPE_V, "native loader did not load the unmodified mod") + V.require("Water").setting:sync("full") + V.require("RenderScale").setting:sync(1) + V.require("DayNight").setting:sync("day") + end + U.wait(mode == "voxel" and 1500 or 240) + local active + local present = love.graphics.present + love.graphics.present = function(...) + local t = love.timer.getTime() + local result = present(...) + if active then + active.presentMs[#active.presentMs + 1] = (love.timer.getTime() - t) * 1000 + if active.previousPresent then + active.intervalsMs[#active.intervalsMs + 1] = (t - active.previousPresent) * 1000 + end + active.previousPresent = t + end + return result + end + local original = Logger.report + Logger.report = function(fmt, ...) + if active then + local args = { ... } + if fmt:match("^frame profile:") then + active.windows = active.windows + 1 + end + if fmt:find("%-34s", 1, true) then + local name, value = args[1], args[2] + local row = active.sections[name] or { sumMs = 0, windows = 0 } + row.sumMs = row.sumMs + value + row.windows = row.windows + 1 + active.sections[name] = row + end + end + return original(fmt, ...) + end + local function setCard(card, on) + if card == "P1" then + require("src.core.FrameCap").vsyncPacing = on + elseif card == "P2" then + P.gcBudget = on + collectgarbage("setpause", on and 120 or 200) + collectgarbage("setstepmul", on and 300 or 200) + elseif card == "P3" then + P.hotPathHoist = on + elseif card == "P4" then + P.worldDrawHoist = on + elseif card == "P5" then + P.paletteZoneCache = on + elseif card == "P6" then + P.audioWorkerOptimization = on + elseif card == "P7" then + V.shadowWorldAnchor = on + V.require("ShadowMap").invalidate() + elseif card == "P8" then + V.renderBudget = on + elseif card == "P9" then + P.lowTierBudget = on + game.save.options.performance = "low" + game:applyOptions() + require("src.core.FrameCap").apply(240) + end + end + local cards = mode == "native" and { "P1", "P2", "P3", "P4", "P5", "P6", "P9" } or { "P7", "P8" } + for _, card in ipairs(cards) do + for _, on in ipairs({ false, true }) do + setCard(card, on) + local paced = card == "P1" + love.window.setVSync(paced and 1 or 0) + U.wait(180) + collectgarbage("collect") + active = + { card = card, enabled = on, windows = 0, sections = {}, vsync = love.window.getVSync(), presentMs = {}, intervalsMs = {} } + FP.setEnabled(true) + FP.reset() + local start = love.timer.getTime() + U.wait(240) + FP.setEnabled(false) + local phase = active + active = nil + phase.elapsedSeconds = love.timer.getTime() - start + assert(phase.windows == 2 and #phase.presentMs == 240, "missing measured frames or FrameProfile windows") + for _, row in pairs(phase.sections) do + row.meanMs = row.sumMs / row.windows + row.sumMs = nil + end + local function distribution(values) + table.sort(values) + local sum = 0 + for _, v in ipairs(values) do + sum = sum + v + end + return { + samples = #values, + meanMs = sum / #values, + p50Ms = values[math.ceil(#values * 0.5)], + p95Ms = values[math.ceil(#values * 0.95)], + p99Ms = values[math.ceil(#values * 0.99)], + } + end + phase.present = distribution(phase.presentMs) + phase.intervals = distribution(phase.intervalsMs) + phase.presentMs, phase.intervalsMs, phase.previousPresent = nil, nil, nil + phase.world = game.overworld.map.id + phase.stack = require("src.core.StateStack").describe(game.stack:top()) + if V then + phase.voxelLevel = V.require("VoxelState").level + assert(phase.voxelLevel > 0) + end + report.phases[#report.phases + 1] = phase + print("PERF_PHASE", card, tostring(on), phase.elapsedSeconds) + end + setCard(card, true) + end + love.graphics.present = present + Logger.report = original + report.passed = true + local f = assert(io.open(out .. "/report.json", "w")) + f:write(Json.encode(report)) + f:close() +end From 7727e8b618ba0b86406e5af37328ed29b86c0cf3 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:55:21 +0100 Subject: [PATCH 131/138] Clarify the owned output location required by the performance runner --- tools/check_full_game_performance.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/check_full_game_performance.py b/tools/check_full_game_performance.py index 26bb7250e..1f364f4c0 100644 --- a/tools/check_full_game_performance.py +++ b/tools/check_full_game_performance.py @@ -38,7 +38,8 @@ def main(): parser.add_argument("--love", type=Path, required=True) parser.add_argument("--emerald", type=Path, required=True) parser.add_argument("--backend", choices=("opengl", "metal"), required=True) - parser.add_argument("--out", type=Path, required=True, help="New output directory") + parser.add_argument("--out", type=Path, required=True, + help="New directory under this checkout's .local/qa") parser.add_argument("--qa-tools", type=Path, help="Shared qa_storage.py directory") parser.add_argument("--map", default="MAP_G00_N15") parser.add_argument("--x", type=int, default=10) From 12ae28f3d24831fb34f7c05519c4e62dfa2d02d0 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:03:25 +0100 Subject: [PATCH 132/138] Measure the 60 FPS pacing boundary and moving-camera shadow workload in the full game --- tools/full_game_performance_driver.lua | 38 ++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/tools/full_game_performance_driver.lua b/tools/full_game_performance_driver.lua index 15f4ff03b..02d8e9dbe 100644 --- a/tools/full_game_performance_driver.lua +++ b/tools/full_game_performance_driver.lua @@ -55,6 +55,25 @@ return function(game) end U.wait(mode == "voxel" and 1500 or 240) local active + local world, worldUpdate = game.overworld, game.overworld.update + world.update = function(self, ...) + worldUpdate(self, ...) + if active and active.card == "P7" then + -- Keep native simulation/draw, and exercise camera-driven invalidation. + self.camera.x = self.camera.x + #active.presentMs * 0.25 + end + end + local modPerf, count + if V then + modPerf = V.require("Perf") + count = modPerf.count + modPerf.count = function(name, n) + if active and name:sub(1, 7) == "shadow." then + active.shadowCasts[name] = (active.shadowCasts[name] or 0) + (n or 1) + end + return count(name, n) + end + end local present = love.graphics.present love.graphics.present = function(...) local t = love.timer.getTime() @@ -118,10 +137,21 @@ return function(game) setCard(card, on) local paced = card == "P1" love.window.setVSync(paced and 1 or 0) + require("src.core.FrameCap").apply(paced and 60 or 240) U.wait(180) collectgarbage("collect") - active = - { card = card, enabled = on, windows = 0, sections = {}, vsync = love.window.getVSync(), presentMs = {}, intervalsMs = {} } + active = { + card = card, + enabled = on, + windows = 0, + sections = {}, + vsync = love.window.getVSync(), + presentMs = {}, + intervalsMs = {}, + fpsCap = require("src.core.FrameCap").current, + shadowCasts = {}, + cameraMotion = card == "P7" and "controlled 0.25 world-pixel pan per update" or "native camera follow", + } FP.setEnabled(true) FP.reset() local start = love.timer.getTime() @@ -164,6 +194,10 @@ return function(game) setCard(card, true) end love.graphics.present = present + world.update = worldUpdate + if modPerf then + modPerf.count = count + end Logger.report = original report.passed = true local f = assert(io.open(out .. "/report.json", "w")) From 79ff56cb4e471713d244255bbb64aa6512ec063a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:08:50 +0100 Subject: [PATCH 133/138] Require native pacing and quiescent voxel meshes in full-game qualification --- tools/check_full_game_performance.py | 13 ++++++++++++- tools/full_game_performance_driver.lua | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/tools/check_full_game_performance.py b/tools/check_full_game_performance.py index 1f364f4c0..4cba0e6cf 100644 --- a/tools/check_full_game_performance.py +++ b/tools/check_full_game_performance.py @@ -82,6 +82,14 @@ def main(): assert not entry.issym() and not entry.islnk(), "Frozen inputs must not alias player data" tar.extractall(source) (source / "main.lua").rename(source / "engine_main.lua") + # Scripted runs normally bypass software pacing. Enable its existing native + # body in this frozen test copy so P1 and P2 are actually exercised. + entry = source / "engine_main.lua" + text = entry.read_text() + marker = 'if os.getenv("POKEPORT_DRIVER") then return false end' + assert text.count(marker) == 1, "Unknown native pacing admission gate" + entry.write_text(text.replace(marker, + 'if os.getenv("POKEPORT_DRIVER") and os.getenv("PERF_NATIVE_PACING") ~= "1" then return false end')) (source / "main.lua").write_text(BOOTSTRAP) env = {k: v for k, v in os.environ.items() if not k.startswith(("POKEPORT_", "PERF_", "PALETTE_GPU_"))} @@ -89,6 +97,7 @@ def main(): POKEPORT_GRAPHICS_BACKEND=args.backend, PERF_BACKEND=args.backend, POKEPORT_NO_BOOT_REPORT="1", POKEPORT_NO_DISCORD="1", POKEPORT_NOTIFY_ONLY_UPDATES="1", POKEPORT_TOUCH="0", + PERF_NATIVE_PACING="1", PERF_MAP=args.map, PERF_X=str(args.x), PERF_Y=str(args.y)) print("Fresh owned performance run:", run, flush=True) @@ -102,7 +111,9 @@ def child(label, extra, timeout=900): print("Fresh import passed", flush=True) report = dict(schema=1, sourceCommit=head, romSha1=digest, backend=args.backend, sourceSha256=hashes, driverSha256=hashlib.sha256(driver.read_bytes()).hexdigest(), - minimumFreeGiB=20, games={}, freshIdentity=True, legacyMigrationBypassed=True) + minimumFreeGiB=20, games={}, freshIdentity=True, legacyMigrationBypassed=True, + testAdaptations=["Enable existing native pacing for the driver in the frozen copy", + "Quarter-pixel camera pan during P7; native update and draw remain active"]) for mode, count in (("native", 14), ("voxel", 4)): out = run / mode out.mkdir() diff --git a/tools/full_game_performance_driver.lua b/tools/full_game_performance_driver.lua index 02d8e9dbe..e764d94d3 100644 --- a/tools/full_game_performance_driver.lua +++ b/tools/full_game_performance_driver.lua @@ -54,6 +54,20 @@ return function(game) V.require("DayNight").setting:sync("day") end U.wait(mode == "voxel" and 1500 or 240) + if V then + local mesher = V.require("ChunkMesher") + local deadline, waited = love.timer.getTime() + 180, 0 + while + mesher.pending() > 0 or not (mesher.peek(game.overworld.map, false) or mesher.peek(game.overworld.map, true)) + do + assert(not mesher.buildFailure(game.overworld.map.id), "native terrain build failed") + assert(love.timer.getTime() < deadline, "native terrain did not become quiescent") + U.wait(60) + waited = waited + 60 + end + report.measurement.readyWaitFrames = waited + U.wait(240) + end local active local world, worldUpdate = game.overworld, game.overworld.update world.update = function(self, ...) @@ -161,6 +175,14 @@ return function(game) active = nil phase.elapsedSeconds = love.timer.getTime() - start assert(phase.windows == 2 and #phase.presentMs == 240, "missing measured frames or FrameProfile windows") + assert(phase.sections["frame pacing: sleep"], "native pacing path was bypassed") + if card == "P7" then + assert( + phase.sections[" voxel: sun pass"] and phase.sections[" voxel: sun pass"].windows == 2, + "shadow measurement reached an unready fallback frame" + ) + assert(next(phase.shadowCasts), "native shadow passes did not execute") + end for _, row in pairs(phase.sections) do row.meanMs = row.sumMs / row.windows row.sumMs = nil From aa9a84ebe3458de5944baf54f6cf735cfb23cbea Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:23:24 +0100 Subject: [PATCH 134/138] Record completed programme gates and real-game FrameProfile evidence with explicit limits --- docs/performance-program-status.md | 111 + docs/qa/performance-full-game-p1-p9.json | 5194 ++++++++++++++++++++++ 2 files changed, 5305 insertions(+) create mode 100644 docs/performance-program-status.md create mode 100644 docs/qa/performance-full-game-p1-p9.json diff --git a/docs/performance-program-status.md b/docs/performance-program-status.md new file mode 100644 index 000000000..ae6a5cc96 --- /dev/null +++ b/docs/performance-program-status.md @@ -0,0 +1,111 @@ +# Performance and mod compatibility programme + +A1/A2, M1–M8 and P1–P9 are implemented in the composed fork and offered through +their existing upstream PRs. Existing acceptance repairs are reused. + +| Task | Existing upstream PR | +| --- | --- | +| A1 — FrameProfile sections | [#272](https://github.com/UNDERdecoded/Gen2Recomped/pull/272) | +| A2 — pinned external fixtures | [#102](https://github.com/UNDERdecoded/Gen2Recomped/pull/102) | +| M1 — module aliases | [#274](https://github.com/UNDERdecoded/Gen2Recomped/pull/274) | +| M2 — selected storage | [#155](https://github.com/UNDERdecoded/Gen2Recomped/pull/155) | +| M3 — datasets | [#122](https://github.com/UNDERdecoded/Gen2Recomped/pull/122) | +| M4 — actor/battle checkpoints | [#279](https://github.com/UNDERdecoded/Gen2Recomped/pull/279) | +| M5 — per-mod callback profiles | [#303](https://github.com/UNDERdecoded/Gen2Recomped/pull/303) | +| M6 — API-2 NPC controls | [#305](https://github.com/UNDERdecoded/Gen2Recomped/pull/305) | +| M7 — native registries | [#306](https://github.com/UNDERdecoded/Gen2Recomped/pull/306) | +| M8 — lifecycle producers/followers | [#312](https://github.com/UNDERdecoded/Gen2Recomped/pull/312) | +| P1 — VSync deadline | [#283](https://github.com/UNDERdecoded/Gen2Recomped/pull/283) | +| P2 — idle GC budget | [#288](https://github.com/UNDERdecoded/Gen2Recomped/pull/288) | +| P3 — hot-path dependencies/scratch | [#290](https://github.com/UNDERdecoded/Gen2Recomped/pull/290) | +| P4 — world drawing | [#291](https://github.com/UNDERdecoded/Gen2Recomped/pull/291) | +| P5 — palette/zone uploads | [#293](https://github.com/UNDERdecoded/Gen2Recomped/pull/293) | +| P6 — PCM/prewarming/DS worker | [#316](https://github.com/UNDERdecoded/Gen2Recomped/pull/316) | +| P7 — anchored shadows | [#319](https://github.com/UNDERdecoded/Gen2Recomped/pull/319) | +| P8 — world/water budgets | [#321](https://github.com/UNDERdecoded/Gen2Recomped/pull/321) | +| P9 — LOW/AUTO/UI quads and QA starter | [#323](https://github.com/UNDERdecoded/Gen2Recomped/pull/323) | + +## Acceptance + +The composed fork includes concurrent friend/trade and Gen-2 display changes. +Runtime revision 9532ee36 passes **493/493 engine suites**, 3 SDK suites, 253 +gallery checks, 200 API-3 comparisons and all 17 complete external entrypoints. + +The public M4 runtime 13808f9d passes **168/168 engine suites** and all remaining +gates. The public performance stack a620d0c7 passes **179/179**, 3 SDK suites, +253 gallery checks, 48 API-3 comparisons and all 17 complete entrypoints. +Repairs reuse #128–#134 and #294; the eleven inherited failing suites are +resolved on both final public stacks. + +LÖVE 11.5 and 12 each pass **14,520 checkpoint assertions** on the fork and public +M4 stack. CPU coverage spans all host selections (18 fork, 15 public), actual +native trainer/static producers, data-only story continuations and rollback. +That host uses synthetic content and substituted graphics; it is separate from +the actual GPU measurements below. See [checkpoint admission](https://github.com/Roxas2712/Gen2Recomped/blob/codex/upstream-native-checkpoints/docs/native-checkpoints.md) +and [exact qualification](https://github.com/Roxas2712/Gen2Recomped/blob/codex/upstream-native-checkpoints/docs/qa/checkpoint-runtime-m4.json). + +## Full-game reproduction + +Run tools/check_full_game_performance.py with --love, --emerald, --backend +(opengl or metal) and a new --out directory under this checkout's .local/qa. +A public checkout without the shared guard can pass --qa-tools pointing to the +fork's tools directory. Use a supported user-owned Emerald ROM. + +The guarded starter freezes the committed engine and unmodified mod manifest, +imports fresh content into an isolated QA identity and blocks legacy migration, +portable saves and external import roots. It uses native Game/Overworld and the +ordinary mod loader on Pacifidlog Town. Inputs are pinned before cleanup; the +shared free-space reserve is 20 GiB. + +Each flag is measured off/on over 180 warm-up and 240 measured frames, with two +120-frame FrameProfile windows. Simulation stays at 60 Hz. P1 uses cap 60 and +VSync 1; other phases use the highest normalized native cap (160 on these hosts) and VSync off. P7 applies a controlled +quarter-world-pixel camera pan after the native update. Voxel measurement waits +for quiescent native meshes and rejects missing sun sections/passes. + +One disclosed test-only change enables the existing native pacing/idle-GC body +in the frozen main file, because scripted drivers normally bypass it. No timing +implementation, callbacks or installed mod permissions change. Every measured +phase must contain the actual pacing section. Results retain the original +runtime/source hashes and driver hash. + +The desktop is an Apple M2 (ARM64). Metal completes every card; the additional +OpenGL replay completes the fourteen native phases, then stops at the shared +20 GiB reserve during voxel measurement. No complete OpenGL voxel qualification +is claimed for that replay. Separate earlier OpenGL component proofs remain. Sequential +FrameProfile wall/submission samples are not GPU completion timings or a +universal speedup claim; slower sections stay visible. P7 cast counts separately +show avoided static work. Component pixel, allocation, PCM/worker and rollback +proofs remain in the individual P1–P9 docs. + +Emerald does not exercise DS Nitro synthesis, GB palette uniforms or UI +quad-heavy menus; their separate component acceptance remains authoritative. +Physical Android/Switch/handheld performance: **not measured**, with no such +device available. No full-story checkpoint playthrough is claimed. Unsafe or +opaque checkpoint phases retain their documented named refusals. + +Every performance rollback flag remains available. Approved DS snapshots/ +fallbacks and per-mod callback profiles remain. Existing copyright and licence +notices are preserved. + +## Recorded FrameProfile samples + +Mean ms/frame, optimization off → on. Each state has two real windows. These +are measured samples, including regressions/noise, not attributed FPS gains. + +| Card / section | OpenGL native | Metal | +| --- | ---: | ---: | +| P1 / frame pacing: sleep | 0.3952 → 0.2675 | 10.7076 → 0.3172 | +| P2 / update: gc step | 0.0038 → 0.0004 | 0.0081 → 0.0038 | +| P3 / draw: whole frame | 0.6132 → 0.5207 | 2.4563 → 2.8336 | +| P4 / world: entities | 0.1381 → 0.1587 | 0.2993 → 0.1190 | +| P5 / renderer: zone blit | 0.0253 → 0.0269 | 0.0215 → 0.0227 | +| P6 / update: music | 0.0155 → 0.0352 | 0.0381 → 0.0445 | +| P7 / voxel: sun pass | not qualified in this replay | 0.9418 → 0.7756 | +| P8 / voxel: water | not qualified in this replay | 0.0913 → 0.0474 | +| P9 / draw: whole frame | 0.5573 → 0.5189 | 3.4081 → 3.9999 | + +P1 includes P2 idle GC within the pacing section. P7 replaces 240 legacy static +casts with 9 static and 240 small dynamic casts in the controlled moving-camera +Metal scene. P9 world timing does not exercise quad-heavy menus. See +[full source hashes and all samples](qa/performance-full-game-p1-p9.json). diff --git a/docs/qa/performance-full-game-p1-p9.json b/docs/qa/performance-full-game-p1-p9.json new file mode 100644 index 000000000..3d481540e --- /dev/null +++ b/docs/qa/performance-full-game-p1-p9.json @@ -0,0 +1,5194 @@ +{ + "schema": 1, + "date": "2026-10-26", + "tasks": [ + "A1", + "A2", + "M1", + "M2", + "M3", + "M4", + "M5", + "M6", + "M7", + "M8", + "P1", + "P2", + "P3", + "P4", + "P5", + "P6", + "P7", + "P8", + "P9" + ], + "forkAcceptance": { + "head": "9532ee36ac914cbe0e5b0dcee19b085013bdb27c", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + }, + "engineSuites": 493, + "sdkSuites": 3, + "galleryChecks": 253, + "contractComparisons": 200, + "completeFixtures": 17 + }, + "publicPerformanceAcceptance": { + "head": "a620d0c761dcb7bd23df99e8e1ad3d45015f187e", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + }, + "engineSuites": 179, + "sdkSuites": 3, + "galleryChecks": 253, + "contractComparisons": 48, + "completeFixtures": 17 + }, + "reusedRepairPrs": [ + 128, + 129, + 130, + 131, + 132, + 133, + 134, + 294 + ], + "metalFullGame": { + "schema": 1, + "sourceCommit": "79ff56cb4e471713d244255bbb64aa6512ec063a", + "romSha1": "f3ae088181bf583e55daf962a92bb46f4f1d07b7", + "backend": "metal", + "sourceSha256": { + "src/battle/UIVisibility.lua": "600197ada84c7fbd9bfa58fcdc221ccd38cce80f9e7043bc21ff683b53115ee0", + "src/battle/Gen5BallAnim.lua": "ceb53c18784778ff2c1991586bcd92335a30f9bf5c9c9f3452e3ea9330513569", + "src/battle/StatusHUDVisibility.lua": "b73ac63ea643d0e2ea937708478951da7148d21bc62e028ce27e7d16e3d612b1", + "src/battle/HeldItems.lua": "bde5344bba6956e248e79505976bfa71c5b7dc4a80447b8540fb1f0f12e694aa", + "src/battle/CatchTutorialDemo.lua": "b95dc2505c94b340f0c1a2f3d392668589a9d9d9d0498f15d3b6eb59b3a4d6ba", + "src/battle/BattleState.lua": "1e3595acf43a90ecd594b7b34047fde1474f1f996a713c8614cbc9d91a7f2171", + "src/battle/Gen5MoveRules.lua": "2ebe0de70ffbac510b54499273daabb3a79d70c299c79bf45b0209319aa2973a", + "src/battle/Gen5BagUse.lua": "0e18cdc0f7f7e1da16416800847b31e444bc029b303ed8ad9dc2083b24f62173", + "src/battle/Gen4ParticleSystem.lua": "105a8256ed5b5551d4f79f94c487c0b7dc69c5c6c200ac0a9fc86c73f5dc8eb2", + "src/battle/Experience.lua": "fd7ec2e6d5277aa0370440a07c6cb8f70dff5004b3df9722f08af9836abdd01e", + "src/battle/StatusRegistry.lua": "600832eb249b43c9b32a1f3f65170b2d9e2987db45703ffcf61f69e84fe831c4", + "src/battle/Gen4AnimMath.lua": "cd164c00821c9b0234858d92d1e974cff37e2496ec4055f8cef41638e0ddb53c", + "src/battle/Gen3Catching.lua": "399ce221feee2bf3cc81a54d4d33c135d9f9527b7a25b09478f2a657df32f128", + "src/battle/Gen3MoveAnim.lua": "e7a3435053b290ae630a9be13587251c26daabb1f3fdd37658e49e57993d3985", + "src/battle/Gen5BattleText.lua": "b7b238d4d48fbb97159f2dae12b1277340f0751c49065f2c0c65c3e27751bdd7", + "src/battle/Gen4Safari.lua": "303c46e10d53ac229b6543dd8b5fb091d66a33fb381f7b4fa53b2867cf23cc54", + "src/battle/MoveEffects.lua": "5dc6bb2796a9b1bb21e57ade45a48f82476df87f97e046a5ae68be48f9f0aac8", + "src/battle/Gen3BallAnim.lua": "a10f6b4bf16d4ee54904830886f115604575f5cda2f5fdee0c2593db1287b0f3", + "src/battle/Gen5BattleLayout.lua": "51195926a2d74680783f591a56e0cf0bf255e2625c32bcc69849dd6d11a5cf98", + "src/battle/Gen5BattleHud.lua": "46524b4552ab2c36671a134d7fad0919bda6f5722a207214a1ec6ea152372f38", + "src/battle/Gen3AI.lua": "2d944f72b2d6275319ff4c251794f029e629ad1397238ce2291da659ee33e0f2", + "src/battle/Catching.lua": "164cf1a6b00ad9958d83c1214f33b5781022ed8e75b29d8abe500f0e0322852f", + "src/battle/Abilities.lua": "53079a2b82727a9989820784f623a7a50b179be1409d34eee37c663a727934c2", + "src/battle/WideBattle.lua": "7e35a27babdc39e0428756687b2c1b64a8d0183cd501cdf31709c766bf9b09a1", + "src/battle/HoldItems.lua": "48887eeb4197c7217e55901e1f91fe2e4a87c415f0c5a6a5c36d6357e09b91a0", + "src/battle/MoveNavigation.lua": "c8e4fd7c551d30cd75970deed68cc5620dfe148c565c6bcd5067435ec8e4f8bf", + "src/battle/BattleCommands.lua": "00a4e73f863079cd9bee2d2dd744aab21b9d73be34223369dcee7c2af4a9751f", + "src/battle/Gen4EffectProjection.lua": "ba668d7eb6825cfdf0b34a269c9e76964b6ed794c4ee19e733f7e9a08e3c940a", + "src/battle/Gen4MoveAnimExtras.lua": "4ce596c6362b60804173e2d867311bcf7e9395217aed97d801a612d5740af0b1", + "src/battle/Gen5Catching.lua": "d9be0d52d2a53ad6802df83292bb935bfbb448bd14ee1af7d2301ae666ef7363", + "src/battle/Weather.lua": "7665d191df57714632dc83a29f6e966fa7b3fec41129a622ab3c23d931fec76d", + "src/battle/Damage.lua": "c55d7242134721caf6cca5be1c5fcf4c5c97e054f51727e9c6c00269c5c5dc58", + "src/battle/TurnOrder.lua": "f9c841ad20f22bc7eb828478bbff04fbe8e96ff692572c374d4902245a190a11", + "src/battle/Gen4Battle.lua": "7cd9bc8a24c04f013ddba6b2c42ff0e032f69ff7683badc960bc6f224984d506", + "src/battle/Gen4MoveAnimFuncsD.lua": "c5fad0ce87c3bca7f1e7180f849c0062d527cee3e1533d982a0de4e513e05e09", + "src/battle/Status.lua": "8692890ce34a182719059aa2212c6a52868b4e255bb16535a6f23f5ff45e7589", + "src/battle/Gen5Transform.lua": "fa4a03f55df9bbbda75848cc5113aa722b2cacf371ea6d33ae19c79bfa7ed86f", + "src/battle/Gen5BallArt.lua": "a8396ae0756aee84b3459bd671675e4d01f7fb83788aeaf8f6fdeb0c455d5049", + "src/battle/Targeting.lua": "1183c91394f2471ef7220d9be363d377f3b0e430c5a2b72ed15057ffd908df00", + "src/battle/Gen3Palace.lua": "38be0dedb25b586325345416717e2852375b89d7d80083b2d0c1d226e8493a09", + "src/battle/Gen2Wild.lua": "4eb811a4cd092abf7172ad636c42634343913e751a4b340300f12561937cc10e", + "src/battle/Gen4MoveAnimFuncsA.lua": "a9b3c80c1ed811a78e511915f97d1895696ed8fb624fc554a80d64cf1e36e33a", + "src/battle/Gen5HoldItems.lua": "02bb95f906c6c73ae1310d5c58690f2f1a71e95b4235283914038185ed250d87", + "src/battle/Gen4MoveAnimFuncsC.lua": "dca77cbaab4c992279ce088d6d813de96262743540461a768c2f88966c8147a1", + "src/battle/Gen4MoveAnimFuncsB.lua": "a4eefe96e3cb5fb835e4f16f7e5898762a54a85b281b75f10cf88da756e98525", + "src/battle/MenuAuxiliary.lua": "beccc76f06351d49fe8f1e1eff5895776022b2bf4dea1c13c398d8d55aa667bc", + "src/battle/Gen4BallAnim.lua": "4a74e0e38c2ac001bf9d92ddd105d5e01b8438c4f4cf086c1c7049751d1771c2", + "src/battle/TrainerPreparation.lua": "1b87ea363dada79c0a98ae3c428ded4d41c0f629d0086d750a3d3f0bfe6ac25b", + "src/battle/Gen5MoveAnimPlayer.lua": "5ada5221eb4fb9240d7841cd8471fe28ff15271146009b80b56a73f68d5e0b25", + "src/battle/Gen5MoveEffects.lua": "d04d5d17f2a4baff753c3aceb0f6db21b4a508c40c5b81a677fbd2792887f7e4", + "src/battle/Gen4Catching.lua": "a15ad440bf7ead2b5a66601d45254bd6b5ff4980ba349a82b3466d380a55a2de", + "src/battle/Gen4PalParkBattle.lua": "4d977d8821bf077706036e1bd70992261815dc1125c1b56a6c61b291aa56f40b", + "src/battle/PlayerPartyScope.lua": "61809f0bf8174d54b1e05a555a7fd3163ebfbc56b47a1c2886f7d6be813865a7", + "src/battle/EffectRegistry.lua": "d93b6be875e51d72b117b314abe796953c7f7e901787dda6d500dfb2ef3575fd", + "src/battle/Gen3Arena.lua": "88869a0930196459bcd397d4a8abc8683ed9c9e562e697815d9f1627911d323e", + "src/battle/TrainerAI.lua": "3b63e78374ca6fa67e94f16efaac69f474719533aed24c6ac1e992b7bed32225", + "src/battle/Gen3Battle.lua": "36ba133fb7958e3b34f19c1bf1b0bfc6c34b9a7e8947e6f5fcf9af3545083e79", + "src/battle/Gen4MoveAnimPlayer.lua": "9497c82bd1d4cdd37605bbcf33a68d84bbd6899702d105a3504eaf2a7691ee32", + "src/battle/Gen3MoveEffects.lua": "5e928cd27a981ef6b329dc57139b4ccee290ed16abf5e7f61b1a72206c8e92ef", + "src/battle/RulesetDefaults.lua": "bcae500de922d091bfb82ef019d9f0b1d8367947405f68e92d467f76b15b0c67", + "src/battle/AllyAI.lua": "020bda4fe688b6eb1ef4d3c3049ec442f568f05ec3ac499f59171248ea78c5c8", + "src/battle/TypeChart.lua": "c5590e777cda7866ad0c2f72ca07adbafadf86c72d93152f18ea855c74a76e18", + "src/battle/Gen2AnimPlayer.lua": "6649cb41ff65a36e922d4393193f74c22d9a51d560a007a769ad7ff2b00c7a7f", + "src/battle/AnimPlayer.lua": "1d661eb11bef56bee1397c289945f2cf3c4b6b4f93f4ddb9af09ee4e30ff7d64", + "src/battle/rulesets/gen5_bw.lua": "7097df1ebc7acba315ff2d846dcf9546896cec6cf009b980c4fce949fb72c790", + "src/battle/rulesets/gen4_platinum.lua": "d9c3cf93ff2dce9999042b14e406545ab7a15ee99f641678580462bd0a0aa249", + "src/battle/rulesets/gen3_emerald.lua": "949b57868329802122f24c311e7bf767f2c08b7a180ea2dd27e658d26bdf5749", + "src/battle/rulesets/gen1_faithful.lua": "ac29b54316cdda6f9cd0ba1fc1171cf33f0fde89a11a9c4c81dfcd3d6a861ab7", + "src/battle/rulesets/modern_clean.lua": "6214b30d23d4d694b40e403ed7d421bdd56c55013ab7c5568adb210867a905df", + "src/ui/EvolutionState.lua": "0d1440ab94daf5d6e5034c5a10a5efbbaf631e0b7938c6737a71e6b5d1034741", + "src/ui/Gen4ContestArt.lua": "e63767ea7fc3d86821cf280e0a5fcf7aeeddd838a8cbb198db14d1802f49cc49", + "src/ui/LauncherSecondScreen.lua": "9314468d38ca5414aef5d13cf65ddaa5509db8fd6ecb9ec8c66af87b6bedd49e", + "src/ui/LevelDisplay.lua": "3a410d4015a90f47a7b3c12268717e4d37e5bd8fdc5dd7339ecfe6de45502f05", + "src/ui/HgssClock.lua": "cd1395b2167b443e05c1f02897dd34a456e3c1be5a2effc39ccfb7de4924f25f", + "src/ui/Gen3StorageMenu.lua": "90ad2a4a68aeb03514a9d834f57e586efd88eed0205829ddd39ca41bb9a6573e", + "src/ui/Gen4SubMenuCursor.lua": "ad1cd35235513699dbc9ff258f0a25bf0122ee44012263037c62b58452ead8ff", + "src/ui/Gen4TrainerCard.lua": "f071467b45c87a79b0050f02ee3ad7226f0d16b7e3a2c725d70360e734943437", + "src/ui/Gen4CastleScreen.lua": "9ca25fd73061211055d0b44599084402c032bca6817c39e751253c83b2332e55", + "src/ui/HgssPokeathlonMedalList.lua": "b34eabb8a8f87e56b4e2ebb81e1338c9eecc156c9a7fb7b3b3e8aba053d89c4e", + "src/ui/HgssGearRadio.lua": "b6fcaaaefccf1885ba0b4ec9b88ef30b5073a449290936d1795140ebb0ed0c39", + "src/ui/Gen4EggHatchState.lua": "1f5f6e00c28f46a63184d2228a3ac2d03500ac22dc994b31e5cf46498beb2aaa", + "src/ui/HgssPokegear.lua": "80fdd1c0df89da536986a644c1c219451ccd48305ef7ae20a4cf27201af97e30", + "src/ui/Gen4MailView.lua": "54ea34b4e14e7973672555dba79601b582fb32b968ea6f475c725add301a824a", + "src/ui/Gen3ShopMenu.lua": "c6ae370e3ca1f057c1d7fc65d0f1a05c0d7199a6a852d061dd356c60935917ee", + "src/ui/Gen5Pokedex.lua": "a1f45d413732ad9abe900de04bbc38f45c0f5ac88779809db0a5352634d7ea34", + "src/ui/Gen4StorageMenu.lua": "faa6814972236d979889d70bee12fa98ecf4930a9b0e1dd84b3a4a67aaa8297b", + "src/ui/HgssDexData.lua": "6504809cb4f78ebda7891a1911ad64a9c97706d68941e3bb1a33dcea17e47b58", + "src/ui/TradeAnimKit.lua": "af4b12d22a1fca3e6fc760f348eadde155b82cabddb68ead012bdff5b04ac004", + "src/ui/Gen2Intro.lua": "79aa8477682ede51840cb5bfe5f1d86eedc09eb44a6e4768c87304fe67903a20", + "src/ui/Gen3Intro.lua": "7fc072529e039f498f6c998acfe9bf6a69246b502f7dbf2798233e085ee27ae2", + "src/ui/PokegearMenu.lua": "67ca68a227f1d5fec3382828ae8a430ae05f9eea7a832fed47c9a2b96886cdbf", + "src/ui/QuarantineReport.lua": "6f7295cbee9e45eba8fe257a4ad41f2bd6e4656f90d6c89f9fb1b87ea7421d0e", + "src/ui/PartyMenu.lua": "f0af6351146638a5a03cf3a00550d05dddfb89a0326ab52540c41935454c16c7", + "src/ui/Gen5XtransceiverCall.lua": "f68dec9948b07012936d9f9096089dbd0ac1a224070f18e032b85a5edba4511d", + "src/ui/HgssPokeathlonCountdown.lua": "3bf025bdb835838191451acb6a142f59384cc0fe44ea4588ccc7e4f3fc750d97", + "src/ui/Gen3TrainerCard.lua": "042fdba44664419e29f9ab60791c032540e61f77affcdc8325746a5b3f320dc9", + "src/ui/Gen3Wide.lua": "0d7af8363f415c6fd1abd7433f637e380de715fa7da53883970b8de29ba28c6a", + "src/ui/Gen5Opening.lua": "d93af8c0b8a353beec7137b91c451e5d5344410fd20c50d20b7bc06441e01011", + "src/ui/Gen2Slots.lua": "bee3134ed204d4d960f377fde0830438768e6ca99d7c8fefc668ab51db19b11c", + "src/ui/Gen3Slots.lua": "48af28af17389ed498c3b162eff8492c942617c9595b89bccc27c36b993dc9e3", + "src/ui/PlayerPC.lua": "19c8f6725a2c9c64e205a38b5478e88c9516095c4b6f262a8faa3c6697fd9346", + "src/ui/HgssBoxMenu.lua": "2ee8db418f55033f2dada3032e3b3549432f434d1bc32aab455d7cbecfe3a95a", + "src/ui/Gen3ItemMenu.lua": "30e406a301534c99a90ca609a3f92459e08c266ac23da7be88cd826e9ab613f5", + "src/ui/HallOfFame.lua": "34fe33aca758ce93eb3c03dcb0beadc09cad9ae8f4e83409f3a491870e1e0ebf", + "src/ui/Gen4TownMap.lua": "c03e2ff69c97cefcdd7fb975fe21ee1a6a7984c1561b77bff7a85ba6a352ebb0", + "src/ui/TownMap.lua": "065a23f2af1d921f9381d10f8d682f46f72fb528b8a3de7a089389331e435294", + "src/ui/Gen4PcHallOfFame.lua": "79bd36a93761528c51f01f9515064fc187f703885de24934a19186897d6d5e24", + "src/ui/Gen3FactoryScreen.lua": "e1e9988116bb329f0a47055883be9d31baed6db7eed6e17c0d04ac135d2ec205", + "src/ui/Gen3BagMenu.lua": "98c15a587096e342f052605dc2dac3b03ebb420b39812dfd9a45316fc7701bae", + "src/ui/NamingScreen.lua": "b6252722e9fe04c53ab26889c8ab9667c1f39b9bb3e0c8d432547e583ddf2f3e", + "src/ui/Diploma.lua": "cdce8ff10af233319bdc972d380c3409bfc753d8a27a9dffa1ed6a732711345c", + "src/ui/Gen3ItemPcSubmenu.lua": "d7b31e9b49c1bf8df3ccc978ca43c518fcc6bdc1be5e6ac0aabd85b10fc3e8b1", + "src/ui/Gen3IntroFRLG.lua": "ed6ff6e71f28623baaa596022ea0b24c1d931d6f9e6e1be5cd0a8668ebd51d30", + "src/ui/HgssTrainerCard.lua": "d3bf771c1960a9c9cbc2850731d6d8a9e2682a38c9467f65905d41c5d413c789", + "src/ui/HgssPokeathlonEventRecord.lua": "9f5c010a7e28096b662504e2d496a436daed99f15a0b12d91b7e0de243555650", + "src/ui/Gen5Title.lua": "080b218c14ea20296ec364f8ac2b781b84f1d2bd3ae740caa315af641915f66c", + "src/ui/Gen4Title.lua": "5432cfff6858b836f2cfabe0420d86163aece29cf7a30ad810d4fd6318c634e2", + "src/ui/Gen5TradeSequence.lua": "55fe6bc6cb426da9f40894f58fe3120a6db6957eba9a9d3d596fe1ea4ba8403f", + "src/ui/Gen4DistortionWarp.lua": "26d51974edca157b74ac369de3733321458a2ac8049fa0098fcbeb2a0a0bae2c", + "src/ui/Gen4UndergroundMenu.lua": "67fe58f627c3595218e96dc727c49fbec9e1fbb6cdb67b80b7a9493ee042afd8", + "src/ui/MoneyEntry.lua": "681af23091010c5b803c752a6645ffc54e553a44551b288fc3cd06d6c5f80fb4", + "src/ui/Screens.lua": "43b8487dd4ab6a1bf571e7c6ee724ba8c33963f1a163e5d2593ea9094dd2b2c2", + "src/ui/Gen3DexEntry.lua": "b7d1e489b282c2aaf982864f016754808c1a152c695a18f829d09edeb7bab440", + "src/ui/Gen5MusicalStage.lua": "c48e8f742a1a11fa112ee1914b5455dd3ebba66f02c921808c46de9ea4732525", + "src/ui/HgssOakIntro.lua": "a3ee433ae3691c77cea5e64631dc144501e63d567e86c91b904180fb0f10c47a", + "src/ui/PrismMemoryGame.lua": "b5ebf99db179d5d9f9ff4cf5eafb6e5265174f8c4664326f9969ae422316fd86", + "src/ui/StartMenu.lua": "c5b66a46b7a913cc7ae9529f41aba8097c29a7ba3f894c825aeb49a03a96475a", + "src/ui/PokedexMenu.lua": "317a6800ab386af0272ba43831424050d722f0ccb9732313b14ee41062162654", + "src/ui/HgssOpeningTimeline.lua": "5588442de6c5004a3953b7f74e112ada9ac2ee17b9fbf9c4bef99a23a12619e3", + "src/ui/Gen5NamingScreen.lua": "e9e495a34b409d704ee2a8a4bc930c9645737b8106f9078b0f27a2d6a65a6a80", + "src/ui/UnownPuzzle.lua": "11f334b82ca84053823c0da2aee45bcfaf20d18c7482465f44c5cf472f123d47", + "src/ui/Gen5TrainerCard.lua": "8349c6cb15e6ce4175c3cf0ae8b11cd7aa43b7c6cdf2e18d0b0df45e653246ca", + "src/ui/Gen4AccessoryShop.lua": "9c98c225665d1c381f27051cc946484776712ad85f43ca65ebd7766ac29ef8f6", + "src/ui/Gen3MoveLearnMenu.lua": "acc7bb74a9161cd734f03339b3819556a058d122f8dc9fe21e3ec8210c02b8b9", + "src/ui/Gen3DomeScreen.lua": "6d08a7facc76254c54f98aa503791b348938ea8f60af14fd079f1bd804e5ca7d", + "src/ui/HgssPokeathlonDataView.lua": "0de0aaf8a6dd6f6eaa4aca70e2c608eead049bb5de9911f58b2035129bdf3fac", + "src/ui/HgssSummaryMenu.lua": "d54024223eb2ad4985574f7e710e4c4969eee355c3a9a2dc1bc4f99662f33195", + "src/ui/HgssPokeathlonCirclePush.lua": "f2f7e6f12b0ef7a1e6d0f28cc64f0ee45720810576ba242f946ca2d877e1b890", + "src/ui/Gen5EggHatchState.lua": "4b6e79ed27e9ece3745bd6aea4d0e5adc6031d29ca9bfd3688f4b711ae3dbc43", + "src/ui/HgssPokeathlonLampJump.lua": "f17fc2d55819dcc3e048c881581810662c2f2a0c2c13e7a7388663e4cbfa6082", + "src/ui/Gen2CardFlip.lua": "70dd4c2feece53da7fce0eaf7d053da7db765cd79b454f532fd2efad5b9c85bb", + "src/ui/QuantityBox.lua": "f6f40911c453649c2c4fcf037549972e6a8437425c6f2e4eceda8cf945437597", + "src/ui/TradeAnim.lua": "5249809def089df91dd4ea0333bedf5071b022a635ad53db9ecb0ff106c9c551", + "src/ui/Gen3Roulette.lua": "4059523b8aa5507bba05a73501fb989c6d27b5607925e34f5b6f1c5766a93aad", + "src/ui/Gen3HallOfFameFRLG.lua": "74c6850229076e8ac4b7e4ecdb72e1c9c6def730c59a6948f3ccda2dc192d824", + "src/ui/HgssFlyMap.lua": "c0596df64cb61d1dafdba999b02d91a0cf70078d1f2164dfa9e04ecaddf8a3f3", + "src/ui/Gen4Migrate.lua": "327b2b0dce2697edf4cca4bbe7570d5e38bb4c0975b7091ba9123840425f0fff", + "src/ui/Gen5SummaryMenu.lua": "fbf5dad1baf126a76133f70c6a355bfbb7c7a9649e9246c0720767bd12991b6c", + "src/ui/Gen4HallTypeScreen.lua": "ea4d0fd087aa976b945b66662f032c4111291f20b64349dcb1d0e433e7f1509f", + "src/ui/Gen3Title.lua": "40996d8bb5d297969832a9321771972b4320f5e81799bfca82694bb66a229a1a", + "src/ui/Gen4MainMenu.lua": "a773f94cabb24b95d55b09c4401e86936c3e99fcde4ccd43e82892a472290fdb", + "src/ui/Gen4RowanIntro.lua": "682ef66ebca4df41ba1f128612d6541a24a63bd17582240d9ad31084baaea415", + "src/ui/TouchControlsEditor.lua": "5552115f87de8c7db46990b86810b1a100229082ebdc218eec3ce74cd0919747", + "src/ui/SurfingMinigame.lua": "494556db226f16e7d2fcee7c6e77d33f9e56b63333dcc8b3acce48466c476479", + "src/ui/HgssGearConfig.lua": "4be091831bba6af667874f10e9d37d70ab181e99c953cd28a61f95de6a851e90", + "src/ui/Gen4BagMenu.lua": "c4811c441252f15be357afa7d91cd9ca9548e6f5d6f5794de1bde541e9bf334b", + "src/ui/Gen4Poketch.lua": "f9f138777360c551a99e0be919b538cef8daa47473a45535536ded80c5965587", + "src/ui/Theme.lua": "cf3bbc6fe503972806430559c65fc98ef087993c98c046781daf190491b90969", + "src/ui/Gen3PokedexFRLG.lua": "c8c08bb23ea345f5bd0c49744b8b8432fa5160460dea7208a01928c6959ae440", + "src/ui/Gen3ItemPcFRLG.lua": "81397c395694d0b30e589123231db80a24eda784c4d3cdbbeadd81271651d9b8", + "src/ui/Gen4NamingScreen.lua": "778261bd7882d85eec2de1f9883961c525b0be78ad491583615c6a13de23225e", + "src/ui/WalkerEconomyPages.lua": "576f9f139896b3f1d4898f4801c64b64a3cba7ff7568cefbc3290027f8e5e810", + "src/ui/WalkerEventsPage.lua": "54909c9deca7badd0c080edcef74c86721a9a29a51356d5f7063c4e18d153653", + "src/ui/Gen3WallClock.lua": "b4054a21c8f34bbdbf784f1f8c336025fdb5453f0226e823e6a064ab0a659d1c", + "src/ui/HgssPhoneCall.lua": "0fa4b0e2657adac22750ce37ef9baffcb63404f2191188a78ecb1a819d2f9e57", + "src/ui/Gen2DecorationMenu.lua": "a807dbf7d58174bc51685df2b25f1abb1e44b88573848f53920906d562b4805f", + "src/ui/HgssDexDetails.lua": "96d15c04814e10f434fc7dfbc705b6cc543e59312cc5152c83cb4a99a440e999", + "src/ui/BagMenu.lua": "b7f6d8d9e9ccf739039d97f430760d0c25adbdca8ab3b61e63e42391c5650436", + "src/ui/HgssPokedex.lua": "c514532e2ccbf8cdae9173876a45108fa495fc3f0db7283c9e5f3a9d19bed6d0", + "src/ui/HgssTouchList.lua": "ee17e99118c43c6eb1b7826df561560d7326460a7bae92835a3115bfbd94d1f3", + "src/ui/IntroMovie.lua": "e06835504e3a20fe620c3a6b47fc996ac23a97da0ab94888236313909e12689d", + "src/ui/Gen4SummaryMenu.lua": "37f12989318534e4aee26bcadb21b4556df5e461d20893a0b25cb3f54fd22cf0", + "src/ui/HgssPhoneOutgoing.lua": "c776ee4325d546eec96ea950a8ca1523eab7dcc1d8a0ad373a6b3ad678b4a5ae", + "src/ui/ShopMenu.lua": "729bb3cd5162b07fe8490cb4f3662171e0d3b7edfbd54172434818f00e3cb6cf", + "src/ui/HgssTitleCamera.lua": "2022ed24115487166933271046d869c6cf45eca9c6ea29bb65cf3dcbd6240560", + "src/ui/HgssOpeningCities.lua": "a6067716772f446aecdc9974bc4da0876a23f1e3916d74e86e7f6103f387c6d6", + "src/ui/UnownWall.lua": "0148d186cc6c68e87f450cc5b89cb812d2e51a1fe46a42ac9a4ca5ad8507f1bd", + "src/ui/Gen5Intro.lua": "88a8968585b5cecbce0081610d0cbd3bcd6c3ec196ed8058257b6278ba024434", + "src/ui/Gen4HgssStarterSelect.lua": "e928b2262919ebc409510144b4cf8277d0ef92c200fce6d7b5216066ab756db9", + "src/ui/Gen4Intro.lua": "73b347c90f7dad958074ff87f48eddcb39f4add8b6f3ba47b9ff0d14e49a2713", + "src/ui/Gen4ArcadeScreen.lua": "33f34445512018d29dfb3c371836f5e1c26d3ee4f5165a0eaff09926ca1f1e8c", + "src/ui/PokedexUnownMode.lua": "36cf47201b7ec8d636a01c04deb37d0185a73329dc3549683317e38d20d46ea0", + "src/ui/Gen4BattleParty.lua": "35bbe52afd1782cd22bf1f39358d23f8cea8079033f5b096a8fd1eb8a80c16bb", + "src/ui/HgssFieldPanel.lua": "0f29dcd4d6a9d07465df9a3f14e00bf02aded5fbe59c9351bdf37d9bde335a89", + "src/ui/Gen3TeachyTV.lua": "0f84cf2d1d178a93935c6254daf11bda35cffd460e10814799e50284a0f8c433", + "src/ui/HgssPennantCapture.lua": "8361b8341bc2dbad5b37c82f4a4ea2f5b2808c891055f81070776077265f7536", + "src/ui/Gen3StarterSelect.lua": "02b74396e7bfc4edcf77e5abb61a12a35cf4ba2799fa846604743e1f9c6f4771", + "src/ui/Gen3EasyChat.lua": "c1295899b07604d02db1187a8efebc188ee83b0c0c9c79c6f52a6b51755122e4", + "src/ui/Gen4MiningScreen.lua": "88ec4922a3f31cc9749ab3a72162af291b75456d62d3c5e62c546840a3b545b0", + "src/ui/HgssOpeningMontage.lua": "aba21294f98a70cdd00a2ce7d42b40d52b96c910787c71877cd80a211946ee85", + "src/ui/Gen3SummaryMenu.lua": "179ce00d6c8fb7571327c799b13a1343f578aea64f891d7e146ad71d6f06b1d5", + "src/ui/HgssPokeathlonTeamInfo.lua": "205bde0fc63eda97f9e11cc61dc0f7383774de0c22bb8e32026da4f7e041771b", + "src/ui/Gen3PlayerPC.lua": "d04b3ae8b394b2e49cc83d87aa1f150348d2eeb1a687424eb89bb38c4e8f46f3", + "src/ui/Gen3Pokenav.lua": "1aa5c7c54256adc42d31f79da0c08de9370152e25e87f7eba86022bef4dabeda", + "src/ui/Gen5BoxMenu.lua": "77252728f818bc6ed6f4be89d41e44a82ee387c941786b505e6e1245e179bff5", + "src/ui/HgssPokeathlonRelayRun.lua": "7ad08a8b8d1277c5561db5065e87e74a485659f442ba0d0d6612a99968094813", + "src/ui/Gen4TradeSequence.lua": "4dff0507617b86fe93d5bf343c85c13dca9e6d1cd566d3207f2a653329aa0930", + "src/ui/Gen4RibbonData.lua": "c578bb1a0d7f028d8349e569ff1b3d7169329f53275905d6b3da10630ff7bac0", + "src/ui/Gen4ContestScreen.lua": "e0aa401a163c9ef1ae4dd7bd3f87a36e4ec523ff5b525ec5bfab84e148147878", + "src/ui/Gen3ItemPcQuantity.lua": "833ee03d79a95bc26375f0ed33508405f9b6a8f1d0bf19dde6697ca835a76f91", + "src/ui/ShaderPresetMenu.lua": "18792a77d28d91019bc9ca148e1a3625d04ac521065f6d0adf1c6b3ce4659adb", + "src/ui/HgssPokeathlonGoalRoll.lua": "8a199944077bc47a2b89716ef2e82381adb41748111824c3021c96917fb4705f", + "src/ui/Gen4Options.lua": "be509ee4d9f3985940032c848696c623007f20375661d05e7457bf16e1f3bb0d", + "src/ui/Gen4EvolutionState.lua": "46e260b5b90bb3ecf0ecd773c7568f1eeb5b0c70febd7a6b624b895b2f1d61db", + "src/ui/Gen3Credits.lua": "c9764cc224fa9a291e52d5073baa326c3e7ecd9763d140c0acba8e5bd57ce385", + "src/ui/HgssDexSearch.lua": "a3423bfcab8b612bb0b7590f2df9844738b415186b2b3314116467e2103c88a8", + "src/ui/Gen4PartyText.lua": "510c8adbced81f4f90598f838c36dd7097d8326dc492246ebe0085f319da6de3", + "src/ui/Gen5MusicalArt.lua": "82a186f3777d9dd7f586002dfee6d22fa1fe314edb73fa60143b242960569b00", + "src/ui/Menu.lua": "863e4207239a41021e339844c142011c7d8844f319bec8a29fe299d112cdabb3", + "src/ui/HgssAlphPuzzle.lua": "8e34cb25680bff6a4a1b3be66048de045f16ee3ff55c6f2dd6156e28656804a8", + "src/ui/Gen2MapRadio.lua": "b782279704778a43f62483dee65a9cbce24db3219fd4ea08919eb87d73ba2614", + "src/ui/Gen3BerryPouch.lua": "92fc90d00cbe34f24a503bd3f6aea7ee2e97a1c8a9e13d93facd445d74f8189f", + "src/ui/HgssTitle.lua": "5275939e552d7c3325a909b6e741ba3757fb2349ca41384b7dfe30727ac2187b", + "src/ui/HgssBagMenu.lua": "1d5413f9a42bb3934939b49a7e3671f92af1f0fa0b8deac32909051dde6e218e", + "src/ui/Gen3TMCase.lua": "7e992d68db63ae9e811378b2624e74d4ca9ddb93372bdffeea595fec52d27fec", + "src/ui/Gen4BattleBag.lua": "8d2cdd45d191935da322c3d04db2c443f1087d449ed550a5ae49d331a3e1800a", + "src/ui/Gen2TradeAnim.lua": "3c931b12efd082a03ce3529641db5007843846f3927d8250660c24c4c0e85f28", + "src/ui/Gen4StarterSelect.lua": "c8634bef9ea6f94bfecc0992605afacf462c5c38c75d1fb0767c1adf63047e65", + "src/ui/HgssOpeningMontageView.lua": "87fd48ecd297830ed04164f9985adab41294c060b5c0e7477dd60c78cafe29e0", + "src/ui/OakSpeech.lua": "c16a09eeb311b5179365dde8453a0ce9d99fb53617ff9c6e049202ec5b6b8668", + "src/ui/HgssCertificate.lua": "6d82df59254068fbe2174ffe2765e32436fb7e7cb7d9db8aef81447a50d42f85", + "src/ui/Gen4MoneyWindow.lua": "e9e1a3bcb9667c42fbfc62e51150e0475e87d1e5886686300091cbdf86ce0e29", + "src/ui/Gen3MainMenu.lua": "ac18f602a36940e86d75df9a532775a4e041c946a1e9dfe2aff868e17330dd50", + "src/ui/Gen3BoxMenu.lua": "b56c3829b9f82d4c01d278ef6745e8cec3a3cc7ce111151ecf5c3db0c72478fd", + "src/ui/OptionsMenu.lua": "c86b5a0fd748ca23388c92f2a17cb0f1af9cd30c89ee97c00d2471f7397f1980", + "src/ui/Gen3Cutscene.lua": "594924b8c77189f20893859abd50f1b57f254230c021b7d1a45ce34a3931e2fc", + "src/ui/PokewalkerScreen.lua": "28e71f44cd8f612502cf0586cc5f45bf8b9603f2e76b3696f6f937bd3e17e556", + "src/ui/PicBox.lua": "94b34c0d6294a556905e1cdc50677430d3f21e2d306c0998003c6bb7b4a2c7e7", + "src/ui/Gen3Contest.lua": "efc798dac760c3dc68b5cb4e92df47d7fcd343ff2f85589d9b250040b4a755c9", + "src/ui/Gen5EvolutionState.lua": "0d5a71bfa8f5ebd1212499ae6f2dac1a4c7d61da5fb4829f6d432ff4b590c357", + "src/ui/Gen4Pokedex.lua": "a6b73daa89b1ded03be44882fb95ebaa47eba0fbcdf82e8a038a682234b2b238", + "src/ui/WalkerMinigames.lua": "b970ad35e8cb26f01d055e53989be0631013d5a02a4b4645688c47010fd30155", + "src/ui/Gen4ScratchOffCards.lua": "cde782d137ed00a1a7fb3d3a661eff2e7048741f860eda679efb364bb2b63a35", + "src/ui/Gen3DexSearch.lua": "841894ee565391b94cc14af24525a06f8c3f4bcd7b5feae53c448b150201e101", + "src/ui/ChoiceBox.lua": "1f60d3c77f444650e5a13f20f5339fd1e7769d8873ea9d562db0d43a17d52dc8", + "src/ui/Gen4DexSearch.lua": "3b76665a24e243d29294f83ac30d54739ba600d01778d788e13f2ced1e64df85", + "src/ui/HgssGearPhone.lua": "7c0ce609c686338bbabc039a03958bafc50d878048826c0596cf4e33b1aed7ad", + "src/ui/MoveLearnMenu.lua": "c40eb3652c5754ea1469089ef819bfef1d13ecacf5d24cc1337043fcf79362ed", + "src/ui/Gen5BattleBag.lua": "e16d1ae40cdeb6a0184e4f2e6c57405ffd66a178c35efc18d35d50e5a8b2d32a", + "src/ui/Gen4Opening.lua": "e0c971dec219ca967744e22a97d4992e9a2ee3c93ca6d7be09eeb5da3ecf92ca", + "src/ui/HgssPokeathlonCourse.lua": "3b20b79fb5addb8b3b500c31be6af41a1e47cd7e1089f74a726f906eb42b58f2", + "src/ui/Gen2ContestComparison.lua": "d8ab913d7dd49d99789ae731e2eb4233fa5065c0fe923254cfa36280a62892c0", + "src/ui/Gen3TradeAnim.lua": "8a919282d50e8578e3ae6b466c06289fd1973de0954f8528c3d70df279b10022", + "src/ui/HgssPokeathlonRoomView.lua": "641838be687f6267d759a8f3193b103493ff4d250df362ad31b2689f22c472e0", + "src/ui/Gen3CreditsFRLG.lua": "0bc37ab2fd7a98be57deb3bb770e88e72ad7c3726ac351b01c60948256561c30", + "src/ui/Gen5TownMap.lua": "dd2aba3972cc7172ff31dd589825ec455595d2f749d21d888def459132b363a6", + "src/ui/TrainerCard.lua": "48315148aaffc2c2c7c5c37e837eb9150f301c10a18cafe97fb954ea6b057d3e", + "src/ui/Gen3BerryBlender.lua": "6d60232e2fa1be575334bc7d071249efba807e3e0c0eed0e104e196b0a329d6d", + "src/ui/Gen5MusicalDressUp.lua": "65f583c2931a31b11488d198f57ecca0fe3e1b79c6334499d70422da49af6637", + "src/ui/Gen4WalkerConnect.lua": "10431e89b283ab18bc6c9a5e953672bd006b454401e8f888dc1abe3d06cfe7e3", + "src/ui/Gen3FRLGScenes.lua": "049dac8ba3b8217a4ff3cfdc3104a7ef15c304cef8cba1be626d25ffc00b7dc6", + "src/ui/Gen4Journal.lua": "5b57ba4ebf6bf180f80688190da4dfe887733ccb9c47604eb9af0e4e58955e98", + "src/ui/HgssMartBuy.lua": "e322ee49e0d2cc497fde12362e837b88a64efc081a2b0cb00f3c7bc70f50469a", + "src/ui/HgssPokeathlonBlockSmash.lua": "24dbb732fba05d7da848005419e9e94f11ec78e6622ce9e1da6e77562fbf7263", + "src/ui/HgssAlphInscription.lua": "12066f922807082b882ca9d39d80172cb9b0fe016133e9156e349cf00030f5b5", + "src/ui/ListMenu.lua": "24bac8ea7278f0b2da111717c21541618fc6165acbab4d38dc01c87d0da0781a", + "src/ui/HgssPokeathlonSnowThrow.lua": "1922c223e021690456f5134318d5927c6b435af395095e7d28595128622960f5", + "src/ui/HgssVoltorbFlip.lua": "fd4c10e13a96bdf85d84fa813521fba69d31f68a198ffd9f80ce8142d7e879b1", + "src/ui/FlyMenu.lua": "bdc11a42cdb0e32369fd4a0764baf2e73a6808f55cccbc23b17801b694287dfa", + "src/ui/Gen3StartMenu.lua": "d4ee792db03e79e6151d50b1d03370cf67a70f389fbf1798677f29da19c8dc96", + "src/ui/Credits.lua": "f720b9a78d52c4eef5319f8ffe1ef9f468689af8575ec6e7937c74bffff94599", + "src/ui/Gen4StartMenu.lua": "3934a213e7d86dfc9197a361ab39199d5632f4b2ee144e744ee80be3c3e88ea0", + "src/ui/HgssBoxMove.lua": "89b1065ab200574e26854e2bbb9cd1ebf6c68fb26b8be4bd7605f5e3e6d450e8", + "src/ui/PrismCustomization.lua": "f1e2d1e869519fc017826d3d618a95100509d609db049f43837b31f5a27e010a", + "src/ui/DexEntryMenu.lua": "9cffbceb3bab7b2bcde2bcc6dcc9d3d0a42b70fbfa5824c27916b20e87f01af8", + "src/ui/Gen3PokeblockCase.lua": "ebd540b1913d91ebc0ac4ef5417c98fc090f8ea9ca135cdc799d4b50d9586705", + "src/ui/Gen4HallOfFame.lua": "2980892573d8b4b92db9be17d406a599c0b71ffd88857534e672589f641825c4", + "src/ui/Gen4ContestActing.lua": "3f78727a609a283c497b91b7a60ec85bfe58daf9388b8d10e552d3b60c55fe6c", + "src/ui/Gen5StarterSelect.lua": "41c3b5db701dc3ab1ea77694bac71f408f7f5cd67bacc62225316c22a9bfdca6", + "src/ui/Gen4PoffinFeed.lua": "498ba053c674d5d0bea66834cca558f1d34cdfa504a4afb299518b5eeec80a13", + "src/ui/BindingsMenu.lua": "3b2858144fa31361f4db6db1175002a9f6fd80deafca1419b9eb2ec727b10cfb", + "src/ui/SummaryMenu.lua": "f014172b3ee93c93a732be9d03d52b25b6ab8591274241d7337f9abf70282fc9", + "src/ui/SecondScreen.lua": "a728bcd7ea1f823db411b88884d61bf7d1a531a78d6bd7f85991e28f2b6e6e90", + "src/ui/Gen4FrontierScene.lua": "9096b387eefc33d2d25ce6352e45c834a78c9f28b2dbfd6d29be99d4039901c2", + "src/ui/Gen3Pokedex.lua": "42acd8745692338b592b1306f7bb248d2e4053045798c9ed0403bfa313f1e62e", + "src/ui/Gen3RegionMap.lua": "0d2034ba087b0e015c50a32cf18cdc2893bdd8969573a55c6199e020327285a8", + "src/ui/Gen4BoxMenu.lua": "e50a3f05facc2a520fecdf2754aeebdf75f91f4156721821aee29c3588590df9", + "src/ui/Gen4ShardCostWindow.lua": "df42ea1017d2ea64ab025576e0e825a9a426980ad1d453afcb38d89baa848743", + "src/ui/HgssGearMap.lua": "c3f787f46d4200c0987c7b894858b3779d271e3af00c1503b52e4bf9d8eef26f", + "src/ui/HgssIntro.lua": "fcb139d8bbd971ef0847364a09a31cdc6743557becceb69f119f56d5b045c86a", + "src/ui/Gen3EvolutionState.lua": "1dc2114072edc6b75b6930a1765a68323aa6ead2e5b476f3e80d4df05bb636ec", + "src/ui/Gen5PokeTransferScreen.lua": "1aee38660616c9353ec5f0b998a7ff8b4223303f765372883adaec1c8ff65258", + "src/ui/HgssPokeathlonTrophies.lua": "f9fe63e173f238289fdc09f7f2ae92b1c0060d6cb46642ba32255fc43012b283", + "src/ui/Gen3ItemPcMessage.lua": "458a268dae7059403506829dd3c3caff4861806a7dc9bcdb1fd5123fd43e6713", + "src/ui/BirchSpeech.lua": "24e40adf075f0d2257e18e39f9df50f1535ca5576b348e1c9c3a03565e68358c", + "src/ui/UICommands.lua": "fa135cb2b1ebf141b090766f2f8c0d8b5c0bacc951e666ebd71326fe321952cd", + "src/ui/SlotMachine.lua": "0aceaee28a607742f64dc5fc64cac3850013891021d154b5ccf89cfa29125481", + "src/ui/Gen5StartMenu.lua": "9b3ce8251c6b4c87ffc523748a7aa31940d7a048bd745193f5d0acbf075917bf", + "src/ui/HgssPokeathlonRingDrop.lua": "6398b107500f3b811182cf058062527713b7d4d743a50dec59718e64754012b6", + "src/ui/SaveCompanion.lua": "1263bf267d2b1912cdaa773415a175467ed5e677405e55f4d13ddfaeb1ed0c3c", + "src/ui/HgssPokeathlonCourseRecord.lua": "d399e3907ad85afa8e681760fa9a54c39698ce72e53bb3cc26a30bd8f984abd0", + "src/ui/Gen3TitleFRLG.lua": "c46254e45b77a7ea3a229d8902653654598c1343d80e4714c5ce7b3ae73d22bf", + "src/ui/HgssPokeathlonDiscCatch.lua": "56a7b387ab2fb2d082e891cf094d4d4f54d3b2bb2e9a56a2354ad4677ec77ae4", + "src/ui/TitleState.lua": "106de4b502d05177332d09381e99c998ac04f09a7593df3e131061a5688959c3", + "src/ui/WalkerTransferPage.lua": "79073f02787b5db6037ddda9b02848d55b3324fe13a49355db717867da2bfc4b", + "src/ui/Gen5MoveLearnMenu.lua": "5c49f3787b2744d2ce3e6a4180f2107e7730f9d7cae93405c1e68d13e7c95388", + "src/ui/Gen3MysteryGift.lua": "c444c43813ee3a87d99d70bc690cb56e6b22933a823f555cdaf2525a7b9a70fa", + "src/ui/BoxMenu.lua": "191454836526c59348cb0e685798014f29c8317c550f0481eabaa25a614e5b4d", + "src/ui/OptionRows.lua": "56d4b42fd4b3f0fe41423bf38d86c5c9d1652df685017402cfee18a3999ab5cf", + "src/ui/Gen4PartyMenu.lua": "8772be0b466aaecd75c2c087e17d42bdb6f939b0c04350647cae870a6454be8b", + "src/ui/HgssStartMenu.lua": "1715b637d40196fc9ffc79ace82ad717c1d1ea15e15030eeaf163993ca594284", + "src/ui/Gen3PartyMenu.lua": "094dd19022c7ad14ff9a82b72edc4f8a2a044831417e7eb258c62a1f7d5f2ebf", + "src/ui/HgssOpeningStarters.lua": "b8b213b9199556d009d61cf05720b3649992203381ca05cc48ee4820f14fbf42", + "src/ui/Gen4PoffinCase.lua": "efc7a43abde953b18ebffc523d3e286b834c9e04eed390f2ae08f60b6683c94d", + "src/ui/Gen5JuniperIntro.lua": "64c8457d211b3bb1507fb852d2e5b03a137e2a7c54c8bf3bffcc1eb7921885d4", + "src/ui/Gen4PoffinCooking.lua": "b1dee6a913efa2a2ea553a4e7cd12667558c33301c0e85ad693a2c10f35dc9bd", + "src/ui/HgssHurdleDash.lua": "5a60939facafa40e714d44cc101b57657ed5a33aca91d8782a9700e98b1657c8", + "src/ui/Gen5MainMenu.lua": "83cbd10e1506395cf85b4617e3be2a1dd814deff18e93382d9b042b337c42b0b", + "src/ui/Gen3PokeblockFeed.lua": "293f097cee17664ad804d221e670dc19fe2638d2eec21646817d7782be4c56f0", + "src/ui/Gen4ContestDance.lua": "720bbfa3ff782d5ffa3fd0b2abdf8ab9e2072e1f6eab811bae938c7b88bc0bdc", + "src/ui/Gen3OakSpeechFRLG.lua": "0baca0b700fe0eada7757e3d37f877d4f9b45e3b0ab8dc44ee4031dfd93c9c62", + "src/ui/PartyNavigation.lua": "836b85304e7a179874b9a11a4f56f1953229a9a09234c8636333b0da2dc57cfb", + "src/ui/Gen4Credits.lua": "a2046fd3d165edb8cb6c77f93f9755f457d792746737c8cb64c129b342364e3f", + "src/ui/Gen3Options.lua": "914cd7abb3464cb5eeb10927ce047091885ce100f26b86260b1679ee4ba2bde4", + "src/ui/Gen3HallOfFame.lua": "c9d3deebef87f988068f5bc5daa9c4992502548493606f0862ac7ef81a34f82d", + "src/ui/ModUI.lua": "931c6df3e57da87cc76ff39c84ce3d672dc763d7a612c970120ca8dbcd62baf9", + "src/ui/HgssAthleteShop.lua": "c9a06c6ceab242a11145fc8028a0e4ea9d7aa38ccbb05b3aadfb42a46bb05a01", + "src/ui/Gen4ShopMenu.lua": "caebbfa8806a310cc41c8928916f3cc8e7749acdef1bedcf01235e03569fa12b", + "src/ui/YellowIntro.lua": "c7851836241741a65832095bd238029c47fdc5ecdc6697db48b954dfbaa4811a", + "src/ui/Gen5BagMenu.lua": "e6cc1316c29fc47f941bb3b8cc66eba7d265f601df6bb603fa6788a3325dfcea", + "src/ui/Gen4ContestDressUp.lua": "88dbfac2f014fe9fb416d24d9ab3a6a1208c654cae68a0734b05d52201e349dc", + "src/save_convert/GenSave.lua": "63dca97d05aa168af19255314dce09c2faa1634d4c8ed24aa9706dc6ca707c27", + "src/save_convert/Gen4Save.lua": "2bb314f0d7d63cccc98770f124112f11047b44e2fb6ca90921a62a39009f06d0", + "src/save_convert/SaveConvert.lua": "b0e29d96689cba01676771dacfb8ae5e8253fb90dc2e8cb262837fe8f7bbadc6", + "src/save_convert/Gen5Save.lua": "a2c0ed364b2a65a52df1996a603dd2f7fc8a26d047c969f46837c64197e049b2", + "src/save_convert/SaveVariants.lua": "462aca96b045623e78b02f75d0e0206928ac186a14930f3d745af0477054d21d", + "src/save_convert/NdsSave.lua": "f0b4b1d0648510d88bf8edcc683a60d575332a05b73d4970fea305bb0f322376", + "src/save_convert/Gen3Save.lua": "53749665d5fdb324c346a783a9e5b1eadd0968ae89cbb069add000dd78c00f3c", + "src/save_convert/Gen2Save.lua": "b445a38b4cca31243f3688c09468e214dd54fbce16aea98fa049c1179fc33296", + "src/save_convert/data/event_flags.lua": "a3ba7b8a78ecd25544c9a7458490ac950522c5433d7be5a45ae2477bab047d82", + "src/save_convert/data/charmap.lua": "4493a302d288f3f58deb4f1ef6263e3f478b3cf48d397214d6b8f91714e53679", + "src/compat/gen2/Decorations.lua": "90ad63d1af5327db26ff6051177a5f1c9a0de3bfa9b91cf037f2ffb67bed94bc", + "src/compat/gen2/Unown.lua": "9a7a279701aa93a65c7506880f0f0cd1337c9ec20d22f4ca778844094c8219f5", + "src/compat/game3/Pokemon.lua": "149cd6bf9c157211cc33873efd32b6b59e797443c55386e80dce2ef6eda873d4", + "src/compat/game3/Follower.lua": "be2802a3f6df2b8f1af7acdb1e06fb88613149432ecee2792baaace2ec64d438", + "src/compat/game3/Player.lua": "ab9dda23fc4a567e0709165543f372e7745e1d1c52dbfd7822f32fc49c05fbae", + "src/net/download_worker.lua": "c564e6c10d860ac80a1dda00b57f15aab2998c1b20d44a3c9c68634872c3dfa2", + "src/net/get_worker.lua": "ed10f3cfdcb4d6a1511895358560d9e0e4f917e633ff5df3759cba0db70f3d56", + "src/net/AsyncDownload.lua": "e4fc65812d3e2f10a42b525831f297ce600ba42a26a772d48bd3060cf3caf6eb", + "src/net/DownloadTransport.lua": "dc04ccebd710987804d99f000aa78a0457069004d61daa1c98b9697325f5b7cd", + "src/net/DownloadPublish.lua": "8f03058feb3ab8a8275727c9441f5da326733ec8617adebaa5511ad7c9b33d3e", + "src/net/BoundedHttp.lua": "65f4d828b8230facbb0af12fa35384a57fe2ae9325468d9c4398699cd9939516", + "src/net/Fetch.lua": "b24ae3e02f56d3101eb515d599c7afa4acf3b74cf3596fc0bada2d9c13378fdb", + "src/net/AsyncGet.lua": "50548cdb6b9a99af09d34164e558613ff27bcbe7acc49e8c783f936c0f783c01", + "src/core/SaveSafety.lua": "25cd1b2e41a0701791af7df3de374b1d2f2045b57ac78ca89aac20060ab0060d", + "src/core/FrameProfile.lua": "d4b78fee874116094b6c4eaf41a992c72c4a09b034455868d1df2aabc7fc83ff", + "src/core/Logger.lua": "5fda12d50031d1546bd3c139b6ca03c922b28df1355b2704d961141196e349b9", + "src/core/ModCompat.lua": "e9387e6e3785101785402b5336e473e15ac99a7150ebf047cae0c77015ef73a9", + "src/core/Game.lua": "38519435a2df34c1b2a421d390d15084bd664978973d3b0ca9c2047a17cda701", + "src/core/SwitchAudioCheck.lua": "237729a8ca83950763cad050657fea4a93dbdcb5d59bdc8b62f0af39d03bbcff", + "src/core/Timing.lua": "cb435e57baa9b31e4505b9e833fb437975b47ea121375d0d4370b12dd6b56828", + "src/core/FaithfulRes.lua": "0f83f5799c4266886b66dbd7ff72fdcf38ecb8a368a0a09bfdc72cde6f670e20", + "src/core/ChipSynth.lua": "ec359285be3e9bf4b015d2e3a9ff08527793719a489a89bdf2d1c6a0e4bac179", + "src/core/FrameCap.lua": "6fdbfb74b2bc6aa703c1bf459f5512dd24ecb10f790a25ce58b4533f89748985", + "src/core/M4ASynth.lua": "0d60dcd9cb91a44f40186b9f1eb55c28c400b28960f5a4a21eb19d19c3c10a77", + "src/core/FixedStep.lua": "8661197ec7c8b56249d6d22aa4a4450344bbbbfb0eaeae69ffce8598e80ce1fc", + "src/core/GameVersion.lua": "647f64abeeba74c4919988194dd72ad72bfa64b554d5ca5c35b45025467eaef4", + "src/core/TouchControls.lua": "74f9bd28f6ca9f15bff6f40e409d31d645bc24147139acfaa7b578f02b083d81", + "src/core/Music.lua": "349a213316df07dd293a272d0e91131438c68e2b18286310af755c4a962a6432", + "src/core/Platform.lua": "8004601cc72f53a3172ac5cbed60e7ba7eee2660b71a308409cf5b72ae730817", + "src/core/PlatformHooks.lua": "26b0400455351b5ab7a62ff07e87eadf4c7e9cc5404ac2bbfb7c244eba20de62", + "src/core/Sound.lua": "7473527636d2198916c16ee40322af12805439925dd4ae5b61f594d3190b4eec", + "src/core/GCFrameBudget.lua": "c04ebd1f405e9a44b2d4736bad879be18cd20cde8d6d6f7e21c1fb8289a1a6fa", + "src/core/Probe.lua": "6337c006fc69641a82365a59de0533a8c3387c4101d0365eda5d69a5659fd3a9", + "src/core/Strings.lua": "bac84f31a3936077eb33657d73860f70e1fb1c2b687eb8e3fff22e9121b12438", + "src/core/Version.lua": "7cedb981ac1af4d7a5be48760ec132e76ac05e4fa119b9fa51ca6652ebf30ff9", + "src/core/GenOptions.lua": "ed70d3e154c68ed1ce6bd27ceca6368f239b6a9f9ad1d8cee724907d8a5026b8", + "src/core/VideoMode.lua": "7c3ba205bf4c72b9c0e781ba83349730461084bd248b19d68e17414daef140ba", + "src/core/chip_worker.lua": "c58df4dc639d4d8427527fc1fac8671014ccadbca81238060183523f723eaf38", + "src/core/Data.lua": "46d654a0d75a63c78ccc93cf78f2190747c60b907f5edce58d6b9eb1ee1ad578", + "src/core/ChipAudio.lua": "e7e5cde5da08915386269450d822d027d17afab961b0db8e6016010d17717541", + "src/core/Frames.lua": "61a196f05bb3bd6f9e81f82932d7d255894cb5632049a74e54471736df6658b4", + "src/core/BootTrace.lua": "d452fa775acda243b88be3d81cac241cb5b64fd24d30e8ba7aaf2504c85e4a1f", + "src/core/WorkerFs.lua": "3371e316eefbda6d0c0853ff29d48a07fd0838801316cd72486dd3154e605bda", + "src/core/Gen4FieldBgm.lua": "1b9555acf9ae2be8fedf320d10c6a22e313ae8acf782298a957b61e679b87888", + "src/core/SaveSerializer.lua": "d514673f891d2b0031e9d3be1188f12251df38b174c2f1f2702d906b4c5a40f7", + "src/core/SaveData.lua": "0cba28aee3289d5365d0741a4f04037ecab09b2c0c0851703796e20d7a852b6d", + "src/core/PlaythroughIdentity.lua": "0684db29075e4da99cbd982b6774b5a6d64b6b08dd314c9df0cb327db5436e49", + "src/core/Input.lua": "9611aa6ee452f2fe8c375ca9e63c537142a4d835d5a1dd8361b4f417d1287946", + "src/core/HostShell.lua": "4d1040763ea11afcc34897c10e93338fde4e63f7b1465387ce91a8b1056728a5", + "src/core/Gen2Clock.lua": "c945b2e022ac474cbc89e738eac17c1cf12a1534e5355de857a0a4d39ff88954", + "src/core/Performance.lua": "def2c6b3d5e93ce8a10c10f03b40f30ac05997a1330f3ed49b8d1395399b4877", + "src/core/AudioPcm.lua": "02f0e7f94f2195e1044b3013762705f7a5e6fb8abf2ee292df2c2965e815848f", + "src/core/DiscordPresence.lua": "34e085bbdc684848456740ff161946cddc33937be2ec9539a2a0f84d190e68b6", + "src/core/SaveIdentity.lua": "c29abc5c2fbc2e2bd63f45bff26df0c8c165fa57079f1c2ba5d3280e8301075f", + "src/core/GameSpeed.lua": "0937c6163ff30d52b0a73f473bdf0c39baa49b05095802b1e27a4d561be9ff33", + "src/core/StateStack.lua": "3bbc4cb728616456cf217a613695832b42d7f5e32172327a117d7ba60375d0c2", + "src/core/Printer.lua": "f3bf9159d4e55efd2ebff9a5a2058c2f240ba4ea9741c44c26bcd1a3f522a1d4", + "src/core/SafeArea.lua": "ebd6f51d0ba4ea8084d780ef6cb8e38607fb045b40116539870dd870752e7f9c", + "src/core/HotModules.lua": "b01daa4828d61b49e04e3e644fc12436649870c620a5ac5a37f07b20d8afd74c", + "src/core/GamepadMap.lua": "4165d796d291bce1b92c65db14f7e3713d2e64edfe93dab3cb7f758441f803ab", + "src/update/Check.lua": "2dafe9f4e55013bed77fbf2b98824977cc8c97f4e8a35228ea75e0542d42756b", + "src/update/check_worker.lua": "1b0a04a4d454f5cf9fb935d924ab00b8e666f3880e5bb00b13db2cc572d20c2b", + "src/update/Sideload.lua": "6a638755037e6350cdd1eaddd568b5f7e65a4afc4c0547b61769d524af946c54", + "src/update/Boot.lua": "9f69847735eaa61ba6159deb401cf6329d039b592445bff465ae5562a25f2d0e", + "src/update/Semver.lua": "747bd2c36e5ee5f21962c90f1b92ae9cbff81fe02595cc3230c63ee03d9ca009", + "src/update/Payload.lua": "0578193aa8cb207dcb4aac09f6c9c46b4fb429ea8a53d43a26e3f2fa6dfda2cd", + "src/translation/DesktopProcess.lua": "7e3809ecf8cb1969be84c486b0bbd3646147b37a106896bdc36f29937da55e22", + "src/translation/Progress.lua": "e1d3ca95125cd523d3e41a3dad97fe0a61896fe71fe1544673965ff901744ee9", + "src/translation/Sources.lua": "3921e20e615831e20d86332703839391104fb16020b066a4d7a8f948533144b2", + "src/translation/Languages.lua": "adcee1b322a0b87b2d8ba6cb77cc4d08a304f44fb2059bd8456cc4a9ee76d2e2", + "src/translation/Service.lua": "bc98fef6ae105f422e8bd7b0a2d0bc6a051715c20ab275d4ac565dba7d199706", + "src/translation/worker.lua": "c98246c6fc7ce9b01cdde65817a30c5b2679a4bdecaa2bc7c4f81e29c1226bb5", + "src/translation/Glossary.lua": "6f4d4847f1a03ad3efd834fa9bd6077a04de876a3b35f1140ad3cee8b542ab84", + "src/translation/Plan.lua": "492470c2f0149327833b2b3f887684a3fe784789a45e08cf33da95374fac73de", + "src/translation/UnicodeFont.lua": "0a423fa4e7dacad9fbda93fc349a3daff8f6b9f3dfbf30c6fa9a2caa21cf2755", + "src/pokemon/HgssPokeathlonStats.lua": "31628816a5a56ca109a7439a4b951cbee5106c3994907d231bac8ad3d050e1ec", + "src/pokemon/Gen3Friendship.lua": "6d446e11174a0c1ed6e982cfdb01613df817820595089e565d5f3307fad1a291", + "src/pokemon/Gen4Origin.lua": "cb22737b53d6efbba112e2843407927936c6cc3ba651ad323b0583711af5450a", + "src/pokemon/Evolution.lua": "d194acd7ab28d1b5c5b9e760d202c565d14dde6427b1ab1bc801cc68b9d34b12", + "src/pokemon/MonAnim.lua": "59691a65169bc9b0ce930470f12a0da21f1f47363fb630025503d745e326380e", + "src/pokemon/Gen4Friendship.lua": "2807a1adaebf6e01722fe3749457e3de98d2e6e396f68ae5c423c4b7e8a46c56", + "src/pokemon/Gen5DayCare.lua": "2a1cd040f745caf2b631bd83738f1cf50198bd381eddaf0fbe03d36686dd8731", + "src/pokemon/Gen3EggCycles.lua": "cbd9383936bc84d86e0e1e05101c02b06583425255853a1692ce474dd2240fe6", + "src/pokemon/Pokemon.lua": "53f1ff84914e7aebc8bfcbdb7faea1ed61736d7b4dc344b28fefdc3ee51c9a94", + "src/pokemon/Gen4GameRecords.lua": "a31088992c3722ea1778cb6a165933dbe85b9f6d3f649c1790a0961bb6fb63b3", + "src/pokemon/SpeciesRecords.lua": "1af8e1cc2eed08346ee2eeefe9349d998456e172e160c07a1b620f7879d773e6", + "src/pokemon/Gen2Breeding.lua": "371d8c281a97ad9331f57cc193579b9de76b40fccbfaf941b05642d196793cfc", + "src/pokemon/Party.lua": "8064594c12048242e0a61632ece302f41582e4c12a09fa6ea74e708b8c79783c", + "src/pokemon/PicAnim.lua": "26ff5f4e3146ebe240b373c6fd45134e25b5be11fcc6adabc46cffea454c4298", + "src/pokemon/Boxes.lua": "6ea0047fe392271b443d867d941b219912433bb973a212203f36e62aab448344", + "src/pokemon/PolishedUnown.lua": "60d8b72428b7eae321f5c5b0293384d6fce2e8d9aa9ef2cf41f0c97b58c9aab7", + "src/pokemon/HgssPokeathlonRecords.lua": "6ac8f1066292cd29f8b23ecb5ed93d38d42fcc9a2fb84d2adf084b44fe840d46", + "src/pokemon/Gen4PoffinStir.lua": "0b975f69f83a3d33cdaa44c40d34c7b4be8e833fbf28c64c714aea49f280c025", + "src/pokemon/Gen4EasyChat.lua": "b9768bcec271f43aec8ffb34d8824c3e4773a380dab9d63a4e1570224d292cc6", + "src/pokemon/Gen45Evolution.lua": "348adf147879b7f94407873217febca64eafffe95583c9781b824bde6f3932cc", + "src/pokemon/Stats.lua": "31e3b8f78ed066ed0e01444c006c0026a7ab1d8f0a465344e1498693dcf2b8aa", + "src/pokemon/CrossGen.lua": "9e5e540dfe0496ed71cfb8cf01a53fe9a2a1540cd0042020114bcda2c32742f0", + "src/pokemon/Gen4DayCare.lua": "43086444bf7a1f4ee7eaa39f3388bacf19dc127ff8d1b0bf1b2dfcf85b6b7eef", + "src/pokemon/HgssAprijuice.lua": "c15ebba14ec8d884428c9532053d86dfa0a0a3a321bbeb0a421df6f7a32d64ee", + "src/pokemon/DayCare.lua": "cd1a932861cfa66bda9143b52b259f7b3c0314fee6a7407f1b7419a625b1e5dd", + "src/pokemon/Gen5MonAnimPlayer.lua": "86cb49032a5cfc4ca473fb5fe1171373408c93e26110392918cac9019400dff3", + "src/pokemon/Gen2Pokerus.lua": "94fe1ecb7945435d3cdafe43ac0896d4c7687009968b42a07d21ae137b4614c4", + "src/pokemon/Gen4BattleTower.lua": "b874980ae561b28c0a6a4db2c5c8e7b40472f52065223ff3cd136343f73af4a8", + "src/pokemon/Growth.lua": "4b9b8baf993e3776f8dfeaba6725e3ffe78a86749d39f67da1c357355f1a2f7a", + "src/pokemon/Gen45Hatch.lua": "ad8f22ff848b05fe706de9bea0fd9fa8348c0cbd9c80a6c4695f533c165c9c9a", + "src/pokemon/Gen4PoketchState.lua": "8007aac4c6b414b9237f866a17c9134c07cc8fee7841050587389adab449bc04", + "src/pokemon/Gen2Unown.lua": "ede45a06d81d3f20eda5fbcaa5a64a75dfdb1fc44be3cda405b81f963ddc918f", + "src/pokemon/Gen4Journal.lua": "8cf25a548b4aef57e629bc640a7ba81058e58d764ed6b8be7a1b00b99a1efa3d", + "src/pokemon/Gen4MonMotion.lua": "df0972c8c62720286ad3a39aa2690357094a491375ac58313927b24b37f2fe36", + "src/pokemon/Gen5MysteryGift.lua": "e4c9d1fc8e3f0fa5cde670c868e05e9eb5d945cf50f022af3b41c412dc761632", + "src/pokemon/Gen4ContestActing.lua": "0c6b53c1e3084ec2739c09e8f547904cd0aff21f70530b9a7fbac860a2724520", + "src/pokemon/Gen4Contest.lua": "45278d3f1b721d7859847eee1bd336c20cf2672a0b9e5a4e60be732083a40c95", + "src/pokemon/Gen4MachineLearnsets.lua": "5ebc355efc5f61fa88dfad9cd34a90ae4cee105ea12c786637f2dc5d82f75aeb", + "src/pokemon/Sprites.lua": "95d309add02e174d0588624902cccd5a2f0bc0424cff7e630bbd78d14edc1f0d", + "src/pokemon/Gen3GameStats.lua": "ec0d9b7ab50f70ee7fbfd84f5a7c38a8f03e87082da0ff508803ae9a4a1e17e1", + "src/pokemon/Gen2Friendship.lua": "6e2036a747919ef5d5cdd9300887ecce7918f50d4ba21711d7ae801b7d056338", + "src/pokemon/Gen3Breeding.lua": "2434c32ca4dae4013824c5d5adfbae57052f2cb4e5b9bbdd16f44f49e344ee1d", + "src/pokemon/Gen4Forms.lua": "a4b3a243e9aeb8453bd9d64a255eaf37b020f415c4d736f146fdb76c266f5fc5", + "src/pokemon/Contest.lua": "ecd763b0d2e9d6e1acff06e62353224280c4c10ebcc36b3f8dd355acf45e5549", + "src/pokemon/VoltorbFlipGame.lua": "0005e75f654864e3a18cfe2e3d723505452e821b1c769ec34bae783570d40c84", + "src/pokemon/Gen4ContestDance.lua": "d55a9d2eb5ff919b0ba752208aa30deea6d0b50eff562336b61e395c7d3b3b4d", + "src/pokemon/HgssSizeRecord.lua": "1cb097e1c2fa9a13c0720b458668aceea82797d08595f7791d3fba2fc7a6588c", + "src/pokemon/Gen4MysteryGift.lua": "2e20bace544bff4c4883dcd345a780971fed99444d7ce0f1629897df4e3f850d", + "src/pokemon/Gen4Poffin.lua": "b168af7131c896314044ae7757b630e8e6bf3fccf3717b6a8969665363981d9d", + "src/pokemon/PartyAside.lua": "c1c615312cbe135d39a3cd9622e19da66b24b0ff1ced85971b917185619ac617", + "src/pokemon/HgssPokeathlonPerformance.lua": "626adfb86e15a5adbfa0ae92e1601c88930f594c7b96b1e07eb6c41c15199cc2", + "src/render/ShaderGraph.lua": "a3d20dbdefe342d408a7cbafc76a6973c4b5dce9de85a0bb68e610eaa49f3827", + "src/render/SmoothScale.lua": "ef088406cf4bd3b7c4ede5ec144a195fe910af9588b66df00bacbf43069ed0a7", + "src/render/Gen4Palettes.lua": "7ae2b70ca2b5c8f1577e54d8d767d3a5e1517104f3fcd78468eb2da98f5a17f8", + "src/render/TileRenderer.lua": "f977403edf3f7c38dee0f01b85ab8a8602a1680a4d9ba81e891476c67691b732", + "src/render/Gen5MeshWipes.lua": "d3fc2fe4b4723ba419b9e8f0e9eda8a2e776fd667f75ac6a8f306a0089c096e4", + "src/render/Gen4TexAnim.lua": "cdbb68351a591838311e5e77eb24938801ccf318b06187bf35ee27e6893c4c56", + "src/render/ShaderPresets.lua": "a3613779438a7a8961ec2d8590146213a1479641897e19cc7b762473ec8a56aa", + "src/render/TextBox.lua": "6ccdc8b5b4622502e3b3036a935d98f9e2ca14fbb37c0d92a0ffe7a2920732e2", + "src/render/Gen4RangeFile.lua": "4c7d0d0e51f2b8cc07d84d7dc680d684c6c7c711fb459bfcd6fcac0061cbae9c", + "src/render/FontHD.lua": "301f2c75771f5b6800bf3ffd719081397b60baa666ca644295996cde259b433e", + "src/render/Gen4View.lua": "6c7e374cc956796712366edf70538728e9dfb71b728a185915e5a9bce79d438e", + "src/render/StadiumArt.lua": "4b86edfb96f75977c9a18392ac7bdf4e34e726069b8e24e8f179166c565730d5", + "src/render/GBCFX.lua": "c36b867d809493928632cf64db6c8b605a8b80ee3f1536ed1fa587740ba0c6e8", + "src/render/Gen4DepthFog.lua": "67d89d19a168066b8761310ca9228a9d2f22514b5fbc5adf55cf8cf24a380420", + "src/render/Renderer.lua": "21fe4903a2e4907ecf84917f081a45a0cfa37c5ea91248126f8def91e61ed63a", + "src/render/Zoom.lua": "b908263bd8c8716c201085758bab90c1ca76dfeb6c0acf4c11d7f7488957d25d", + "src/render/Tilt.lua": "72d9ca5048e4e1025157e1962401cf3cd54687d07e5d465ddb520f91b7bfa351", + "src/render/GbcColorCorrection.lua": "0a96ee109bc9d01d46ed2ef944a4b7777b4b61ed2a0505796129c361ac937dc4", + "src/render/Gen3PaletteFrameCache.lua": "244ff347dfb5d9a565fb5f8e821c7b3d6408e0ebbff92b29a70ec6d387f33e04", + "src/render/Gen5CutIn.lua": "35dfa373d8baddb3a84a52460445216ffc5907cc9a9d3647fab208e749fa22da", + "src/render/Gen4Shade.lua": "304ee2b1f64dacb11bcba3840b2acdd04b71260f3ff7238c2499a2657f651921", + "src/render/Gen4Decals.lua": "41fa92005c990bf5bcafae89082b3298925bd4501f4e71001fcdf7ded1527da8", + "src/render/Gen4SnowView.lua": "aeea7785df118f7c16a58a656d454bda288884cde6ccbcf17590a630c0380389", + "src/render/Gen3BadgeArt.lua": "ca5362ca7b3b4f70aa7a833acaa4c6a4ac0c0bd2441687f23a3579c041d7ba1e", + "src/render/Gen4Model.lua": "0c647dfb1c1330a8bef196fa2d4c620ce4e17b62ee8a0e1bd636e02dff0248e6", + "src/render/ClipSpaceCompat.lua": "84738259f75eb74b6464ea49e1c910b3d990af1f85ab2b49efd91131bdafb1d7", + "src/render/Font.lua": "b17fa2c8cc363e0dabbf9fe70a34ef9e35ef2e2ca8968bdfe5e97a409b470676", + "src/render/Gen3Scene.lua": "2c54b2b4a82e9d5f807b5c381f0d63fb8d51379f0d67e1bd67f3195ba5319458", + "src/render/Gen5Seasons.lua": "ce34c8ee03adedd73309380f8e159f0bf324b58d65b175ad812bca9c3eb5d69e", + "src/render/Gen4Camera.lua": "0fcc80363e5ea3b01df7bd3e506240fe1cc494bac0ae3e807416ee4e567e3d81", + "src/render/HgssBattleTransition.lua": "967ba591e97cf4b15b15105f6c21c4677e6bf156612c254e326c83fa5e5c7de6", + "src/render/Gen3RegionMapArt.lua": "dffa3cec761c834a504be92eb300e37633cc846dbd8d6e85902ee4932776b7c3", + "src/render/Gen3PaletteSamples.lua": "8000353f6260a0ab2712b8e27899c8b2a4442f7143b22e0f6a6ec020a67e9e42", + "src/render/PlayerPalette.lua": "7d6d2c3dc2c5839a8a23e9258e2c4b4fc58ca158fc05ebae71ad163fd0767dc4", + "src/render/Gen3PaletteCycles.lua": "3f898dfe6d823b16555b8b11793c632e093223bf8c7d851fe8210d4426a26e2c", + "src/render/Gen3Tiles.lua": "918f7a94773a79ada6a9f8d25599a2f8c88c46ac6e07622fdd0b318ca4258fc9", + "src/render/SpriteRenderer.lua": "47a2ea002e6509dda539ccea295ccaf8d70efa63504f85a0401183cf2906068a", + "src/render/Gen4DistSkyView.lua": "dc33ce269e895283fb2ca69856bf8b5e4536df89619cf412de1ed817322ecb40", + "src/render/NativeOverlay.lua": "cac9a9b8cd82dfd3d8e0fed466260db8766206a98810a5df571909dee3ad4b8e", + "src/render/Gen4DistNeighbour.lua": "c473e65eae5fbaee93640419890ee9206c2b9ea582e50546ca127bbbe67888b2", + "src/render/SuperSample.lua": "27204884f78b1c62703967d1cb5ba7c67e1c7fa873b9e71cf9afb66d5171eee8", + "src/render/Transition.lua": "9fe68f8d52ef1c205ffb81ac3c9593924b35c68be271384e071d65c94ff6bc7b", + "src/render/Gen5Particles.lua": "83ddee32b398e9d7f7a338b161e411994568ef9d1955ed0cd1dc1725f9b03359", + "src/render/Gen4Ground.lua": "60d20d82661baeefa1595fd5936794fbd982aeeffe43213b939e62f4c6999e07", + "src/render/Gen4PropClock.lua": "970ec21f1b8feca24daa90de2e174bafc23d24cd18a5a28458886a725223dc39", + "src/render/Gen5BattleTransition.lua": "853e31b5996f308742a278cbc32c82f71adf0eb0372c198fb962e005265da88e", + "src/render/SecondScreen.lua": "a517cb5c9e8793f1c906cd2a30f5bfc0092224210fbb9d81f27eeb3e7231d814", + "src/render/HudTiles.lua": "70001ac9a15847163791daf7713541c5bad407ae81ea6ade0e8a2ff1776c11cd", + "src/render/Gen4BattleTransition.lua": "671cb91b85b97812b9cfa3c321f1a3597bed0619a5cdfbf297c5d5d1be547dfa", + "src/render/UiQuad.lua": "8d7715e0bbfc21d2408c6652def9e01474a7dedcbc5bc9e15f0cf39b60aa73b3", + "src/render/Gen4FlashDark.lua": "d4f015b425ad0b22a8a3037f48fc90ed3d0321fd45f7a3d3a3ec8b228151b1a6", + "src/render/Camera.lua": "93034192070d121064b46586ebcd01ed078d849fea5e57e56790eb73f29f4764", + "src/render/PaletteFX.lua": "e0710ed3342eb954b9bbf8d38524a9f0a0599fe686995e5b29616b135dab0350", + "src/render/Gen5BattleSceneView.lua": "e1c2baa488e6abad9578e17d5a90211c584f352c2f4f83096519e139f45c4792", + "src/render/ScreenSpaceUI.lua": "80b89a94dd82f7c23bf3be6a9c560a5df924cda5bd27ca6b7a498e25846abeae", + "src/render/GraphicsStack.lua": "90d91dadb97db351b827b902ad1e244657dc3b6f6f68fd003778a80e9b93428f", + "src/render/PixelCanvas.lua": "9aab73def33ea50dad0be046b603f4ddfc47a4bea2dc7fd9163b250ee92a6fce", + "src/render/Gen4Shadows.lua": "9faa161fd00cbf268cb44b7fb4528ec359acfd628ce6aec0f41a5286952c7960", + "src/render/Gen4TerrainAnim.lua": "013a73a61e6e2771c8542e3aaa6b787d1ff90d91f0e2c5c87790d0fb6738cfc5", + "src/render/Pipelines.lua": "0a66ecfaa64a26c6acf73d8d723d2da8f174736a848ce2a4a39f7742294e1c24", + "src/render/BattleTransition.lua": "04ee9e9d57b0ecaf6f20b56cf22738ba9c0b0f23443dd80aacde834c7d1df3cf", + "src/render/Assets.lua": "61e8bfaf95d02dc747a5bba2b2f04c2193cc44d49dd47844c695eb413c9ad966", + "src/world/Gen4MiningDig.lua": "ab421310dd873b2e1dc2b89cf7cfb2e088e50a45cf5a6ba534577ecb9807311a", + "src/world/FieldDefaults.lua": "e933384c57fe9bd41b9126e816d8ffb6eb7d06060675df2843eed781ed3a08ac", + "src/world/NPC-1.lua": "f5afef5669d5a093d3ff87f5d80a7b67304030503789d79164e78e1e8c9c40a1", + "src/world/Gen4Follower.lua": "28720d093a702fa1d7de5db1595384df525e2885b95b3193005a1bb108717f92", + "src/world/Gen3PartyFieldMoves.lua": "f79ab45abb018e21691dbbae167328438a44fc65140067c2037ea9542a39a53f", + "src/world/Gen4BikeRamp.lua": "480c7aa07b0dd804d87f748ab8ddd2b451594146283dcb731c0c112382f6940a", + "src/world/Gen3MirageTower.lua": "54deab7eb1d3c3a5cc6aa0aadf58a129249bc5fdc161395ec8a9446526ae84a9", + "src/world/Gen5PostWarp.lua": "1ecc290dc41a3479f5ebb577dcf847582727e8efedd4261ebd3ea085c1abab4a", + "src/world/HgssDowsing.lua": "c80ac3504cca1f7a5ceb3e07a121f4de33b01061dab0b73d7c26147c0d9ce0a0", + "src/world/Gen5DriftveilGym.lua": "53390d35e563a9d189f4207389c18027a0aaa2e11b788c673b0e8c7b2f8dbd4d", + "src/world/BattleTower.lua": "7dce1d7969883a7b72bd938c67c64f85fb414bcdc91ec035f461ba1b6ec1ed55", + "src/world/Gen4WaterfallRide.lua": "ad5451023c10ae105cbdb44f9a574368deec165cadc3a1079f2825dd72b8b999", + "src/world/Gen2PhoneService.lua": "911f896bbecd7ec9f9bffcfb28869b081caf8ddf60b4f54f3090d94690319768", + "src/world/Gen3Regi.lua": "19846fa019db07aacee64aa7102d7ea12bfad263edad341b11f8096da8f56eac", + "src/world/Gen4MiningWall.lua": "5671157dbb2df3598d1870312b15b69e6f7c93241f821b7554d41b1382200e7b", + "src/world/Warp.lua": "301f46eb37e99387f27f52e4c3abb7080e213c445144e21711d48063fb6f3004", + "src/world/HgssFollowMon.lua": "97d46c8269253063147d59d8161ddb153f5aa51f530e215dfc5579802c9c8f1c", + "src/world/Gen4EmoteDraw.lua": "e2fbe11bd963cdec98bef0a11b33a275729d58334d25239ccef63680ae508609", + "src/world/ElevatorShake.lua": "165b745105f3eda0bd2fcd6a99133bbff7773dc8f4bc7e56315205bdecb47378", + "src/world/Gen3MapPreviewFRLG.lua": "0b5a4c7eeeb9729f2f0ab71354f2a9021762d6bd61746ab2799f9f60d580e943", + "src/world/Gen2MomShopping.lua": "10a38333800908257a3babe12c7dc7a6fa87840f1a20ee4a78e8d24863b119ff", + "src/world/Gen4DistPlatforms.lua": "7e3f2889b6f74df570ea95f7295826818f58b1c896416a3f380ea92097f63d02", + "src/world/Gen3ScreenFx.lua": "a81221b5798231c9c8862cd2574d8853a34a20f578337dbf8a7671db7144b714", + "src/world/Gen3Gates.lua": "60e8f9ed4d902a02df2185033afd1b313e36630ea101098c75192c984642d1c0", + "src/world/Collision.lua": "1cd1823f40449f39e2ea9d50e5b77af5baeace3349eefafa1027069bd69b6675", + "src/world/HgssRadioMusic.lua": "31abb2d994f3e7c5178f2ed8667ed9a3213d00b1215396067ed4dc8c5c8cf863", + "src/world/Gen4BerryPatches.lua": "5086bbdc8dfe100ddcdf20cb5a26fa155c0c384b62d4dbe2a480107f771d8986", + "src/world/Gen4HeightRule.lua": "201316ce02b80ea6286e4d17c571ff56d0c1594f0941b9275bc2e9442bbd1a3f", + "src/world/Gen4DistSurface.lua": "e6cd12c7d71b4ff986dc787d44abed654e3d3d7fb06917ad3db0891b701ed3fd", + "src/world/Gen3Weather.lua": "fb58d9b356c1657618ee7f456d5bfa2b079c256c43c3789a36fe1283b180e09d", + "src/world/HgssHeadbuttTrees.lua": "17f2afdc706896a6ee82a7fbd52dc77cdd27a11c086da43761fe6b3efe4f262c", + "src/world/Gen3Roamers.lua": "c3839b47fc45cf85c1640aaac073ffb5dfc9b33fdfdd1c8c27d81877e2c4a217", + "src/world/Gen4Blackout.lua": "6df473b487071fc9fcf10f2517cd2f9a94c7a494ceabd11a1f519145fb7d18e1", + "src/world/Gen4SurfMount.lua": "3b1acc82ad42e2ff5c2c92fe96a0c89867ab7cfecd881eaa24c22275af6a2ac1", + "src/world/HgssEcruteakGym.lua": "bb39781e6ab9091a204c4a542e72b490ea00fb5e77cfeb182fe9199f48ea9e24", + "src/world/Gen4Roamers.lua": "2cf121c4dcd00c7d3892da2aaaa755a8618c10a33fb08bdb567d92343b46ac4b", + "src/world/Gen4RockClimbRide.lua": "5397973226ade97ae619842c0ea8569dfd9b75625cf581b780d95e90ac800773", + "src/world/Gen4FogWeather.lua": "03ff596d9313e0098c2a85fb94ebe159d20926acb705c79636befda3db8db3ac", + "src/world/HgssLegendCutscene.lua": "55ae12d9fb590facd7c947810f4a63009e18effc0abdd424d0b1a90278129a08", + "src/world/Gen4VsSeeker.lua": "8c7dbdccda4eecf756eff50d00984e9733345d6894b582fd19bfc093e885e203", + "src/world/HgssAzaleaGym.lua": "cd29cdc598bdc272f5e68c36dca9ec58d33b277b025599733fc65d20a043bd25", + "src/world/Map.lua": "44353af9b8af816430eb4c84e5dfcbed090eb9745c48e007bc650f794bca2639", + "src/world/Gen4Fishing.lua": "c0f6623612bf88f336a0214c78b9107d91739e0f72ae8b92d0262fe6c0352d9c", + "src/world/Gen4Radar.lua": "cd698b32d6b5be85efcd5f25644f66fb32bac883ae2eae8fbba4807518763928", + "src/world/Gen5WildTables.lua": "da6fb0ec87df07dc00c0cb3efc703a3d2fe66f423f5202589a87035cfce590a6", + "src/world/Gen3WindowMetatiles.lua": "a86fa613a0dd72ced1e227a0c0c6df53cde2a663e2c4a9a6a97cc3f1e7134240", + "src/world/MapLoader.lua": "cd2c61efb9fba2a909ff0f37d71432a20a468fee4e6cfd97a19d65fbbf0befe9", + "src/world/Gen2RadioAudio.lua": "552fc687f9952925fc62483b1204c49b2649bf6743e7d1b4f5840afaef9bf7df", + "src/world/Gen3SecretBase.lua": "d64d3267223d99feba94d08f8ca3d28af15562ba8644c268e87795c5279ca564", + "src/world/Gen4CyclingRoad.lua": "14c2d8ff5afd8f2bd82ddd553413c302c334630385af67373e33bb4c2e1e0af8", + "src/world/OverworldController.lua": "236d822c2f17f90a66297700b337a58b27824b7833600889da27c8e2a868de0c", + "src/world/Gen4HoneyTrees.lua": "b8b1d6d9bb3be4f08d23567b2c6587693ac9ee408fb0cd65e6ea7dcf9178aa64", + "src/world/HgssAlphChambers.lua": "7e13d21f52a6440850c832d0da6d87bd7eb7653f5913c116a1552fe5971f6497", + "src/world/Gen2Radio.lua": "7c1ea177b1db24f9d7eee3b4506d4b7c97078e19ce9d4226a92982c119e7b6be", + "src/world/WorldAPI.lua": "4aad5cdbca1af2ec1973401e7658fae2ababea9188ca47b88afb5a3275b342ed", + "src/world/Gen3Flash.lua": "74976c9a31386a8e5bfed8eb5b7fefc2818737ea80ffe9b9045d9a372816cebe", + "src/world/Gen5Phenomena.lua": "0d3eb2c6021acb52523fc4a0a67083e5a2508dd7a2f614d7f997526f08f71c63", + "src/world/HgssSafariZone.lua": "79ef6ea09d1e0f304b99c01714fdb89492834e9d17707e61d2e111019bc33489", + "src/world/Gen5Dowsing.lua": "4eaac34726a0b104feb775e51da20cacd53ac7829b5f0b9d4757923197d0f70f", + "src/world/Gen5Fly.lua": "2547903dc15272479f9585134f7221f7739f1d39148682e42ade5c1ba11c70e5", + "src/world/HgssModelObjects.lua": "0ff2fc75483bfdd220fc9d24d9924ae0ed5c8443b58acd1ffc38fb7167a46643", + "src/world/PikachuFollower.lua": "2230cd1a5d1947a8d082d880bc4f047b8cd4561b03b8d6feb86643b5bf8f3103", + "src/world/Gen4DynamicMapFeatures.lua": "2a84a05714b149e3a2d95701dd3b314a7c09c8d9cfcb3283e2b08cdcfaa741ea", + "src/world/HgssBugContest.lua": "8d139882d36542d35af894366df9f2a38e725bcc17d9d00f5a520c0505d98dbb", + "src/world/Gen5FieldRules.lua": "df672840abe99fc305414f580b3329356c69677c051926ad70460cf5c46994be", + "src/world/Gen2Decorations.lua": "0628ed321b3e44c051b35fa99f7dbdf5186044e4191058847b5a7ef8a05df04e", + "src/world/Gen5Quicksand.lua": "8a3d0485031784181ebba7eacb660190fe7a175ab1d5456e76650e9f672c1c44", + "src/world/HgssRoamers.lua": "d1886475de202a8f18a8da37150a24856b2638c461b2d6be737ad83c6bf2e8ad", + "src/world/Gen4FieldFx.lua": "6a9587ee8450b47ee49fe657b5e2b7db25ccbca6e3f48b6977dc07d5633a4949", + "src/world/Gen4EncounterEffect.lua": "9d266f523046d0aa27047366dc72dfa814ae9f7a632774764a5020c60532bb7a", + "src/world/Gen2Swarms.lua": "1a04e392c24160be40a547fa15f9091e90f36c943f6bc72d7a2899b8acb1e12a", + "src/world/Gen2ApricornPrograms.lua": "0c85d4ee533f00c98d0de6e58db80c07bfb077b8b22dfdf8347983fcf71b1f8f", + "src/world/Gen4AreaPopup.lua": "2e9e5beeaee8166306a4c7d5150da06754c3bd5b949d213872871a0af7104c76", + "src/world/Gen5Warps.lua": "7ec56009932ae400d5fda85e01a177c456e354b09c1482a27ae9d5eef2a52b8a", + "src/world/Gen5FishingLure.lua": "e9c0ec18d7686979936aa96e47114c5b77efc4332459d38b292705d7c97417c3", + "src/world/Gen4DailySlots.lua": "40c6d21c629cf60bf7fb0f18b32e6ccd1c308c4e49eb7011db39821eb90c920d", + "src/world/Gen4MarshLookout.lua": "3db4957bc039fd5823d43d5b829cbf9b557d11804577c0aad897bf41799565bd", + "src/world/Gen4Swarms.lua": "873709b72fae71c615c8bbe8390dc1d02a27e56d182cc71bbc8867c9ded33480", + "src/world/Encounter.lua": "200602b2f9842cd2d06d15988a534c9366d4d4f91395ddf27f6daacd4bc0b2f6", + "src/world/Gen2Apricorns.lua": "253c130f2402618af5fd90f27b7484ca8fbed7d2f13232ed05fa020aa0a7baf8", + "src/world/Gen5DragonGym.lua": "ae0d5378022dd2ce4fa154c2575a87c49d3b14231d52c6fcc6cdda8be7758a79", + "src/world/NPC.lua": "75c6214ebf2c3b7a75fca60b14b30b650a31376c15143ca76e3ea320975c5b99", + "src/world/Gen4Jump.lua": "a9b827fb8d355a0192a1e0f50b565724feb2377ef5a6cb3009862efb438b79aa", + "src/world/Gen3FieldRules.lua": "0839556137cf6c121a3e9cf47e3ef331cc9389dea6bf76c384ec0b450dd7bcd4", + "src/world/HgssBlackthornGym.lua": "74241df5827d4602a6c6da7d60875cd9b91d075b68b731a50eb26a9293eb141c", + "src/world/HgssApricorns.lua": "141906cdc3edc16d68b2b87e79aac174ac9cdf24caf7080d07e0be7ee42f2ae8", + "src/world/Gen5MovementTypes.lua": "f05eb4b1ebd9b1b6cb2c32217487c592991acc8effd46c03fe0e3dbddd321e39", + "src/world/Gen4PlatformLiftRide.lua": "4e1ea45c88d341b943bf2891a47fb5aef80be89b90246f0aa3b46d9deed2756b", + "src/world/HgssOutbreaks.lua": "d218d17862ab50870051574816b39463f34ea66fac5497680400212837161f2a", + "src/world/Gen4Underground.lua": "9e82756c8943b9265563a623d16145c2cafa2c82aa7dea40bb8045f1b92b0ad6", + "src/world/LiveMaps.lua": "d705c4df72fa59bd562efefee9312781e71c0e59034eac6fec0862f32e0cf2f8", + "src/world/HgssPartnerFacings.lua": "1b3681d43d3ddda79643863dbd07fb292264544c45f8f8eb0897f72b36030d16", + "src/world/Gen4TileScripts.lua": "3461827e93e67794b03b9144e6df87fc7fc9177081ad07996578b4292ce2e558", + "src/world/Gen3Decorations.lua": "22a7892a2903064929c956f7909fba943b65c15e1b69a96a7791fb2e98d7e8a9", + "src/world/RoamMons.lua": "7e261ac504cba92d4376238436c4c3b9c885c375a236da7c390b74544de4790e", + "src/world/Gen3MapPopup.lua": "9fc0e8e842be3a25ec986d2b96830cc3cda6afcc102217c7a32d7874c7241829", + "src/world/Gen4FieldMoves.lua": "b09c78454ceb725cb9e6cbc675480d96eceb01dbb25db448f7bc85b4e58316f1", + "src/world/Gen2Landmarks.lua": "91e272ebd51806d81902fde91d8eb61b8d414f2839b5f9a7d34483a6546e85d4", + "src/world/Gen5PokeTransfer.lua": "9f656e8582a289971d718aaf7fdbd90febdca79c9fa91c19f0cdab3768e00475", + "src/world/Gen4StrengthPush.lua": "776684a00c61c1004a5f2057026ec1896202451f7937e1e9ab6ba47fea176011", + "src/world/MapOverview.lua": "5e41f8528a2f052734975d12715abc09cb9c77ecb90c65a8ab1cc8c827c9418e", + "src/world/Gen4MiningSpots.lua": "fda0d502dec5790315c0e01e10531daeac46cd08c3c80c5dc37b2afccb099ac8", + "src/world/Gen4FishingTask.lua": "96631a7ac5b4e04dba9e8e03da3756b8992d100d62e4f4ec476cf13b7b140935", + "src/world/Gen2PhoneBook.lua": "12274ffd9d67dd35b7d274628eb18ccb2536ca7491364ae6aa454440f93d0fae", + "src/world/Gen4HMCutIn.lua": "d7f87c75a1d12c2c0d2db09615d6ff3c301af18cfe79d27e13b0529bcbbe31ea", + "src/world/Gen3Elevation.lua": "8d4010ca8e753b8d87415019aeb283f9eb1c99a12985a925d6fe59b74b330e29", + "src/world/VsSeeker.lua": "2d363a3754090577faa920e0ca2aea52f296867c05ab19e25e87ef585f98114c", + "src/world/Gen4Snow.lua": "a9e017a517bedfd809cfa3ebd2fe081d99304642397a5bc5d81e3baa4c08b3ae", + "src/world/Gen4HiddenPaths.lua": "71f24a56763b36f538995dffab5482ab4bd8f3fd38308b7f8412937172f6de3e", + "src/world/Gen4MovementTypes.lua": "c1910b4e8b7a0dba4f9f764d873fb5b3dabe6f7327c8b7bfe1f5ceac0e93d58d", + "src/world/Gen5PlayerForms.lua": "8f5949a58cf6283a45e98192a6ee2f4ebb01c68919f177af3ae56a55a87f30ef", + "src/world/Gen3FanClub.lua": "6344ab097e7043a00237cab0258f8bd3b8fca1de5f92b8722e590b4ea100000f", + "src/world/NpcControls.lua": "f22cb5e1394102156594a0ca40280321bdf019996dbd18963360453862325902", + "src/world/Gen4Doors.lua": "f5a134ffa295fd2869456cfedc17935234e507bacd81c439fd4b893027f072fe", + "src/world/WorldActions.lua": "6dd26baf4a0d4937d6edff957b1087f7edbac9139d5d251b85d871cccb8a9261", + "src/world/Gen4Fly.lua": "d03e34924998dda751a9b626f65fe95b7f891dc49ec262501319c1269f791525", + "src/world/Gen2EncounterRules.lua": "f7ad4662d516e938cf37857305dd15c748af8a92cda8eafdbf61ae486eb98642", + "src/world/BugContest.lua": "fa3c744a443179f3c68296a59fc8f5385e93a2a9b82889fc0b2a8c6956f1213e", + "src/world/Gen4PalPark.lua": "175e180c337eab0ba43140ddac21a8476f0676afe7693c3ac233887a160fbd49", + "src/world/Gen3SweetScent.lua": "ca076f8af9c1fbee4243a105f7154fb0f4157adf0320dd21277c87f97ac3aea9", + "src/world/Gen4PropOneShot.lua": "cb6ad82819577acd3db913dc8371902b7432bae060a784fb7992c6b574af80e0", + "src/world/SsAnneLayout.lua": "bc59903a3009e86d42413a1ac5382c7c78bce05603a2a7abc18b7894256c537b", + "src/world/OptionalFollower.lua": "cd9e8129acfdb6af5cf60eb87f05ef618f781847494d96cabd1f69af9947033d", + "src/world/Gen4WildLead.lua": "b2c3e8337885a69c63429e71a1bbad671151fa7b2eacc0d65b7585802f2170b8", + "src/world/HgssFriendshipStatues.lua": "4021251d94b544c32b6e35ffe696476c8d3de478c86f1c59defd638c28167498", + "src/world/Player.lua": "a1cbb34b80ae5b8680dea7114f0e43663eadce22217de279eb352b93d8526e0c", + "src/world/Gen3FieldMove.lua": "999d9e263e81926a899378ba50d71ed6655ff214f9878fa4a3f1fe5bebab77fa", + "src/script/HgssFieldQueries.lua": "1b3ad25133d6284bcf741cf6cab7a28688c8393fc577ac9145a6bf10953c7ef8", + "src/script/HgssFrontierTower.lua": "2bb05b78a127287bb5b11a7cce01d7c28bede35e456cae755d1f1cec2619ddef", + "src/script/ScriptSave.lua": "6568c8d26a40aadc5af0d70c63828edf25667fcd89c8595a3d35b62b7228eddf", + "src/script/Gen2ScriptVM.lua": "d411895ee777802497fe0be348df4cde39904111c9d1ecdd1929400745e7617a", + "src/script/Gen3BattlePike.lua": "9f7629cb26283d3affa9861eb6e675c177aadf467af1614ada9d4b6429182bf6", + "src/script/Gen3Specials.lua": "6a4a5495463d0029014961a52ae7fe7d08ff6b43f1b7e9b69e943eeb1d45cc2c", + "src/script/Gen4FrontierVM.lua": "0ea1539eb8980134f9b6bd2fd6de4f13412cd40440684b291a36c45283117e14", + "src/script/Gen4BattleFactory.lua": "c2e480a87182b2544bbd16b9ce8eb21cd8fb6247d225085f60f431b17478f62d", + "src/script/Gen4Commands-1.lua": "f35e2d0a7d9de9ed63288f9e64dbe34d705b41538f4b71675bb5011426f220b4", + "src/script/Gen5FieldCommands.lua": "7b179030ecd698b49302020b6b48aff386afc33024d6b83ae9b6709e5ca1c152", + "src/script/EasyChat.lua": "6d79db9c3c39094849092acce54dc7f9e3500f22ea53a3d151fb500cfa50abe0", + "src/script/HgssPokeathlonShops.lua": "fae629f29e253576f7d264e99a836575e2fb69c362a3482eccff5cdc0c88220e", + "src/script/Gen4Commands.lua": "f34ac63ea5a3370771fedf9732c906f1860fa78f42c7752ce5926efe8908c197", + "src/script/Gen4RankingsMachine.lua": "3d9fa05bf5f20414d102ce2ba3992eb188dfa989c4628b846bd085fb22c0d345", + "src/script/HgssMartCommands.lua": "fa033ee8f6974d44416cc1cc975d69bb865181e2cd3edf27ac7006531fd170e7", + "src/script/Tokens.lua": "120011147de9a679f72a171177d00a125944bc166444544594344943156b837b", + "src/script/Gen3BattlePyramid.lua": "0da693c362a8345ad813c6e5e91c8bca21e3de0550a10ecd67de464342dee373", + "src/script/HgssPhoneRematch.lua": "c1ee3ef13624a67840d6eb8ad34437e57aa29a5d3d9163d0ed2bcfe1796e493b", + "src/script/DecodedScript.lua": "3b39ca370eb20e66a49b3471b7a51a6322d3785f12792160f548fb8278deac58", + "src/script/Gen5PokeTransferCommands.lua": "a998620cfa01d03871e0ba6be95aaea00dfca72883161cd1cf7981a6659c43f2", + "src/script/Gen5Musical.lua": "e8ba53bec07881d9fa3a766f80e031bd4d1084cbf519fcb0aded550c7afeb7cd", + "src/script/Gen2Daily.lua": "4db437580049c801b1cdaaa80d2d7e5f2f5e2e1fd04be3732cf272ab7471b1bf", + "src/script/HgssYesNoMessage.lua": "c076a27e0e6ddfa4475967f34aae1bccf4e54a9ed40f03931d56a3c2faa12cfc", + "src/script/Gen3BattleFrontier.lua": "5317434283eb7b4c74812a0f0e8e74392a112e0b0f487c7b4b44b8aae980fd2a", + "src/script/Gen4Save.lua": "c905035a7489d7f130b462e3c6f2a70a2aef1c5f0b1cd98afc4bac3276db1dbc", + "src/script/Gen5MoveExec.lua": "928e767f2b18a3fa21dd8af577b1e597901e246670926b25e0f2401ea0de4faf", + "src/script/Gen4ScriptVM.lua": "2358e07b86a9adc61a8a3eef6d356032252db458bfaa907449cda4e6c4470bf6", + "src/script/Gen4BattleHallLobby.lua": "34155baa3b03a6de4ead369c776fceab90c89b5ef7b8d4dbf34050dd381caefb", + "src/script/Gen4FrontierScrCmds.lua": "ea956914c294b7973857ddc0eb098864be6955dc0cf8df847acf6ac36011d1c9", + "src/script/Gen4BattleHall.lua": "0283f233129ac3993adb8fe9dbfe7146b676a15bb0b77884ab3b7df9e86adce1", + "src/script/Gen3SpecialsFRLG.lua": "3409ee3a2768ec4830a4aa8f917fb41ba04bdbb2b800d07071fc576f09ecd44e", + "src/script/Gen5MartCommands.lua": "1f40995a45276ab1d1d264cc1e27740de57ef71d87db28227a210098b21205db", + "src/script/Gen2Commands.lua": "1c5f85cf0040d091c15db07e28aa2713c67653a7932d3f23888b50b52ddda183", + "src/script/HgssPhoneCallCommands.lua": "5005bb33646fde15dea83c8fc55fcd0da4818ee10edc3357058afbd2f668aa08", + "src/script/Gen4Daily.lua": "20961a4282c147610c7db6de526c8d15b5a08a5ea718a6cc531d9e58f6aaa009", + "src/script/Gen5BattleSubway.lua": "0623ea2336664e537fb6bfcec228b4e996781017952f23bb723b6ec643903d46", + "src/script/ScriptRunner.lua": "1caf422ed85a4cd7f03603200ec0d0a83be1700e8794a3c6b20489d57b47ff33", + "src/script/Gen4PalParkCommands.lua": "b6b7f7098f5a0dcb1051e7e5a1cd718d1d2eaaf14c6c659aa87a88c8b402b9fa", + "src/script/Gen3BattleDome.lua": "2a09b2d02abfe19e51d67813d6096c0bed0a2f9c5e594990bc6eaa2b59373f69", + "src/script/HgssPhoneNumbers.lua": "d0ec6bac90372f71e96f708b7919eada7f78556d603ef0b13c84a0692f0442c3", + "src/script/Gen4FieldEncounters.lua": "85ed6b319cf5d201372298127428ebc00f847d7676c39ff14ba12a0567c1398f", + "src/script/Gen4PcAnimation.lua": "9f4c7e40dfc9b389b8e55728e18507754c9c3ba09a6c15c8f2262659bcc9fb76", + "src/script/HgssPokeathlonCommands.lua": "bd9839b5cc5cc9e70994d35f270868f7d3e9b3481263c05ad2171d5cc4712604", + "src/script/Gen4BattleCastle.lua": "0c1c405cd3643898d1df5ef3c0d3719beb407007feb77625ff2852e537284feb", + "src/script/MatchCall.lua": "cf03d529d670217732217479662111648b3a0b8015fc3fa7d6e076c13e81700a", + "src/script/Commands.lua": "3fa3d569ff6cfa988c80491c6537c7cd4c3538e4aa5262b501f6067bbadd7c4b", + "src/script/Gen4BattleArcade.lua": "09ff49d619682160c40d70b401baf72f710acbf4f85e31af5677005e6a0abf28", + "src/script/Gen2Specials.lua": "53a7f0c2ddf3f1cc2a9f8f87bb647989cd90843c7378bd6461690a4fea6a5345", + "src/script/Gen2Flags.lua": "2fef027b70ef308982f6be96c9a4a1e0066732041aaab590b6e27f025c60bd67", + "src/script/Gen3ScriptVM.lua": "7847cf677f2ffc910f70e659d3a6838bc3b9633b9e15316861dc770209b604df", + "src/script/Gen4BattleTowerCommands.lua": "07e2e6748e9f157c3e9eac5fe315068301d7faebf79da72fdc1705e0e7b48646", + "src/script/RuinsOfAlph.lua": "6a9887522a0cf6f7535859f4e256bea23600e53193ca81948260d37325acf168", + "src/script/Gen4ScratchOffCommands.lua": "4b56c06f59055503aec28b9fdc6a6b75aa5f9ef591adf13097003b2901fa5e57", + "src/script/Flags.lua": "bf4a30f60be18169f3ff273a97b92cd14db0d49cd921d440d98f33c2b09963e9", + "src/script/Gen4PlatinumFieldApps.lua": "ae6b7e9af99cf7d2197c48ad6648f3ec3da3eb8a33078ef01317ed1d15f7792e", + "src/script/Gen5TradeCommands.lua": "e3d309c43e9b0c466b5bd80a61158519e244451adc5406ccb42746c93213e75c", + "src/script/Gen5Stadium.lua": "6b8921304de895432d5ec772d528fa5e1a5f12e02f11c9ab8e13dbee5c448e14", + "src/script/MapScripts.lua": "8703767ec2c1a417a159c084c6a202f5cc2174b9511b0170d31d13a1cc4893ca", + "src/script/Gen5XtransceiverCommands.lua": "9a94e8c52a5c61f83fa2164a596554f11bf9ae11dc75ad24d971c02adbefa6ec", + "src/script/Gen3Commands.lua": "3f30e176ae87a37b720bbd3c680a51bf3d5975ffc0f624c3e30ea6a62fb41d43", + "src/script/Gen5MessageCommands.lua": "57a7a64aff5e9a1d6d9f4ba41fd501589bfc86ee8669591a4d83a7d1c924078b", + "src/script/Gen3BattleFactory.lua": "4a32c9d707082315d51bbbb620c48b3b490af0b482dadbcada5fdffc4b45751e", + "src/script/Gen4MoveExec.lua": "086792ff6faeddd6fcf00074cd20a4552510ef71ef82844383232b797e244445", + "src/audio/NitroAudio.lua": "1d81f278653ce90e5521d9b89d9a75ad24a9d860927e00e9ce87a715137366cf", + "src/audio/NitroSequence.lua": "5a93a96afebe4945e951fe0cd204924f4e350cc1aa8fa8c348774dc69ca1463a", + "src/audio/NitroWorker.lua": "6ae969b5bc6171060aee2226a2ea536d6a5223d87ee86be43ed2d46067e8a4ab", + "src/audio/NitroTransport.lua": "bc2e6e0f3aae8020bcf06a8d5591f59a03955e05d02b7e355fe02e82ee9fbbd2", + "src/audio/ChipAsm.lua": "b018d7a82fc7af8fe302398134eb78f7e21f8f3d487ddd31c4d928534c9e3c38", + "src/link/Protocol.lua": "81cd62d6d2318e9d37355fc1fa53fd80709360486906f7cfdcea640aebdf5a7b", + "src/link/TradeText.lua": "7e0bc4c0b771ff6870084a5522662788cc3dea71fbe088df1a969e67fb96d024", + "src/link/Json.lua": "5b1662cc46508ad28f5fe8768e322b4a898caadb74711f5fcd880377dcd2d79c", + "src/link/LinkBattle.lua": "7b717bbe6d8455061deb29160843ded313ab2e00e3353db521d9a0a171cfbd99", + "src/link/Fingerprint.lua": "9fe9abac01cc395364d4108e201acdbf07fd87dcc7e1a880dc14ec89b3226be7", + "src/link/Net.lua": "93a508597a331c660c887a5d52c0342f01aaff8c6823773d48cae9bc47dad616", + "src/link/Handshake.lua": "f89a6a200021d7bd337f5c98e9011fff700d7f4cf11e151868eece978d6a02c8", + "src/link/CodeEntry.lua": "95c8d6e42d7b5da81b64ac11a2e051817df6828ed1c453df3be5a463009fc8f0", + "src/link/Tournament.lua": "0752e6b1b40bbbfdfce6b640c3f575c9a3ed7b4988ed05333bdc7ee5e41aa9c8", + "src/link/LinkState.lua": "ebbd6f5ce922e44d0f96b0e136077bdf65fe563a2faaf3d56110e1bdfb3bfde0", + "src/inventory/Gen5Items.lua": "33bffe334d844ae461892ba4a12ee3c79c903e296ec5e335a8f50cab9eeb9f74", + "src/inventory/Gen4ItemUse.lua": "566ead80919424c4751774a249e5c69b2d5330fcce6fdf31953194b870e6a194", + "src/inventory/PolishedBerries.lua": "a0d3d2d637c8786b8336649141b04c9f7cfd32ee901fcbef592bbb2f8a957ed8", + "src/inventory/ItemEffects.lua": "ce76864d60301a34b9178f14a98f4faaebd31d1f6b2a8eddf5da57dce97b83b8", + "src/inventory/Pokeblocks.lua": "5e37c404241a974c357131bd01e12e677f78f197c7b4538f21f0942f7628c513", + "src/inventory/Gen5ItemUse.lua": "a2c2e05ad391b03fce2d3519bbc330ce84a58eca7c52171be0ac843e5d3dea57", + "src/inventory/Gen4FieldChecks.lua": "adde117e3ddbfd7d2d639bd98bc7ea15b09736dbfb1eba226e7684a2a112865d", + "src/inventory/Bag.lua": "5486939b73e3a4ea86841ec3485ed63940bfa476e1085b4f426b900d2215e2a3", + "src/inventory/Badges.lua": "2a045f98c0b0ded18e6b5aca470b491bd04b8b9a872ad8ef83a30aa17c861dbd", + "src/cloud/OneDrive.lua": "f2a671d7f7d4d0bb51e7ea3539d53bf253107e9780a0080382afafcc50d29077", + "src/cloud/http_worker.lua": "be45a07722e9087073a0b59e3fdfc4c8c142c91dd2aad0c1e7ed33ab23ca66d0", + "src/cloud/CloudClients.lua": "8a45df459c926106d6fc156c9271e0b49eaabd4391dac4811ec951af9d844093", + "src/cloud/Http.lua": "4ba8a992e3b71f86648f8afd273a3bd02885906cc6020a02b7e9e3bd0cac4c88", + "src/cloud/Task.lua": "c2a037b7abd5c71a60ca401920976ba1bae2488e93acf0ee3f638b38c9edd629", + "src/cloud/MicrosoftAuth.lua": "ee38953c9ebee6e69daed4de66f2176837d71f632a21109d0d279deaf8b361c9", + "src/cloud/GoogleAuth.lua": "e1c9d942d9fa47b967c3db5f9c4e350dbbfbae72109dc5cc4bf0b0f801456557", + "src/cloud/GoogleDrive.lua": "2d6a7a83d080a17ab33e490be481795bc006e664bfc55de8410ec17f082ce55e", + "src/mods/Loader.lua": "3b7fe633df57326084b33f4bb145805f4047e07fff40eaf82c74907b9a84c1de", + "src/mods/StreamMD5.lua": "fa97fc3482f1de1cf28c1da567bac12c5d8ac96d2a74e41f1f096e3dabd21bc5", + "src/mods/UnifiedCallbacks.lua": "d7ef6833d429424e43ca8e371de9d49e7c249d95c912ed90a18a127aaa441494", + "src/mods/Runtime.lua": "b27e75396cded1c8d6076e18152ff622d17474fc2c0b2c6a3edfc9ab813957b6", + "src/mods/Hooks.lua": "1a18a22d92efaea9bc79fce0abc74e8f0c66764c5d955588ff8106bdf6696e55", + "src/mods/ModGens.lua": "71d8da96ca6881c95542da234d6f646002a06f07478386476a23074a83a57924", + "src/mods/PortableScripts.lua": "a4fc8a6f34f6983caa197e7098ef724ba46f44bbd171c51b4bc5da73885f205c", + "src/mods/UnifiedContent.lua": "ea4cbbf9352dc88f969369f5f05e0cf8f02391daf155a2d8457704abb739fdda", + "src/mods/Api3WorldEdits.lua": "2c4de48ca6f10db20e64588be3e18820c05a1f181d2fd7aea74bf4fe46b25ba9", + "src/mods/BattleIntents.lua": "05db3c192d186a1aef560c0a0ef0518e871070e0b3a7de184233f26517292f55", + "src/mods/Api3Catalog.lua": "59cb1c13b6e6db1f1c5ee414d307889f43e7973fddf0bbc36da8f0d86dd974b8", + "src/mods/ModImports.lua": "c8447a5c0831a9392432a77a081f83f41739b2e773f4fde4cce3181c09416078", + "src/mods/AssetPacks.lua": "788424783a3dc86d1a8421422bc7d20ef35a2181779b7d938a6eb8896a6dd88f", + "src/mods/PhoneContent.lua": "e15027baf9f191be82414f0240d28b9d9c0392852923f0aea3b8d1b22a5979da", + "src/mods/ScriptProgram.lua": "d7b82e0d1ada721977a483c2de9dfb923381935972183554e0ca307159eedaae", + "src/mods/Api3Records.lua": "91f4127235302def2e33d808cf5c78a1e03f0b46b2133dfa665012e28e58e5df", + "src/mods/Api3NPC.lua": "45b3c00a8008f8b42aa622372ee15a6703555ee5e5e142ae1550dade3326dc8c", + "src/mods/Api3.lua": "99151fb6dc515ca856a686c75ef478cf710af00e27b703e9fe025185b9dd3886", + "src/mods/Gen1Compat.lua": "ea1202ef4fc1feb46fc85f6c6486b80dba0406034630bfaa514a2d6ff5c707d2", + "src/mods/Registry.lua": "e78d29ea0eeea3165010c23ea9ce7f2cccb65cdd3727de91dcf383ed17f75965", + "src/mods/Events.lua": "8ca0a72df6b8a761d1ca031aab4161640e4bb518b19fb9c932dc9792e31c68d7", + "src/mods/LogReport.lua": "cdd6efff8ce1371db5ce356ed183e06a8ddad043505d20cb122cfe68567da9ec", + "src/mods/Manifest.lua": "e89f0d5a5aee609d756a86991c93dec696cdb7cde1ca52fbc8fcce9fa92dfe58", + "src/mods/ModCopies.lua": "5d66850f3c0c0f754dfc8073f274e8047c1e84b66d556edb1af90a21b33e5202", + "src/mods/Capabilities.lua": "da57d928b759a5971de48b20aac21344a064c1fe51a1cb00ff79dcb925f6359f", + "src/mods/Api3Warp.lua": "a6802656f9f4ec65656e2bb1ebd56256599bff92aaba918292655fe73a78d0cc", + "src/mods/UnifiedIds.lua": "651c283e31083549031226bc975d845695bf4b06e6545b11fa59c516ffbb3823", + "src/mods/ModIndex.lua": "711e100af3ef8836868f913a8805970d0c5fe74e397c00d0e71e5e4b160c0273", + "src/mods/Merge.lua": "ab4406b5235108a336a7c3a0f2688f5b8a39452c4edd5354e1c8d77681f6e8ed", + "src/mods/AssetTransform.lua": "59657044cec253d5eab63896904f63a7f4413fc86a2e2f05bc833cc58b0ee3e2", + "src/mods/LauncherMods.lua": "e4ed29fb38c9026d927e3c3f93cd36a7dce6b504caf519efb5524c751f3fae0a", + "src/mods/DecorationContent.lua": "f86e0dc3e47aa1327732b071a93ce64744e9f769bb3f68c0bfd55f05566b1718", + "src/mods/LandmarkContent.lua": "a6ffae7ef24ee1f0505b1bd6c89ad72a6cfb6060acfea2f8f7c70ec1926f7fa2", + "src/mods/Net.lua": "bbd33eea7c119bc305fb57bc2bb7c3fbf1984f1adf7e98fbea286604c1a5f270", + "src/mods/PlaythroughStorage.lua": "b4d0adcd2ae2917f5e474f364a5cdbdb8d5b8a1ae70280381839307a88c761ae", + "src/mods/Schemas.lua": "1d871f236f348494804e0074d355da13b9e762fba1bae25e96a891fbe0d08e51", + "src/mods/Storage.lua": "d07c8921f9e49947158521c11d31c1619a2ec7574327bdc0e7226250a71de24d", + "src/mods/DecodedCallbacks.lua": "ba0318a4970d29da314e70b95a2713964f5f758686e839f99bc8dd3d01c450c8", + "src/mods/InputControl.lua": "8897d9e051d4adb7489495f7e64364a6eba19a7df73574724cd9772dab4ca615", + "src/mods/ApricornContent.lua": "e6451f59a295e29819cb58bb94d50e9ea5c63a5d37ba9652272b511e0b400350", + "src/mods/CallbackRecords.lua": "480d5fa3526d01f4478326e989c9157286803ed56029736fd78341ec2f508b16", + "src/mods/ModSeams.lua": "660f73af0b6f2f3ac6edb42ec1f742ce6ae28a388868eb8625ab0f7cf8f118d3", + "src/mods/ImportAccess.lua": "cbf7294ea84f0b0647ca078488457cac799cbd59fe0cf409a70e4f8e2f18da29", + "src/mods/UnifiedSchemas.lua": "b7dc4984044bc63fa8533aee9f8a5da429c83f33966e45fb24a9b017a32feac3", + "src/mods/WorldReads.lua": "5529dc5ce24a10df31c690fb1b01ed09deb084cf7bf5dea96cca7a5ffacaff48", + "src/mods/ModRename.lua": "59c23658c1503360c9ac26869e5b9b5f7bf73aa3635460073765c4dfdae6d0dc", + "src/mods/ImportAccess-1.lua": "47102cafb9ac4c59be34f9a83a81920c82e8806ee8fc3977ab20509a65312097", + "src/mods/ManagerState.lua": "e29f62294f6c15fe5b78a52e3059f6d73803b76fc8c2989094de458a20c4dfc4", + "src/mods/ModProfile.lua": "d8debce893a8ec695f5656036947bd24b27bba65afa52f24375dc7fc3879a1b8", + "src/mods/ModImports-1.lua": "20a3c396244a6e480e47c8e01e8501e1c12b5e220e41dffdc2dcb00ae34a4565", + "src/mods/WorldOverview.lua": "b7c0c46cdacd405356fbe46c8d7898aecd237925d74abb3643dbdd155f5c517e", + "src/mods/RadioContent.lua": "06d0ac27359603bffb9945539f05dd5318c50119d81d1b4caf45bcfa0dd9205e", + "src/mods/Semver.lua": "3a39e20cbdbb93c6e67595143fbc7646986334087820554b4121e73fdb3fc724", + "src/mods/UIActions.lua": "dc3feca71cace847c503d6b6189bcf6aced394cbeb3a729e68a8f9b52a9c2392", + "src/mods/ModUpdate.lua": "106f6fb364b1261cbb31cafc87ce0a00a05d541d1c6d9746fddb89b3b64041ac", + "src/mods/Builtins.lua": "378d301dd0637431a5783eb5879ecedbb8ad8973d9922bf68d157f0ba5aaa22e", + "src/mods/BattleSnapshots.lua": "47ed9fece415e624d46b66fd9136e0957ba24bebe5c746c5dd2577d3f5604f90", + "src/dev/Console.lua": "63b268239f3430f24ccbe0cb2ec94720bc676fafd2a3bc19f8161f49a6261c3d", + "src/dev/HotReload.lua": "8e54c2847109ebcdee901d591d499ecae435b718dbce730d02653a55820ebc78", + "src/contest/ContestRun.lua": "42ae50e4f3feb214f79d234fd8c02831eb54ed713352afeb7500668250db3522", + "src/import/Gen4Items.lua": "7f4a00b277b7008f295aca878e2d9a9979a7d0716ee85439117c330b231b023a", + "src/import/RomExtractorGen3.lua": "636613acf761acc52fcd1181929f29bd777318611b20e24a3dc2bddc57f33635", + "src/import/Gen4ContestArt.lua": "4a2e1f0332950b591714221625887b7b5274259f630e8b05d56fa6ac20d1b89a", + "src/import/Gen4ShopArt.lua": "85e30b3cc8c02d11a1d408d43b214ec85aaa034a19a62f553371db8f53913310", + "src/import/Gen5MoveAnimData.lua": "c8a3fcd16c3b823f6a4010b65cdf5a5ddef4cf7e135dd983b15eb1860be28965", + "src/import/Gen4HgssBoxArt.lua": "6171bf0f921b4a6b26dd465828ef36c9f894473a911dfefe806e73ac1db2adab", + "src/import/Gen4TradeArt.lua": "2edae77779518860455b92faf056459b1bb84c2f460601b33b3119787c48d9fb", + "src/import/LuaWriter.lua": "6a94964bdccfdfe49772e0d22de26d400c62899bbdeeaffa623095b4be54d647", + "src/import/Gen5Font.lua": "6831879b6b30fedd1cf7fdc55147c8af59253161881134e68c0e808aa85dd9db", + "src/import/Gen5BootArt.lua": "0db690048010afacc265f7da00f0b49acbc69eb915a92acd0d9e7de66b8d3068", + "src/import/Gen4TypeChart.lua": "d122ce147564554f1b47de29b8458e377abc8347867be3bcf4df60832eeb82fd", + "src/import/Gen4SpecialChars.lua": "96a22d14a5c4ee57bef37a10ae7b6c8bc9e88d4b952a64c0c1d7b55ecd62a963", + "src/import/Gen5EncounterCutIns.lua": "f74695a275b01c008714b2d47fcf93ab55e93d4ed3d3d86ebe327a2c90d60338", + "src/import/Gen4Events.lua": "0aa970d5c6a8e544868f7af97625ebd07e97422270982e4d3be70da7992074d4", + "src/import/Gen4HgssScriptTable.lua": "794b71159f39d480e07bf3f4e3bbaa486bbd43d8a842901596a3a8587bbb53ef", + "src/import/Gen5MusicalData.lua": "888be5d240c397a826102cab3972bafcaba74bbefa37d69111780d1d221a01ef", + "src/import/Gen4MapHeaders.lua": "086f1b4a94c6fe505a0195cce0c00b5403c5f94048c987b0b82d1b43929e3bdd", + "src/import/Gen4HgssFiles.lua": "bdc512072a046423d5d38f2a77dc58f5ca581c15842638262d6e92c8b365fcfc", + "src/import/Gen4Screens.lua": "199dbf3f95d4d885b41099df67cc759b3fd0813165d5c8c6aba3f707f2f5efff", + "src/import/Gen4HgssPokeathlonLampJumpArt.lua": "77cdeed0af92e01a99342cc8d641689684395d7d33ac7f5ad0936d3a02664927", + "src/import/Gen5Overworld.lua": "d7c57e4f1544c2bd0c92209322e8c8cab37af0f88d5e695feec3220e86f7826c", + "src/import/RomExtractorGen2.lua": "b62a458e3bbdfeb5b2d14c287635f0ec06ddbe9a386dc423286b997188c9d98b", + "src/import/Gen4Trainers.lua": "57beadad20d602fa60b64ef446a4f12fbc837fbd8bce9b14de102a9e27579991", + "src/import/Gen5BattleUi.lua": "f348106f44d2a3a89f40eca195d172df6616b45536b80f7255993fe31402bcd4", + "src/import/Gen4Cells.lua": "b2c76fe6ca28bc7ef24061c267811e6db3ac43ca162339f4118db909ac651af6", + "src/import/HgssEncounterEffects.lua": "f229596d1e28222404b4a25e1dcceec7bf51694b2e7cb5b7ae2675deb862c946", + "src/import/Gen4HgssPokeathlonCirclePushArt.lua": "11a00e31c5ba33b9c6b2205387a737dafe14a86ce9757bfce52053a0ca9d02b2", + "src/import/Gen4HgssMainMenu.lua": "abbde6e6fabbaf95a2664a70c8cd5334f2c7e38a297ae64c8280b5746ee8fa5d", + "src/import/Gen5EvolutionArt.lua": "3b9019f0209785c3cf020972dab4b3137ef022713f219948566fb2097291a6e8", + "src/import/Gen4GameCorner.lua": "9558f15bf9391a886b2a1f3b73e9e1aa14ac9774775a9868288cffcf0862538c", + "src/import/Gen4HgssArea.lua": "24b63049948c64bb5aef173a01b955e249e47c9c22485702a94ce22fff55656e", + "src/import/Gen4TrainerCardArt.lua": "db922855f86f8a40bece40b8b9d3cf3d07d29ccafb26d75f5cf120f8f222a52d", + "src/import/Gen4BackThrow.lua": "b13b6e6e518f1fc28f3e347bd20a305162ba5f5287bd7d58650eaec20d536ef6", + "src/import/Gen4Trgra.lua": "bdf0a304a40087ba878f893e475ba51c3c05c470a5c03a7426b2d6ce17905129", + "src/import/Gen4Trades.lua": "c1f3bb47f1fe9162e2b40718b065d67addec453bbb31b1f8d53d880d7f566abf", + "src/import/Gen4HgssPokeathlonPennantArt.lua": "347437eec2bd71f1fe136f6e30737278c83e26b195a07103e8941be47792527c", + "src/import/Gen4TownMap.lua": "92ad40058a3671feae453a782bdaf5fe14d616e402452a621506d7ea3d8812d9", + "src/import/Gen4HgssOptionsArt.lua": "3cbedbb59f09f6aa4e9829f98b835c931814e3bbd8dc8f7adb51d55f3e3cb3bc", + "src/import/Gen4Emotes.lua": "1ba63ae0f8557f4b7a53a9ce4d896dc9a5b4c7f483ae13bf63ba9656d2db7f43", + "src/import/Gen4HgssAlphPuzzle.lua": "fce5a951533b7fa757f64fd6501f2780ad156ea3e7ee31355974133e61ba9d15", + "src/import/RomExtractorGen5.lua": "5d7da4e3754aacbda5e51ea0513d2c5cb5287a5846ccb1b0754821396794a01b", + "src/import/Gen4Moves.lua": "c27f14ce18dc30c851bf5743c358bfb5362db1def20f59ec0545a610c4efaca0", + "src/import/Gen4Terrain.lua": "f601b93d74e0ce5540703ef87ff293145a08871c40d24cafc64e9bbe49af28d4", + "src/import/Gen4HgssDexArt.lua": "bdef563272ffbaa227635892c68c1f4fa85aca8811cae6a9f042c821cf19c5c5", + "src/import/Gen4ContestData.lua": "2612e9bc09ae666fbfba5b4841d08a4b30052e16eff0d9f486fb286290523c6a", + "src/import/RomGba.lua": "41880f95ea74f5ecf2aedd4742526ba2524c5652b96c40204cddd0a90529ed78", + "src/import/Gen5MenuArt.lua": "e86aa6481b962b9decbee776d99b37b842b48f2573558be61643d51b81746268", + "src/import/LauncherCloudCode.lua": "87f6eab370e99be0ac2890e346b717295a81535762fec3dd01adbe5f966f485b", + "src/import/Gen4HgssPokeathlonSnowThrowArt.lua": "9a5ace5148dd971588ff4b57b99abd4910ae48a682436ef6627dd0a83dcf0fe5", + "src/import/Gen4JournalArt.lua": "4f3b859a768870d6421f21c2f7adfdc8980e04815fe27dd365fd425b89eb9959", + "src/import/Gen4Anim.lua": "21e7a1db824260d5c86dc68e2c515fa45ca4b85abbc3a4801a1595aa07c9ad7e", + "src/import/Gen4PoketchMap.lua": "f281f85ed40bddf97785f21b2a965c8f9131ae887714825cf59ad63b9fcd4a8a", + "src/import/Gen5Data.lua": "c882faace21eb8f6cad26dc812c1626b32309af44933d61dffcb527534a0f66e", + "src/import/Gen5Cursor.lua": "520270ef74d47f54735ba55b357c88bd0a1074db778506691b63109f85b85192", + "src/import/Gen4EndingArt.lua": "d668d96e061fe537d7ee7a6a5881e3ef8e78c262270c299192cf29ca1e07e553", + "src/import/Gen4HgssField.lua": "7d48f449e6e49f2ad939dae72254ef9da9c3cc46991b6a9a1d3a2b1d6cfd9880", + "src/import/Gen4Nsbmd.lua": "5b1ed726c0423eb10b0a87e8bba73727eeedbbca50d8600fb3c71bb250ed4b60", + "src/import/RomExtractorGen4.lua": "a391eb27d13ed12fe0475caa74eee1a985132e3da70420605f06babd2b6efd1d", + "src/import/LauncherBackdrop.lua": "aec2493e0dbe7ad3bfc3e3494b62ed85b8f4919dc31aa2bceab1f84922fce20b", + "src/import/Gen5Facilities.lua": "62fe2a650ec5de63978bc6e6c37d50412cef8a850ccf826a834da4d43826211f", + "src/import/Gen4StartMenuArt.lua": "18c9eba8ce12c5ed6d245e04cd01bf9e1b4acc7d96687109a87d8d280e401546", + "src/import/Gen4Font.lua": "43beb32b056b56ef7c399b2a38cb58541401675e19bfb962db5dba7b1de7cd94", + "src/import/Gen4HgssPokegearArt.lua": "08c371e33fa962df2e52d636ecb654b631844ec9795e995d15a89535b190ab5a", + "src/import/Gen4HgssPokeathlonRingDropArt.lua": "780be45a4d67376a3688549a9d00b09ec748d346037c9edeaa89b21d4bd7d2ac", + "src/import/Gen3PalettePrograms.lua": "47f5a5aa2e9da8d8eed86a243d63937f02b4af5bc01e8a189ace0532c77dab5c", + "src/import/Gen4HgssOakArt.lua": "8871801b2f2e5b784966fb13a19e7d3a0a2e5c8a9169895b752ebca0172b8175", + "src/import/Gen1TradeArt.lua": "ffa053dc928ef06932ab248592b9b8daf0929be4023c8e87901dc6679dbfd431", + "src/import/Gen4BattleHallTables.lua": "81755152278d2b813c7b219fbacae40602d62e201f62722f8d8d3b5caca72385", + "src/import/Gen5TrainerCardArt.lua": "91742f255437a0afcf2f233d5f523423019a03afcfe0b99e6eca5a85bf6d4734", + "src/import/Gen5NamingArt.lua": "bfb8fa9ebc8bd7926c08a52dbf1e779e8471281e2488d43a639b2b92d09781e6", + "src/import/Gen4HgssFollowMon.lua": "cfc2ac63ba0fd363dbf9644f60f65731f08abeb9152a6ebcc676e67d752ff5f4", + "src/import/Gen4PropShapes.lua": "1e3d2b6ccf833d9e87db51ca4fdaa66fe8aa3148a429a84eaa45f7d4e8c9ec86", + "src/import/Gen4TownMapArt.lua": "c46cc36bb25588b062966b86e5dd5ac87d3ae8da82dbeb7ddcd93ce01655f080", + "src/import/NdsTrimIdentity.lua": "45b6b35656f7adaecd38f98eac7890ff84e228bc59045676461040f0ca3c72a4", + "src/import/Gen4PlatformLifts.lua": "b41995001f39f04c3e8c4fd37146f43568eb3c082182324abe38a1735237561d", + "src/import/Gen4HgssBattle.lua": "6d6fc045006c211c6b3d92d780a8be9922a85d21600e8e601ee26876fe1048a3", + "src/import/LauncherSidebar.lua": "51dd35db4400fd76f9bb4357abd087db62f895622aa177170b29cd899ecd9a60", + "src/import/Gen4HgssFacings.lua": "0ff107dbb49d4d891aa7487f3cd2dff051219a768716246dfbf23a348d4e72f6", + "src/import/Gen4Weather.lua": "33feeb7b29ba66783e5656d4adbed7c309d62aa69deb9f19321c431b09dc3af8", + "src/import/Gen4PtFieldFx.lua": "0b9a25e6a68c0770652ef53c91bcb107793cdcfccf0545558ac8fe944a8ded22", + "src/import/Gen4Facings.lua": "5c0f623912e7f4e49d731d3af3e50878437b2e7a042f6a4c582a04e00fae9a89", + "src/import/Gen4DressUp.lua": "fc57efd45ebe1baf4401908f944f00cd40d2b85ff6ae52f80fda93fe5f3c8120", + "src/import/Gen4HgssPokeathlonRoomArt.lua": "e802ffc6880f5265d8c7d1a03f26e30c711bff1cd7f2c3ab2f435cae5efb01b9", + "src/import/RomVerification.lua": "a1f531ef9e7ad188cd2b177cebefe561358f626dcdf2dd39a45222c28774cf9f", + "src/import/Gen4HgssPokeathlonCommonArt.lua": "9192464aea8842641c820888f563a4a4c01bf885659e830ca8ca887762ec2f21", + "src/import/Gen4Text.lua": "7b56c12da325bf0c5a4a37dbaa7a03c51fb2bc151a89ef9bda35fd8b20d2f1bb", + "src/import/Gen4Seals.lua": "97b8be7b1d14dc61b09addb56c2d11615a9add43aa1f6ddbb04af4c9593c316c", + "src/import/Gen4HgssConstants.lua": "28760fc80423bb0a7b0106e98c8620570f143aad383d33b2246b8bc374527ddb", + "src/import/Gen4HgssBagArt.lua": "700131faee87737deea5b0ccfbe09d9abc00b33a050c30c1eb17a8be978bfcb8", + "src/import/AndroidStorage.lua": "2670aaa5c121537d11e144412ffcbd87df0239b179b47ea33e28f4d61bf428c6", + "src/import/Gen2TradeArt.lua": "5ae64ebd987bc6573bb431097ff5e87674d8704063427828426c0b3b1aef473a", + "src/import/Gen4HealthboxParts.lua": "2892e314feccfde3a08816f633969eea92dc70ac71bb31e48d2601c9db70d880", + "src/import/Gen4Maps.lua": "6470511e18e585087a12d584d70263bbc173858229fc9bfe65d24f841a2a22c9", + "src/import/Gen4Particle.lua": "5bfb083fd440c738b6df9ae4da25a3ddf703e78d263ef0329d5f799ab5fd2e1e", + "src/import/Gen5Events.lua": "7dcaa8f36c13f824f563a412ca7405ee25a42c5dcae25a98861a89ce3087f1e4", + "src/import/Gen4Dex.lua": "6b3b8c5a6e05183af759d2c414b33aaa6d48200bee1adb9ec3f400b73b0c2db8", + "src/import/Gen5EncounterEffects.lua": "65c54f4b1d426b1bf8702570abfd7e358b8c16d22883c79dd30bb3a2b59df2f9", + "src/import/RomExtractor.lua": "74f6a88cb4a0a231d9bf9fb12f5239e1ddf278693e1e5013fdb5f5f7f96e2044", + "src/import/Gen4BattleTowerData.lua": "61009d86fbf45e1dc1e98915ade447577a90e47e835ae69ec7758bc6f9ea3349", + "src/import/Gen5BattleBagArt.lua": "8ea397b08421b9c8ef58de63f272d108d406769604a91cff374166d65fe7cade", + "src/import/Gen4TrainerPrize.lua": "67eb5d9aae8e74514bce2d819611ba2f5d0ca29609f960da6e01d09e71e8be20", + "src/import/Gen4EncounterEffects.lua": "35868a3926d024b3b575e2737931adbc190fe361d145d7daef4dff038de2fdb1", + "src/import/Gen3ScriptOps.lua": "0094689f1277cfc3ef65f316cf6c59a7b92c871a25c5cd5b772727a9d1a3fab5", + "src/import/Gen4HgssStarterArt.lua": "14b226ce6803a7ecc0aca9a351ebd58866d2cc4b1b0ead026ba875f9be6c9de2", + "src/import/Gen5Trades.lua": "3393239e5bac78f6a0d26a6dc2fc9da68387be3fab6e3354007b10f15fecc287", + "src/import/Gen4ScriptOps.lua": "741078316804b655711bed195cf8f97b80da2f6a28872a68fc242dd669cfa40a", + "src/import/Gen4IntroScene.lua": "5bca967f304a5ef41441890e6b2a309d6be42fec6c63ae4e596fa49be79afd36", + "src/import/Gen4MailGra.lua": "0f07951a4319d4fd20396e5580fc7dcfdbb3cea50ffbaf44028ea7ed65b91e52", + "src/import/Gen5StarterArt.lua": "b6c380ac6f45f344424db0e0a8b2ac1339c4ef6ddfc604ebd0b818708a70ef4e", + "src/import/Gen4EggMoves.lua": "8e28bd37f19f7836f1bd0a8c4a5af3761b883f163f0f045769a2e9ae41f5ebba", + "src/import/Gen4InitScripts.lua": "418ad57caef42ce0f808dccdfc97795f582c81d030d01a0cf350714fed8e78fa", + "src/import/Gen4PartyArt.lua": "2797ac614bb1cf5eeaa33535ae759328e4d0b1bcc3d063b7cb3299a7d84c82b5", + "src/import/PackInbox.lua": "03600a1c190104342c813f514647df6f5bdea5470f157ce34c0ccf6fd943b55a", + "src/import/Gen4HgssScriptOps.lua": "67d0df873cd11da1b4d12bc728869a9392b5f8753630f27c1890a06cd45bd3c8", + "src/import/Gen5Lz.lua": "b2fe132b395049222b4a7cbf909fd1a190980d36aafc6b5d58bb6654d924be51", + "src/import/NarcArchive.lua": "56262649e629f7607a6d3fd15f8fd6a657ff3cb6d43cc6510026744ae127cfc4", + "src/import/LauncherRestore.lua": "56d5cd2a9728917038790f34af91468f2746cee79f13ec1ed9c1d7b21bc263a9", + "src/import/Gen4OpeningAssets.lua": "5423a01c7117e080a7f40b6b4e04bc42c02192285f71d752189168383baa80a2", + "src/import/Gen4HgssPokeathlonRelayRunArt.lua": "cb87a7f1e437748e8e0bfd3d0d4309b21f149f01cbf1f91142c55643f6b520c5", + "src/import/Gen4HgssFieldMoves.lua": "436fcf32b965b74a616f8d2b05d2d5d4afea0560d2378fe7d285888242255b0d", + "src/import/Gen4HgssTexAnim.lua": "03f457472fb8a792fc8ab5e951238ab33325c2332bbe04c54c183357746dceb4", + "src/import/Gen4EvolutionArt.lua": "ce28b878d5f912138696c47c040d74a654c57e7bc79dae60357cc36eb83c0520", + "src/import/Gen4BattleMusic.lua": "965e8bdc2c6aed79d1c948ea57fd18b9cb940c008fc55c3d238da6ccb48da2dc", + "src/import/Gen4Models.lua": "6df58c6271c1b573bc81c27119413ad3203d268ce05d0e6d508d0bc448ed3155", + "src/import/Gen5Text.lua": "4279eb697ad283e99a01cbed9ff6704fecd97c71155058a114c0ce824521c73a", + "src/import/Gen5Movement.lua": "6ed6e1c0f8d75022bf3f0d29f076024c788283ddd3d92a6fac09c1837771e078", + "src/import/Gen5OpeningArt.lua": "0f3648fbaa279a54df3e1879ff781bc984197e38f42a2aeaecab00b92ffe4bba", + "src/import/Gen5ScriptOps.lua": "db5ca2b4e93cfd7efae260fc9a4d2e5c6aff7e2d9e9d80fa1fc763845dbb5165", + "src/import/Gen4PoketchSprites.lua": "51bb47b1d3e49a2bc67caa47a01b7a2cc392b1966497dc925c3e22a75c60045b", + "src/import/Gen2ScriptOps.lua": "1a3e9bf553a8afb7af6e361bc40ccc7323ebb23dddc78d7ecfa3c9914364575b", + "src/import/Gen5Encounters.lua": "afc0bf6e55c6aabc85142bae8b8f01c3e38e82d9ebf5a1df3a49563733174482", + "src/import/Gen4SummaryArt.lua": "8d3c23c8cab2c215af26c42e60fc7beb4546ba8d8cb8298a6a45121bad19abd3", + "src/import/Gen5Maps.lua": "4f04b333415a77f8604cef2bc41524cdf742dea7dfe267751502b6ceb16d3520", + "src/import/Gen4HgssPhoneCall.lua": "59e4b6cbbe1f836f3c938b7066a4a2318b4b6852100b465010b48dc992fb5846", + "src/import/Rom.lua": "7bc60f2a515d3e9d9f475d8591f253283ac849a91903fb5d9ffd88a3fe4eecca", + "src/import/Gen3PyramidTables.lua": "e5fb56f2022f71c32f9a13ad1eda2012c68f2c57023c7f72b18c98366bf50fac", + "src/import/Gen5Field.lua": "f1df978bbd23b5f0ac6553785f9241165ba2ae3432c9fe8305c024a5f4364d42", + "src/import/Gen4Field.lua": "63c4bd391ca98cbbc3ad39c28815f81ebf3b0e99a66b3c024971bde5aca0f8b4", + "src/import/Gen4SpecialEncounters.lua": "d8dbd49bbd6e4274dd70ee5a232f7f80c8cd0efad83eb6bfbe07b3c3c4b9ac92", + "src/import/Gen4MiningArt.lua": "7fa3da6e72c8273f8a121eb15b539760e2f9449db284e82a789fd4c7730b8485", + "src/import/Gen4Bdhc.lua": "9fe693350cee26000e6a2e09b0f215f851127a049571b2bbc4b5ad8a9c726cc7", + "src/import/Gen4Subscreen.lua": "19e664fd64c53a2579da99053931213201f3cb3dd3c1571ab9b17dd4a982f8e5", + "src/import/Gen4MenuArt.lua": "fd0b8a32a728b9105081a217ef3a6fb200e6e4d50231d7c414a1525b0260ec3b", + "src/import/LauncherTouch.lua": "d02c620dcce18dbf73540d9462a2f213e8a4d1d6da8c38b95b127c67c3e9b40b", + "src/import/Gen5Subway.lua": "ff5ca3904c9b877306c5d0f1868c3d9b909fff9bb3b8032e5fc30ebaea8da5ca", + "src/import/Gen4ItemIcons.lua": "69fca190bc5cac17ab7aa8dab4e7e6fa43c4622728b0bc053a85f4b01690dce0", + "src/import/Gen5XtransceiverArt.lua": "769b87be45d47fee631e91d5a5c3d5a36103ad59fd4892e9b724aa1cf616744f", + "src/import/RomRevisions.lua": "0e26cb4718be36ff3d0758ed867623afb0f674363c301d6d21635d6691a796a2", + "src/import/Gen4HoldEffects.lua": "d2b1f6ae8e932899631726accbe7d11ec688a1ef7816419299c2eeb62565f97f", + "src/import/Gen4PropAnim.lua": "4eab553b0a2a07631b7394d0a02d76031630921654622291f6cb639646ec0d8d", + "src/import/Gen4Archives.lua": "7c46847d58256e89ddb216c8532f85a913291a35772aab97b4bfd712d033eb49", + "src/import/Gen5BattleSprites.lua": "7a93ce6e15764d4b4e09c5ae0b7001f08f6de0dabedeb017add1284b1a4c2604", + "src/import/Gen4Icons.lua": "59ac81d842c78f76d81725ecf667e92af5d146eea74f1137726e922c49cbe28b", + "src/import/Gen5Icons.lua": "8407afb0356dfb2d4c4bbd997dcdc078562ca89a41339c0b4eb2afe09d9da6df", + "src/import/Gen4DistSky.lua": "fa9f55ea6096f709a946164faa2ee5d5025e137234ad4320398d217a8d042ade", + "src/import/Gen4Battle.lua": "636227456f8015c63fe539ff2f0d4559f5560654ba7ac65b8702990a06dd4923", + "src/import/Gen4Versions.lua": "6829d16083fefb4fc603db0b2e9ad25963ca78fe10f3858392870063f6925404", + "src/import/Gen4Otherpoke.lua": "0416eee1e9740465f7090ec426523249b63e1784150a2a3e25835d08c9b85fbe", + "src/import/Gen4HgssPokegearExtra.lua": "67c7aa252a03a9e2d28406447e97cf718559d9bfaf66c20e7f1203e73f86b79c", + "src/import/Gen4TrainerMusic.lua": "02454b0be146617142b95d9b5e3eb8c1db9438fd6dca12e379e5e9cfcfff9577", + "src/import/Gen4HgssObjectGfx.lua": "ae276d54100577f2684576dfcff6b0ae3a1508007dd27ff52a2e3a14b5a71093", + "src/import/Gen4FrontierCommands.lua": "af0e3cbc2482bfdcf578dac75d75a980cf4c9c187723a0f9036283228fb0f0a8", + "src/import/Gen4AreaPopup.lua": "57d65731046c200f266d8e645f96df7201205e715605584a360ebe2c4fd6b21c", + "src/import/Gen5SummaryArt.lua": "3955b4aaeb0b8a09d209759677888b82716ca6250c97e216fe728eb689f46c46", + "src/import/Gen4Menus.lua": "5720b28953b0a557affcaaa22d77f245a4176e3a69f95ef49c66ea4a5f95ab45", + "src/import/Gen4MoveTutor.lua": "d64690f4a38c2c4e4e9fd532a63d514866892ccd10d43e0fed69b8b317097193", + "src/import/Gen4Encounters.lua": "94ed5d772a89c00a1ce2ebc5d6d4582c16bda9d9f085439471dab6fd37fab4ac", + "src/import/Gen4Naming.lua": "d259695ba16f0e115825281f13f829739a2a38c22bd658d53286ac66fd35641c", + "src/import/Gen4HgssTitleArt.lua": "8b92067e44a043ffde6a9dfda514e1ad107a66ec1ddefd7ecb8194ec03e18fdb", + "src/import/RomImportTask.lua": "a6eb7257f3f0e0f5a3b24adeafa33fe052ccfd011a6929ac0b4b5729754f0b8c", + "src/import/Gen4FrontierScripts.lua": "38e8ae94537df4a205c2bf64168d7acb5bec36a3f4d924bfa8b2ddda39287c0f", + "src/import/Gen4AreaLight.lua": "0b7118a4c6b36a90edf75f25173e798d44dfb57cb912ea70e884f8fafc42b4a5", + "src/import/Gen4Elevators.lua": "8ec3c8b8d2fd742a676c83a432f8d0002d7ec4d4b07fa5ab9a7df0f9f34a1f92", + "src/import/Gen5TrainerPics.lua": "994efbab8d287c159ddc6550345064c4811f711f568373ed5148d7d00fda406c", + "src/import/AdoptedTileset.lua": "b08f6066528129342e0f26213c6e5237421df5a288819dd9773e6c942b9b6045", + "src/import/Gen4HgssPokeathlonBlockSmashArt.lua": "3ebc080b932ce78720c9b8527004efaff81306d0b101eed44e0cefbb49bfd89e", + "src/import/Gen4Mining.lua": "e0c40f9cf8e212628c710e217df9c702cfc4180e563101fe0d89f75a107ab917", + "src/import/Gen5ShopArt.lua": "704e7171f10a97a9387e54e84dc5e49c71e8f81689c85f9edf8c83283c0c7d3f", + "src/import/Gen4HgssMartArt.lua": "36baa661d7094db07d0a72b0ef598a1e1e0ad10f81a0cf2eb4669b3bf372da23", + "src/import/DataMove.lua": "e7cc0d51b420e89b65ad288dcafc0b39fa03aaa6144cac5671aed44cbf7f0708", + "src/import/Gen4HgssSummaryArt.lua": "4850ad585d95ad81b19556b7fd0212ccb5baeb6dd3367b75f97224832ff952ea", + "src/import/Gen4Mart.lua": "3c5a64c4d0d716089af548b319e22bf011605532ed5b7f18f531b3b8818f69fa", + "src/import/Gen4Abilities.lua": "eb63e1afd9e921f414f3490a2209a7649488e7557d96deaa33ec0c90eaa7a8cf", + "src/import/Gen4Audio.lua": "cdd96a8668c1eb219a5728c3d1a862ccdd7da15a3ef9139f8f90dfce962bbc6e", + "src/import/RomImporter.lua": "715665c05439a64cc692d7f2e03f05d38b2350e8e9f103102ff73d4567e0beb4", + "src/import/Gen5Audio.lua": "cf6ee9908bd6fcaf532391df7ed1e3563bd651acafa60f4001068159e1008820", + "src/import/Gen5ItemIcons.lua": "8b75d750de1561678f6a45d200adebae73de46a8e2126bd6b84dce3fcee13c97", + "src/import/Gen4ObjectGfx.lua": "82f3b93f2d16f03c54e6c40f457906b60b3dd07d78a6b548f2a931fe5797b418", + "src/import/Gen4MoveButtons.lua": "c79dcdca34fa4512599f89ccf30c45a1ffc706a4abe215a83d65680dc88d3fc0", + "src/import/ImportRecovery.lua": "ca54c59e6fa164ba6882f87fee1712e20074a4963a975b8b5f4e176e5b48414a", + "src/import/Gen5BagArt.lua": "ce7e910da9992e6ddf21392015629846b709ef73aa441233e278ff9c588ed28d", + "src/import/Gen4HgssHurdleDashArt.lua": "659bbd47828edaa29044681c8310f78ba525854347c196e0930dc49088b5d310", + "src/import/Gen4BoxArt.lua": "a3c3cf31d8d86f29fa4313a7bff536943538acb8d60af82213d339a4d987ea14", + "src/import/CacheFs.lua": "415e93dcf0f9b85cd390684ac3ece39c9a2baa2410aef176cce2eb25c3c09eaf", + "src/import/Gen4BattleArt.lua": "c2e1fdf333c0e798f9efe440e2713053bee6e6bfe7f8d1ecc9f1359b46cc6159", + "src/import/Gen5MonAnim.lua": "a4f4a0776afe9d19b9eac1e1fea811f2162a6864cef4bf89598600b0c76481d3", + "src/import/NdsRom.lua": "6905535a4fef88336b04e44c01c36ff1e2862fa0fc5af90c1475ea6a9a033184", + "src/import/Gen4PoketchArt.lua": "a3e4d2f799a5563dc7e8b55e4c82ea4130a499848afbc714158f0445f580e847", + "src/import/Gen5Marts.lua": "94033cc946e95d1db275c5fb9b788ac2665ae3d4ded6267de670301cc2f68158", + "src/import/Gen4Shadow.lua": "58e898a80a7e1eab2da3c3250cc1dc0e05731914651d4a5abc25a21dc6ff6b83", + "src/import/Gen5TradeArt.lua": "4861db8240584b576290ea73174c3a5a9fc9956527cad14ee300a1ef53fbf2c7", + "src/import/Gen4HgssScriptAliases.lua": "3b6dcdee5fc5ab61a10156859ac218f64d46fe3bb9f8d3a271f2e395016747a0", + "src/import/Gen4BerryData.lua": "379498577c7ef08953d293aa3c8031e2b9017f11520307087f9f94b6cf9687c0", + "src/import/Gen5JuniperArt.lua": "ce76ce7a98d5830a80f0c828b3592b511384a89e555da5676a2de4116df5cb3c", + "src/import/Gen4CellAnim.lua": "3c1378b3c5390d9b6d794e50e3eb7ed9cc8dbfa1e3d69bef62f175245570cbb1", + "src/import/Gen5Particles.lua": "b5afd8dae63d9b8aa87277b7d243d8745a3333ccce93c4eef1afe4e90996ecc4", + "src/import/Gen4HgssPropAnim.lua": "98aecd92b57cd2f0ec22345e152a98fe82fe40f88a9a7ee461ec5b6023ee3793", + "src/import/Gen4Behaviors.lua": "9b546b07e7681a230243f092c1f3e0489519c37c8a5d3d6031f5e73067d03f90", + "src/import/Gen4HgssCertificates.lua": "0347ab81141d2bcae138c71f576162a5ceaeb62df51d092b28fb2a223555bc4d", + "src/import/Gen4MoveAnim.lua": "f9f0f0c61838bc4eb8e0a68f466ea11b0f7feaac15c070197ce8d4d155bb6c93", + "src/import/Gen4PoffinArt.lua": "ca14bcb28795acf5ca59efb3e0a3ab08eaa59bfbdcef18848ee099e561b8ae08", + "src/import/Gen4ScratchOffArt.lua": "9ff1ff4d8f5653c28208982c6cce75e0f79b50830a11f0768f9d8f5498a5f17d", + "src/import/Gen4PokeAnimScript.lua": "79e089e2487a28ec0683292ddbd69b3c03e91a1dc0b4863a412a64f61d5702cd", + "src/import/BorrowedTiles.lua": "f58d1f6a780e2694419ccd9db6ccacf076e56bcc32ede6ff5f34318748be8bdb", + "src/import/NdsBlz.lua": "396a2cd920f9cecedfd38d586b6a57d9d4e579899e8dcb64f711ae5a05771d5d", + "src/import/Gen4WeatherSys.lua": "de7910afad61f96173bd2f6668651915562c890c34c142e38b192da576335ac3", + "src/import/Gen4TrainerMessages.lua": "620535a46048785500bf5a868f2a2d0213886def7c30be5b3dddc903b3bd8f85", + "src/import/Gen4Graphics.lua": "745d10e7e9a518a59946f49ebf6d57121478a2f1e625949a2a7db2cfc93de2b1", + "src/import/Gen4ModelPack.lua": "88cdba987ffca3b97d6c393f21b8283c2ce7a1105d64dfbc969931da453507a9", + "src/import/Gen4Pokegra.lua": "64c13544c0f832a9fb856d075a64b465017b988631a2875e30cfe942422e6851", + "src/import/Gen4Sdat.lua": "94c4a792c621d525b25204f83968797261b4d89b610ed16c27492286ba1fb981", + "src/import/Gen5FieldEffects.lua": "911accabb16fd06cac2b6e28856ce487abf403a940eaf27494b4491095847ab9", + "src/import/Gen4Movement.lua": "594a1c8c3b52a1500729416f02de271e19bf626244cd855f60608109461e6e68", + "src/import/Gen4OptionsArt.lua": "b52747fdfe7f763cc3725dcda8c007a9703fbdf608a7775370873f51c54db3ea", + "src/import/LauncherArt.lua": "0109c5a944328524a346a48ab21542abc154bb21849b8ee6929ae1b2b8e0be84", + "src/import/Gen5DexArt.lua": "8d8efb73bb69e9b39f1fa0587f06b1a95faca9e7ba8e6257c7597edb65e95735", + "src/import/Gen4PokeMotion.lua": "f84a4ec400897c3cc25494b6b6ce59fca034475ccdfa94d0e70d6f4d32f6ec67", + "src/import/Gen5TownMapArt.lua": "f052c2ac129ead2c9eb15480a43273071770492c270e75b0149900d35fba40d3", + "src/import/Gen5MainMenuArt.lua": "ba496c6aa8d365ed49a0a116be88b830194e864b72400355119c61628edf0573", + "src/import/Gen4Pickups.lua": "bb9cab2925e9fb8a4c1930a94c9df98ec971676e1abe647d9410977a5ad474df", + "src/import/Gen4EggHatchArt.lua": "224759ee8f9d1ce8754649179179af907fbe09564659ec801fb4cee92bdaa085", + "src/import/Gen5PartyArt.lua": "b4b99f980ef2b1ee2efea6ba86217859ec5ca86caee2bc788dff5aa4711150ee", + "src/import/Gen5TypeIcons.lua": "6faa71520aa44a870457d6aadb01dccab3170c92c4e10d7ab726c0a669519ffb", + "src/import/Gen5DynamicCity.lua": "55176584d9f3f66ac99f670a522b14e50ef20940769ee452e5c0e5afe200c06a", + "src/import/Gen4HgssSpecialMarts.lua": "47a81f9c646a516360afefbd956063fb0bf0ca852518c6d2edb50d5a3fa1d2cf", + "src/import/Gen4HgssLegendArt.lua": "26bed2a4e19feedb86f522130e42fffe76442970870e33eb8d11452900e40684", + "src/import/Gen5EggMoves.lua": "9f49ba80fd33f5c3c399baf02eebdd3b48563275946e33b794dfaa31e2431d6a", + "src/import/CloudSync.lua": "cbfe7f609b8f123019ddf2b02a676266b9a239fa59acdc4fcf9d9a8503387734", + "src/import/Gen4HgssBootArt.lua": "82a3978e84db00a51ec4b25d48f2fe52f555a175a24b6bccd3741db90cb1896a", + "src/import/Gen4HgssPokeathlonDiscCatchArt.lua": "11f25541429ed06a380a69b86cf1951434214e3815c74db1953ff97d3655a0b1", + "src/import/SaveFileIO.lua": "17c100d6827d8909038008b9802e6128a8620b496ea430d7ab8fc4e38f9a7160", + "src/import/Gen4HgssPokeathlonCourses.lua": "95c0bcf45dee0c946e9b39f5ca6796fd7be6b23ae974c1d429c8a76f3d7429ba", + "src/import/Gen5Heights.lua": "71eb6fdf57543aa80ce14d5cf6510b1030c95132cc3d64270afcd87827b5ae26", + "src/import/Gen4HgssFloorNo.lua": "7fbab5b6fe7857922f8c903d61563b3c81d1d1307373b67213ae3e206dc7e37c", + "src/import/Gen4Feebas.lua": "6684b52cd6289508a9850a70f07159d9d4648a15134a22119e3fd296c397a300", + "src/import/Gen5BattleScene.lua": "0cbf34e2a11e832486642052dbd6cda904d5fbd0e8c8774d982e491224c78553", + "src/import/rom_import_worker.lua": "494a33f7d1c43b4dc84e2d7409972209726c1fb903447e1aa6c641764b5ecd03", + "src/import/PackPublish.lua": "f60513624737e5d804faed65b65f0bb5d1d435cb17810dcfd3eeb137b9e000b4", + "src/import/LauncherTheme.lua": "470feacf8cb89263811bd697a05ccb55df2b2a54f6a6e4cf4e846b7cae512819", + "src/import/Gen4PalParkData.lua": "96b54b51a52edaf93f6cd1a970a8968009ded3680ca9a08a7a8205a239b2e59a", + "src/import/ImageWriter.lua": "d5622dd626b4242a3d1e8385702966ee59708be9ef8a9b0f7e5a42d98acc9c51", + "src/import/Gen4HgssBattleUi.lua": "a36fa520d14057f4337e304f4a331f342b985bdaf3958891d299da12f4fdffa6", + "src/import/Gen5EggHatchArt.lua": "1ff23bc40ff79ed72340e458ab9f57d352c3add60229d579fe3fb7a60a073704", + "src/import/Gen4HgssPokeathlonGoalRollArt.lua": "1593e7afc4207be6a53de5065e576efa70263ae312b051fd8a54168037896576", + "src/import/Gen4FrontierArt.lua": "69cc03e3d3aa4502bcbe56894c905a68adfebd41aacf6854a30f9f207a3119bf", + "src/import/Gen2PhoneIdentities.lua": "a031b0b21fc3024b5478391681fe4b29208fd21dc51a752635ccd1c9499488fa", + "src/import/Gen4MainMenuArt.lua": "61efde4e82628a04df20f818ad72a5b37c4b12ddd98d2df5c145d06617f43bfd", + "src/import/Gen4VoltorbFlip.lua": "60305416c1039a88f6da06c91bb13ab042fbcd4e2f246e09a5ce9e83d1ffea81", + "src/import/Gen4HgssPokeathlonTeamArt.lua": "cd3db5f4ad483ec50fc55f16f7ee71bfc45be2ed7f6030167446c9277d07ae77", + "src/import/Gen4HgssPokeathlon.lua": "18a5942bec47adf0033f6110e16ce81241c9dfea696f8de0ec09e773f1a690d5", + "src/import/Gen4HgssSafari.lua": "92088791df26dfcda8f8512b69260650f3e912d5596a28e9217d20f5fb40ea68", + "src/import/Gen5ScriptLink.lua": "a5a093728c4224969e6bbf8362ddf73b7e275d5560bba1e772847af26f48cd51", + "src/import/Gen3TradeArt.lua": "94b36762822c710cd07ec6cb5fd38ac3b6339e0d6439a7a6d15e587e68b5bbf3", + "src/import/Gen4Script.lua": "5940cac0a1107f93a9f9543b0f59a412b5feadc1d8fe07e53db0aa489ce1d014", + "src/import/Gen4BagArt.lua": "b470b96b9e80f3dbd8e93db7045d2f0fd4a9d8260dd500f344428eb27d3ef48f", + "src/import/Gen5BoxArt.lua": "3638c06ac1f292bf3195d58af2225c0cfb417060b8188c6e8c6bd665842da532", + "src/import/Gen4UIResources.lua": "4ed1a09603cd84881332fc8ad626faebdceff8ef26f92e340aa966a1ff3e01fe", + "src/import/PackProducer.lua": "132d4f610ff0c15b85fbf9e32233fb209905d9f5ff43cc3aceeebe7183049783", + "src/import/Gen4Species.lua": "b50183857c6954d1c6902c6f4874600135abac5b889ceca848d8e804681ea3ae", + "src/import/Gen4Tileset.lua": "c61ad1b5ae2cc6c05c9f5edd86b36b6dcf527f2f3e8182e857c9a9b2f548a21e", + "src/import/Gen4HgssFieldFx.lua": "30ced2204068305179b8406dd923e34824d265b2b7371275125e3461b0f9d620", + "src/import/Gen4HgssMenuArt.lua": "64cdedd0d181cae75df51bfbf0b460e2560ddd9eebcbfc0ab6c3dca8b56a41e6", + "src/import/PackData.lua": "9a1fc4f747d17237c22775e9378055f0ad38513b35b13f3230e1498fa1050d3e", + "src/import/Importers.lua": "79551c25cb8eeb9dbdd8445ecf0459655c2a50e6d096ded6555ab4d332fc4601", + "src/import/Gen4HgssTrainerCardArt.lua": "33754f392371f1f4742ecddcdbb144a9c856065f088bfe72e7928d02992cf5ec", + "src/import/Gen4DistWorld.lua": "9bf51ffd0d7405339354981775e75b893a5ea1de7f9c62a7f7a8c37c8d225c3d", + "src/import/Gen4ScriptBands.lua": "8e3156af4021cbb851d4b0251d84002c03d788ca0274517065f1724279586fc2", + "src/import/snes/Graphics.lua": "5400a2ed931e2767873389e4868aee8673397387c899a859d40dde7bddff0e68", + "src/import/snes/Lz2.lua": "9bbbf6223bce4ea7f9f8a5498eecf1ae196f1b7583759c55d7cff77fb4950445", + "src/import/lttp/SnesGfx.lua": "6f294fbf29b64dd9ce05ce71c5e0ea488837b2d98a62ef3be11d61b2c56e70b1", + "src/import/lttp/LttpImport.lua": "e303e798d973b4432cbe5a5aed48992f0674a124a7c601eec5f35c196f96e854", + "src/import/lttp/Lz2.lua": "099913192c77063b8decdec7a4c9e3781206d259ac78c55fabf05517dc80ffbb", + "src/import/lttp/Overworld.lua": "660f6a1c587c4657190c53a7a72da8fd3fb762c9014f3cb7a8d9ad3653aa5cab", + "src/import/pmd/Sprite.lua": "1c61fd5446c85d2600a7ad7e87fecea95f31b09102a5cd7329042b44a9cebfb5", + "src/pokewalker/WalkerRadarPools.lua": "8065d79cb74e4bfa88dba072287efe345403d287d8603274fb182932da50333e", + "src/pokewalker/WalkerEconomy.lua": "9f4e1122cec088896c2dfabd383024f678e9ad3d7b1fb9ec3751e6d2ee16fc7d", + "src/pokewalker/WalkerDelivery.lua": "8f084a6556aac8b97cbbc49ca6345931614cafb62ae9fe2272f05d0409afc368", + "src/pokewalker/Walker.lua": "3b88bbf30b01970f12e3ef9629bcbf046c092627003ec8dbd7a59f62929aa6db", + "src/pokewalker/WalkerEvents.lua": "3d7a21895e8b343010d4f1a08f25fe91ea905607cb1319aefe37ceac3b5fb499", + "src/pokewalker/WalkerShop.lua": "32abccbf6543d1a048b2b39754d44bc4c1cabdb7dad00a1cf56bcc3f3295e770", + "src/pokewalker/WalkerCourses.lua": "120dc847424c1be13f65617ee26fd7cbdd55397c0bef6b7cb8d6a21a49151524", + "src/pokewalker/WalkerNotify.lua": "c1e51924fe5ad8be79079f7bfd4c42e1df6f52785426372c7a6c961916e29765", + "src/pokewalker/WalkerArt.lua": "9dd09eaae8636dc1379a094ac6293c2b26471e3f81562147620c4d98dc6b7c18", + "src/pokewalker/WalkerInbox.lua": "28ba8e64fb595cb71796bb4bb5c829f5904c23ba47144e8aa76553d3db336623", + "src/pokewalker/WalkerRoutes.lua": "7f7a07cb6cd4b8baf14408833197fa745b575a5cc63a210e58341b0c32b8f057", + "src/pokewalker/WalkerLink.lua": "4a617d6a63b5369eccc5975397e30a99edcd86f54610a68321c2e6b485d3125b", + "src/pokewalker/WalkerSteps.lua": "d0ef0aa134ee49a7fdcf31b74d68c3c7abd5144c78fa5a5b1cb0db3509f37517", + "src/pokewalker/minigames/TreasureHunt.lua": "1c05eaf5bce009dcc6e7fc540994f1e4b1475d628309040cb6992e62d2a69a36", + "src/pokewalker/minigames/Gfx.lua": "fbd1fd80b1b131408b353247134f135b8d6ca3038c00009f92a01d1d93af34f2", + "src/pokewalker/minigames/CatchTiming.lua": "8b8a5e1cba3da4949363510ad6a67c24a8a37a77ed00426e05e2c041899d3327", + "src/pokewalker/minigames/Rewards.lua": "2f933a366280dd9cc3e04d93e6cf865dee394c097a6625d4af25ac477fe5a41c", + "src/pokewalker/minigames/JumpRunner.lua": "06e0037f657a4e2c384cf9eb37f3f2838ecca69d5ebf7b3d588a9c9c243d2558", + "src/pokewalker/minigames/Context.lua": "9d0b920f2074499a6dd2127d993a1afdeb450a4812bc52b96d5eb46f642b0216", + "src/pokewalker/minigames/RhythmTap.lua": "e81ae69b36e84d21d26604eb89428ee800dacab5ae7251813c8b50d96e693a7d", + "src/pokewalker/minigames/Base.lua": "8d5b8b3da1a22a42af3aac2e8eb1db564a41f05946e9cf73605657e36aa08968", + "src/pokewalker/minigames/MemoryMatch.lua": "25719608a817e8534c14f3a118ae3285ddfa389c634c7261f1e7364dc274b316", + "mods/DRAMATIC_SHAPE/main.lua": "b9285e7d22948ea731713ae501c43f8a673f1c397053ba8cf0d7120693381012", + "mods/DRAMATIC_SHAPE/manifest.json": "b94bdb437120be77c014937f0949a17ee81377db570abb46d997e80dd7e52ca3", + "mods/DRAMATIC_SHAPE/lib/HullCache.lua": "f00c5349d5fd394c11f6de81df78e533c41b92f09a82a12a3e1df3cad160811a", + "mods/DRAMATIC_SHAPE/lib/ShaderState.lua": "5a43d3be681ffaab6744425a1a6a9964c466b9a8a568e2286947dcc04ac38595", + "mods/DRAMATIC_SHAPE/lib/RetireQueue.lua": "ec210b5098aed8cf0f9a49a36ad414b95fd7e83c2fb7cf194c90297772242250", + "mods/DRAMATIC_SHAPE/lib/PolishedAtlas.lua": "3d84ab22295655f41ce5eeb1eb4e549a6926a101b9cfefe2d857f037cafaa3cf", + "mods/DRAMATIC_SHAPE/lib/HordeMobs.lua": "15a2548a9f1f1c62f03f41fa5ba4a644c372c0ae50f61876b68f282dbcdbbee5", + "mods/DRAMATIC_SHAPE/lib/FirstPerson.lua": "7ea2022fd7b44095b75608aa70b87dfd1ba311ad20397e2724f04ce8813fc2a4", + "mods/DRAMATIC_SHAPE/lib/WorldShadows.lua": "121d65657f992b93cf63b2f50455297943efe89d9150822a02b8e89ca160419d", + "mods/DRAMATIC_SHAPE/lib/VRXR.lua": "8d9fdb79d00864e94e5ca0ec39a40c3a3006335e1c3081c878ad51ef52c0c6ee", + "mods/DRAMATIC_SHAPE/lib/Voxel3D.lua": "8c19dd62281c8a2eff129627e51bf2d50c208c672d76808b9ec9ab31190807ed", + "mods/DRAMATIC_SHAPE/lib/MeshBatch.lua": "f9e4e401c619e79153b6a663638ae10a005f09c7b1f18b7944e29b8e259a59e1", + "mods/DRAMATIC_SHAPE/lib/GpuQuadSink.lua": "a8ea5f044b92b4eaf8653dd09d2fa24bacef36cf0022f42573239727bc9927b8", + "mods/DRAMATIC_SHAPE/lib/PolishedProfile.lua": "0887c7310119f7e59d6241386df130e8f5c42aa1e38fb8d532f217d5d3ea8156", + "mods/DRAMATIC_SHAPE/lib/HordeSfx.lua": "aead3d1be4ef435680bd24c64f7168d7bc7b55fa9010a83d325a3e6f54d10d43", + "mods/DRAMATIC_SHAPE/lib/ChunkMesher.lua": "d169f799b7f3919a01d394e16cede790ddc4da630a4fdb73226d2b10e7f825a1", + "mods/DRAMATIC_SHAPE/lib/BattleArena.lua": "d4a796948c7199cb006dac5f5a4236957edcfa775544a732340fd77b3505e66b", + "mods/DRAMATIC_SHAPE/lib/Buildings.lua": "0217c2a3c6e87b064cc9b2a381ec0c302696deff11c3233ceb000b05ba11ac1f", + "mods/DRAMATIC_SHAPE/lib/BattleExit.lua": "9a46fd8609a0269d0a52be8ceaac6a1cc15b418f8a6056e11b440f50800a7d10", + "mods/DRAMATIC_SHAPE/lib/TiltShift.lua": "6f48728062fc7654b9c5825525d2fc60b428314e28500cff495b92b8f45b1840", + "mods/DRAMATIC_SHAPE/lib/RenderScale.lua": "2cff29fdcac5aa5eb69020432cce25dab050a2f3475c20767bbc872573386774", + "mods/DRAMATIC_SHAPE/lib/Mat4.lua": "af1bf410b6d2855cce3d361837f9096613b17f4300a076e8b530fae5228b4827", + "mods/DRAMATIC_SHAPE/lib/BattleBillboard.lua": "0ee39926a60de750f8e3ae8f9ae7d8c87d59d5e66136d13b8fe0a73c6bea0d42", + "mods/DRAMATIC_SHAPE/lib/ShadowMap.lua": "049897e9f7ac8c791cfd54e22d13161e4d0ac3f70b6000689d1a5e8d118d7f26", + "mods/DRAMATIC_SHAPE/lib/HullBatch.lua": "0910e86248f5bae9d0e0c75ec33d26d86c45a1f7c0c568d7700c973dd834d70e", + "mods/DRAMATIC_SHAPE/lib/BorderMaskGraph.lua": "4f47d971f8e50dae8973bfdeaa09f7ad9ca3fa8651e3c868e00c4227ca644ebe", + "mods/DRAMATIC_SHAPE/lib/HordeExitPrompt.lua": "320592c560a68205158105ff6deaef14550f4fbe3b8a7e56809d9e4ca4a67160", + "mods/DRAMATIC_SHAPE/lib/WaterBudget.lua": "0616d66174acb385a52025e695e10488024ea93e9b28b5df9dd1fb844afdeb49", + "mods/DRAMATIC_SHAPE/lib/EnginePixelCanvas.lua": "f41b25296374ce7946cb4b020f686603d8b0616490bc6bac7e077c75fba70d9e", + "mods/DRAMATIC_SHAPE/lib/DayTint.lua": "d1a356f7e7c023a13a0e73789be3888b8fcef7dd6321b8b7e41e1946e6206a58", + "mods/DRAMATIC_SHAPE/lib/BattleScene.lua": "f06b1de4c323a6921136edd7230ca5eb0bb36fc189302cfe5735a0fa8fd8269e", + "mods/DRAMATIC_SHAPE/lib/StampStream.lua": "2089062b32c18a99368bc0c3c840f93e85a10143e2a440c3f89b770a25b81e5d", + "mods/DRAMATIC_SHAPE/lib/BattlePics.lua": "14c8f8f20c4fd7289eef1b1d5ce456b7927e39baf7e367c11b2a6b565078a16e", + "mods/DRAMATIC_SHAPE/lib/EmeraldBuildings.lua": "537c2fba21def0740bd9690fdcf031fa425f309ad0e7f9e17264648d2009fc19", + "mods/DRAMATIC_SHAPE/lib/VoxelDiskCache.lua": "c796f3b07839c8d47a93252704fef0ca41179021f4f5a3d71515df7cfdc13315", + "mods/DRAMATIC_SHAPE/lib/TilePlotCache.lua": "bc3a307b98939278b0c4e85c5e5660c6cdc05f1780e71d2eb4a52f6480a4aa4a", + "mods/DRAMATIC_SHAPE/lib/Rewrap.lua": "59dc977ef93b8628bf35573ef5fabe3e3ab78d8792630927a6c838eba4698dea", + "mods/DRAMATIC_SHAPE/lib/GpuMesher.lua": "c3a4d8484aef31a655fae964fda6902ee8833a107864a84ca7f5e28e6eab7c81", + "mods/DRAMATIC_SHAPE/lib/AnalysisCache.lua": "5c117885d46781a698393ad892404e6bc47b849d2be617aff168834d157466be", + "mods/DRAMATIC_SHAPE/lib/NativeGen4.lua": "a815e76e8a500de55fa6cea437b22a203327ed0d7ffdd5a2896c88901714f3f1", + "mods/DRAMATIC_SHAPE/lib/VRRig.lua": "222909d72fed70594a04665ed47b716a495bafa956e079a83f4acf562faf32ed", + "mods/DRAMATIC_SHAPE/lib/AntiAlias.lua": "7b50c401ea1a34a022391042befe170d6e0479edae2f69a6783597473c976b6c", + "mods/DRAMATIC_SHAPE/lib/AtlasPixels.lua": "cb8bfaa227c2205277b8782c847548e086e34fabd10c848406cf1b3a643fa087", + "mods/DRAMATIC_SHAPE/lib/BuildBudget.lua": "85c8556db6f747e62d39c74c7285704a2bd42f54a0b9e3ee22119dd571cb631e", + "mods/DRAMATIC_SHAPE/lib/VoxelPrebake.lua": "59f17fff46250d8eecae23bb1ffaefeb10d8e4e6b9ce16daf068eca7dbf67f1d", + "mods/DRAMATIC_SHAPE/lib/TerrainAtlas.lua": "19f226eb2a05539b43460b2302f4eae3a373dac3f8fb1b9e77f9fe6df82fefc6", + "mods/DRAMATIC_SHAPE/lib/BattleDOF.lua": "da42434316b734758ea562a528719e2f8879859bd2e7babfad392cc11a754c62", + "mods/DRAMATIC_SHAPE/lib/DayNight.lua": "b0556aab01bcc2679b45dd61ce55d2a4186c3f52d134671a8eb24b2a170d7f14", + "mods/DRAMATIC_SHAPE/lib/GraphicsCompat.lua": "575c8ed741f8f3344dcefa4dfd4f539caac13f1f2b1ea69f5120d3a4dfed179c", + "mods/DRAMATIC_SHAPE/lib/Structures.lua": "c4dc200239c3119bf87a226473ce72fa4b5290ed66fc204801c1b3be06a81781", + "mods/DRAMATIC_SHAPE/lib/EngineSheets.lua": "2fe429ca76673f99208dbd94bea8462a679530e934b96a052d88e14e45e09c82", + "mods/DRAMATIC_SHAPE/lib/NativeStage.lua": "5cc43771afad181afd45b5ceb96039c0e48dde76aa8a4133ad204ebaf4a36f5e", + "mods/DRAMATIC_SHAPE/lib/VRGL.lua": "cd64c759d8e5206151307904bf039c16d8b046b9349ad2f6c4b3eafe5949d73e", + "mods/DRAMATIC_SHAPE/lib/Pokedex.lua": "f0e7c0a9429486094e84b90ab94bab695d78a2c4ceb0e7c88e56c43206b9f9d8", + "mods/DRAMATIC_SHAPE/lib/VoxelScene.lua": "115e2a358d94bdafdbaf6395edd15bc76b678515f36ba2c7c61f90472093e195", + "mods/DRAMATIC_SHAPE/lib/BattleHud.lua": "0bd31b17c37686068e747bff362b647c1d9614316340f3f313cd2f53abc88f31", + "mods/DRAMATIC_SHAPE/lib/Sky.lua": "dedc35325fd7923c0dba940dc90b5a1f50574ac7775b98367632c76a366ed992", + "mods/DRAMATIC_SHAPE/lib/TileShape.lua": "1ded661f0a08c719ce42994d643c43cbffcaa03e27ae260a1ed4d3d444e90931", + "mods/DRAMATIC_SHAPE/lib/Tier.lua": "a02d73cd3eef5461632a2fc6e6934cb1d2298c15fc199d1500a59d3bb76ab386", + "mods/DRAMATIC_SHAPE/lib/Water.lua": "47ea97b33cd6b3fe9b8cfb65e763fe04d5f2857e48dd0759c8074ac7405dfa9a", + "mods/DRAMATIC_SHAPE/lib/ImageCache.lua": "9f7b3b008a475243086ccf542f3f80821fec520395df9b66894da9d29642a776", + "mods/DRAMATIC_SHAPE/lib/HordeHud.lua": "67faa79d32c546a6f92b3f0a5c839896bd6718dba5a099307d9f43b82d6f8cd4", + "mods/DRAMATIC_SHAPE/lib/ModSetting.lua": "1ca8f395454f50b5c54708242ca8fe986d0f138482d7540887a0309b71deddd2", + "mods/DRAMATIC_SHAPE/lib/OverworldBattle.lua": "5ee8feb4d65c1200b6c729a92ca3da9c2f85625a1498e62806456a31cffd5c90", + "mods/DRAMATIC_SHAPE/lib/Horde.lua": "28d40cc8019bcde39cbc9d19eb390156e4576decdd52b92e49da8306b9e5bef9", + "mods/DRAMATIC_SHAPE/lib/AtlasUpdates.lua": "eac5fbf72f24c13a3ea3e9d0f5cb8c8fa49a58c7fca46f0d06b646263a1bc00b", + "mods/DRAMATIC_SHAPE/lib/RenderStamp.lua": "1f418a058610659dd86778d756e608ea23971fe77ad6f545be27fc5ea1321e4e", + "mods/DRAMATIC_SHAPE/lib/GrassSprites.lua": "20c15d5decc2616341caa809a01fb5d7ff648b6ae11256e5df94b63cd6cb9519", + "mods/DRAMATIC_SHAPE/lib/CamControl.lua": "2c3f2ede7229a1b209e44acbda65a90317687f80b765f00573228a52ca42e68f", + "mods/DRAMATIC_SHAPE/lib/VoxelGrid.lua": "60856c15b4fe39f9371032b9a0847a642e6ef6719664e7698a51d3776c4bdabb", + "mods/DRAMATIC_SHAPE/lib/HordeGun.lua": "1cf8f87be6a6c4bc61ac01bfdd64ef7eb26fead8c682adfde708af39baa35a51", + "mods/DRAMATIC_SHAPE/lib/VR.lua": "18a8431d5fb5eebf84fb7216b0855df556d57be72ea706bbf64e2f7309120ce8", + "mods/DRAMATIC_SHAPE/lib/ThirdPerson.lua": "89d33454a0b8262ea5fac1d477870badcf99ce7c1ac2f99482981a368413935b", + "mods/DRAMATIC_SHAPE/lib/HordeGameOver.lua": "f56e5d3ece1e6cc7ac2d6a442df250b044e718525b9e9cd0f363c6179d2cf97b", + "mods/DRAMATIC_SHAPE/lib/WorldCurve.lua": "91ee9b80d6b9dec75f7e643159fc9ff52a7dcd7bea06c036295dda9828831a94", + "mods/DRAMATIC_SHAPE/lib/GlassMask.lua": "242327056d1b09d28278588f6b8c712f665ac57c38a5b4fb4320903310d98778", + "mods/DRAMATIC_SHAPE/lib/MeshBounds.lua": "aa6498641058e536f57764235ece43778c4ceeb831ea2629e2891892bc2c18a8", + "mods/DRAMATIC_SHAPE/lib/Gen3.lua": "268de6258e4910ee7f4e37eaf665a3a9fc01a4af98d7a09791171996122a473d", + "mods/DRAMATIC_SHAPE/lib/BattleCam.lua": "ea605b9ecb9c272676772344334eef6d6d23b1fa92bac7402c7f16aebe9d780b", + "mods/DRAMATIC_SHAPE/lib/HullPoints.lua": "bf0849eb323cc5af8f08818dec6b6ce4902d6537f7936ff6ceeab88d2dc22329", + "mods/DRAMATIC_SHAPE/lib/QuadIndices.lua": "95d334fb8277c10de48a12f5c8d10f7b96df903911159bb65cf67852146ebbde", + "mods/DRAMATIC_SHAPE/lib/PolishedFruitSprite.lua": "43582c5a5ecf40eb61eb04ab1d1149875defbeb35b482f390d8a54026dd8fbbe", + "mods/DRAMATIC_SHAPE/lib/PixelCanvas.lua": "a86164fa7147644e2c497fcf3ce4d6c00e1bbfc0d81601b35ac6f89f7bb6883c", + "mods/DRAMATIC_SHAPE/lib/Perf.lua": "f88c185ec321b98b1ca5e797f4601b165fa042487d947024fc30c64d271a9802", + "mods/DRAMATIC_SHAPE/lib/VoxelState.lua": "0d6aeee58aebeea880d8dbf482d7243b55b60c413146db5a5684a91528fedced", + "mods/DRAMATIC_SHAPE/lib/SpriteBillboards.lua": "827e540dbb8a897e576adc5a4119f93af275b77aad9876d854a0373c52ccc138", + "mods/DRAMATIC_SHAPE/lib/FreeMove.lua": "35c7e609652472255ab7c8ed2db50cdcfe493de154c8a4c04a7a2cfd48181305", + "mods/DRAMATIC_SHAPE/data/gen3_terraces.lua": "2d340b90d55a351f03ca3d2bc53b154f369c88e58954cb093aa2677c25d10f26", + "mods/DRAMATIC_SHAPE/data/gen3_shapes.lua": "a6e5c69d91c53d05282afed8499cfa41226702baa0f83da7f3cbf49bbbcc2fce", + "mods/DRAMATIC_SHAPE/data/gen3_palings.lua": "dd9cb9cf168545c61c9f328bdacc983d8975f26e9879c3f375ffd1916c6e9832", + "mods/DRAMATIC_SHAPE/data/battle_arenas.lua": "b94c2f2df11baff890869cf9a5528632e28dd291c994933eac3824785d6c9ecc", + "mods/DRAMATIC_SHAPE/data/gen3_metatiles.lua": "5dc2a6be45e57057c0ed8e94388741d76eaa266a853eaa40133edbcca99d5387", + "mods/DRAMATIC_SHAPE/data/gen3_maps.lua": "2fa3f7d3bc69e24269331e5992b8be8791ab455d3c43772001a55a17e2547a55", + "mods/DRAMATIC_SHAPE/data/voxel_heights.lua": "71610ea7b18e4053728b8506b41fa2fb7ed8e09ce5e81e70ccfe95af408e3aaf", + "mods/DRAMATIC_SHAPE/data/polishedcrystal/voxel_indices.lua": "6a2381c84da1c5b6877fca232741629523f78ba2ebe5321035be6ea52142a554", + "mods/DRAMATIC_SHAPE/data/polishedcrystal/furniture.lua": "6c79002470fbb753a421e25fe6440f69b31a1850b1b9261cf360fba6872c0bef", + "mods/DRAMATIC_SHAPE/data/firered/gen3_palings.lua": "e036aafe4e07e3a749e54537f38b55ff21dc07f5322df3fe1dae662326f4734f", + "mods/DRAMATIC_SHAPE/data/firered/gen3_metatiles.lua": "36dbc083583aaaa8357f0177d282d15f84895dabd678ddf134513f323d96d0a2", + "mods/DRAMATIC_SHAPE/data/firered/gen3_maps.lua": "af479e8433b1d728bc9d8d529eeba7405fe6c724fedf36fd19dd8a13f5e9bfab" + }, + "driverSha256": "ef1131b63ddf8b3a9b9556bbd78f04f36da8e5107fa87ab7d3288519c431f881", + "minimumFreeGiB": 20, + "games": { + "native": { + "edition": "emerald", + "architecture": "arm64", + "simulationHz": 60, + "phases": [ + { + "enabled": false, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.49790746527778784 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.17459218750003708 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0023784722223529897 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.01573489583333032 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0012852430556635022 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.0032104166665880562 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.3460383680555049 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.2580565972222634 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.2715119791666498 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.36096354166677 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 9.030534027777744 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.25547986111109644 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.004042708333277615 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.002932291666690734 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.001277083333469283 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.08104236111119063 + }, + " update: music": { + "windows": 2, + "meanMs": 0.06019947916664122 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.01713194444438552 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.06910017361100307 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.23380798611100095 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 10.707645138888878 + } + }, + "fpsCap": 60, + "present": { + "meanMs": 0.3601967013888929, + "p50Ms": 0.35245833333430454, + "p95Ms": 0.397124999999221, + "samples": 240, + "p99Ms": 0.40908333333256053 + }, + "vsync": 1, + "elapsedSeconds": 4.974142666666666, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 20.805987970711275, + "p50Ms": 16.678291666666567, + "p95Ms": 18.240500000000992, + "samples": 239, + "p99Ms": 21.201708333332014 + }, + "card": "P1" + }, + { + "enabled": true, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.30275954861109045 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.42259236111102566 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.0041888888889266935 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.19896128472161045 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0022769097223651613 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.005755729166767587 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 1.112777430555393 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.9962954861111022 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.07947569444460474 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.4066420138889285 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 14.600797743055619 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.005610937500044599 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.006154861111292339 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 0.3171631944444586 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0038475694444040087 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.35535000000022077 + }, + " update: music": { + "windows": 2, + "meanMs": 0.05533524305552836 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.024421006944500924 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.029184027777902816 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.9673236111110558 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.9907213541667865 + } + }, + "fpsCap": 60, + "present": { + "meanMs": 0.4207958333333058, + "p50Ms": 0.4203333333343551, + "p95Ms": 0.4658749999997269, + "samples": 240, + "p99Ms": 0.48741666666884953 + }, + "vsync": 1, + "elapsedSeconds": 3.9701822083333322, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 16.602120118549507, + "p50Ms": 16.672041666664583, + "p95Ms": 17.85925000000077, + "samples": 239, + "p99Ms": 18.597583333335166 + }, + "card": "P1" + }, + { + "enabled": false, + "world": "MAP_G00_N15", + "sections": { + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.08542413194578083 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.2972538194441506 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0026890625000941526 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.002287500000175413 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 1.9751751736112468 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.7183272569445769 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.6414664930556757 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.0765656249999136 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.20061336805593893 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 4.625552430555668 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.63671145833335 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.0035819444446823683 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.002818923610907002 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.008072916666771826 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.19604496527763082 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.022921180555712795 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.018548263889147286 + }, + " update: music": { + "windows": 2, + "meanMs": 0.028725868055682703 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.6131835069443407 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.005145312500006345 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.2954885416668625, + "p50Ms": 0.38583333333264136, + "p95Ms": 0.45766666666224864, + "samples": 240, + "p99Ms": 0.48020833333950463 + }, + "vsync": 0, + "elapsedSeconds": 1.852431833333334, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 7.720079497907943, + "p50Ms": 6.225374999992539, + "p95Ms": 15.866750000000707, + "samples": 239, + "p99Ms": 16.76220833333275 + }, + "card": "P2" + }, + { + "enabled": true, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.34213923611116454 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.2467182291668202 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.004387152777581467 + }, + " update: music": { + "windows": 2, + "meanMs": 0.040143229166966776 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0021788194444501605 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.005009201389016837 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.966205034722023 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.8592093749997698 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.07689618055544874 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.30280312499984713 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 3.798583159722316 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.005752604166486819 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.007768229166593699 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 2.4019213541669546 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.003772916666762901 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.11319704861110225 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.02850937499991879 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.022674479166454965 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.027639409722442682 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.8268696180558255 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.8525468750001746 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.023374479166567614, + "p50Ms": 0.022625000006826212, + "p95Ms": 0.03350000000068576, + "samples": 240, + "p99Ms": 0.044291666668527796 + }, + "vsync": 0, + "elapsedSeconds": 1.7467716666666675, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 7.302367677824275, + "p50Ms": 6.209083333331478, + "p95Ms": 15.41841666666599, + "samples": 239, + "p99Ms": 15.980833333333777 + }, + "card": "P2" + }, + { + "enabled": false, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.3992840277776614 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.2451385416668117 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.0036222222224768785 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.026395833333081598 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0017378472220978363 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.005035069444359053 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 1.1527711805558707 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.9039949652773327 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.10894045138862829 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.32641597222224183 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0038461805553483925 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.8966864583336805 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.011103819444328405 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 2.826288715277814 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.002128298611140759 + }, + " update: music": { + "windows": 2, + "meanMs": 0.17838715277775918 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.030748263888839368 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.023782291666870257 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.11019184027804367 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.8706876736110332 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 2.4563439236110858 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.026294965277617877, + "p50Ms": 0.02529166666320748, + "p95Ms": 0.04308333332758707, + "samples": 240, + "p99Ms": 0.053624999996060296 + }, + "vsync": 0, + "elapsedSeconds": 1.5830512083333304, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 6.6018125871687525, + "p50Ms": 6.2344999999943695, + "p95Ms": 14.46174999999883, + "samples": 239, + "p99Ms": 15.32566666666213 + }, + "card": "P3" + }, + { + "enabled": true, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.3836902777782628 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.2741229166667155 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0055611111110298594 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.004095833333828125 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0019831597222778425 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.006661979166470525 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 1.1829500000005635 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 1.0398100694446466 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.08772187499856976 + }, + " update: music": { + "windows": 2, + "meanMs": 0.05594635416668048 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 2.833618750000122 + }, + " step: whole": { + "windows": 2, + "meanMs": 1.0330409722221803 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.011414062499988848 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 2.664316493055526 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.011806944444581191 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.0261173611108223 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.12621180555630515 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.024380902777595047 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.029651562500765394 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 1.0056237847225387 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.3440744791662306 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.026160416666535607, + "p50Ms": 0.024041666662810712, + "p95Ms": 0.04412500000228192, + "samples": 240, + "p99Ms": 0.05933333332919233 + }, + "vsync": 0, + "elapsedSeconds": 1.6331057916666651, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 6.8239102161785015, + "p50Ms": 6.317166666661933, + "p95Ms": 14.506041666663805, + "samples": 239, + "p99Ms": 15.551791666663206 + }, + "card": "P3" + }, + { + "enabled": false, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.45207743055533106 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.2992987847228494 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.003957291666948729 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.010248958333125559 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.002578993055143049 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.006068923611124433 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.9459303819446891 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.8268656249999099 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.2193322916667443 + }, + " update: music": { + "windows": 2, + "meanMs": 0.05470677083344218 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 4.223928124999876 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.8173473958333528 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.00837517361131172 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 2.6863362847223606 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.010667187500068565 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.0316164930554604 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.11602847222175565 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.030780381944624697 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.042563888888539715 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.773203819444627 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.3070350694442207 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.03909531250002966, + "p50Ms": 0.031500000005735274, + "p95Ms": 0.07075000000611453, + "samples": 240, + "p99Ms": 0.21924999999356487 + }, + "vsync": 0, + "elapsedSeconds": 1.954807124999995, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 8.14158786610878, + "p50Ms": 6.166374999999391, + "p95Ms": 16.586625000002186, + "samples": 239, + "p99Ms": 33.72341666666756 + }, + "card": "P4" + }, + { + "enabled": true, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.3514807291666422 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.11899236111077016 + }, + " update: music": { + "windows": 2, + "meanMs": 0.020493749999742995 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.002153298611240473 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0013501736110181164 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0018335069441673588 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.34138142361079815 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.3003663194442119 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.13139201388939387 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.17933993055481895 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 4.2370694444445665 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.2969024305554532 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.0038312499998305327 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.003788194444916589 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0007869791669593933 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.05952812499897912 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.0231916666666739 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.0253218750002245 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.04235572916639896 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.27154670138885467 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 2.790489236111071 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.03310833333355726, + "p50Ms": 0.026083333331428094, + "p95Ms": 0.05070833333320479, + "samples": 240, + "p99Ms": 0.06962500000184946 + }, + "vsync": 0, + "elapsedSeconds": 1.8118586249999993, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 7.570836297071125, + "p50Ms": 6.403708333330371, + "p95Ms": 16.44954166666679, + "samples": 239, + "p99Ms": 17.797874999999408 + }, + "card": "P4" + }, + { + "enabled": false, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.23901371527808024 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.10369409722255085 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.015609722221595254 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0021201388892203945 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0015749999997242508 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.002473611111260728 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.45567413194452416 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.4030769097221073 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.06233663194414874 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.16559999999996577 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 6.115118923610799 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.39623923611162104 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.005280381944257803 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.0039053819446503253 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0015053819441964342 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.056597395832014286 + }, + " update: music": { + "windows": 2, + "meanMs": 0.02541145833359811 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.02149461805635392 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.030275520833278332 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.37639513888866694 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 1.6684697916672597 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.027260590278199714, + "p50Ms": 0.026541666670709674, + "p95Ms": 0.04583333333130213, + "samples": 240, + "p99Ms": 0.05329166666712126 + }, + "vsync": 0, + "elapsedSeconds": 2.0056575416666647, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 8.365920327754521, + "p50Ms": 3.589041666671733, + "p95Ms": 17.178000000008353, + "samples": 239, + "p99Ms": 18.174125000001595 + }, + "card": "P5" + }, + { + "enabled": true, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.27386527777772207 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.12078177083300426 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.017743229166544694 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0016664930558481692 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0014640625000339469 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.0020661458343340655 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.49098211805578273 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.41023524305566406 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.07939565972131153 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.17709930555485087 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 5.679511111111128 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.40672864583323565 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.0061267361111265 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.0037543402777136707 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0009451388895224492 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.06295538194457015 + }, + " update: music": { + "windows": 2, + "meanMs": 0.044566145833109985 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.022706770833380574 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.03409201388917538 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.38345920138880274 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 1.8691546875003517 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.030081597221946293, + "p50Ms": 0.027916666667238133, + "p95Ms": 0.04762499999344527, + "samples": 240, + "p99Ms": 0.06445833334112194 + }, + "vsync": 0, + "elapsedSeconds": 1.9589907499999981, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 8.188237796373754, + "p50Ms": 6.337999999999511, + "p95Ms": 17.552333333341608, + "samples": 239, + "p99Ms": 18.203291666665677 + }, + "card": "P5" + }, + { + "enabled": false, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.22869045138905383 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.12707673611120165 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.01856614583285084 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.002765277777102189 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0017682291668800572 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.002762326389849079 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.481777777778151 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.4088437500008979 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.08113368055602994 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.20663402777723047 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 5.974373611111202 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.405649652777947 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.006460937499882155 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.005221527778420665 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0012772569445947586 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.08329583333305615 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.03617638888859057 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.025251215278565553 + }, + " update: music": { + "windows": 2, + "meanMs": 0.03811163194399707 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.36238159722209085 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 1.7012376736104784 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.03283871527729095, + "p50Ms": 0.03012499999499596, + "p95Ms": 0.046749999995654434, + "samples": 240, + "p99Ms": 0.07566666666036781 + }, + "vsync": 0, + "elapsedSeconds": 1.9895978333333346, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 8.319948221757315, + "p50Ms": 6.04783333332648, + "p95Ms": 17.804874999995945, + "samples": 239, + "p99Ms": 18.368291666668313 + }, + "card": "P6" + }, + { + "enabled": true, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.24944739583293202 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.11139340277711203 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.019063194444276142 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0016203125003499963 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0019458333329798923 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.0020694444448743354 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.4403657986107312 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.3651741319445456 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.092690277779095 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.19125677083415837 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 5.90617795138891 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.36204861111057696 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.0039800347225652875 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.0040399305558066105 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0015227430553525307 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.07008142361071279 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.03569999999939455 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.024861458333151155 + }, + " update: music": { + "windows": 2, + "meanMs": 0.044542708333346795 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.33820243055524557 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 1.787778124999632 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.03096510416661739, + "p50Ms": 0.02983333333617111, + "p95Ms": 0.047041666654479286, + "samples": 240, + "p99Ms": 0.08195833335378211 + }, + "vsync": 0, + "elapsedSeconds": 1.9850945833333355, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 8.300677301255236, + "p50Ms": 7.329208333324289, + "p95Ms": 17.653416666661315, + "samples": 239, + "p99Ms": 17.94758333333135 + }, + "card": "P6" + }, + { + "enabled": false, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.399957118055975 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.15994288194507433 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.0214656250000426 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.002014236110407334 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0012348958329096622 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.0025848958335572547 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.4614185763892683 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.3897052083325766 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.08310138888868816 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.21752777777832577 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 3.4080913194446323 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.3863876736108848 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.004375347222218551 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.0035690972227146274 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0009090277774070898 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.079100868052695 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.04339409722229348 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.037366493055894054 + }, + " update: music": { + "windows": 2, + "meanMs": 0.037163715277621634 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.36187795138893364 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 3.0733723958329113 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.04016111111087758, + "p50Ms": 0.03262500001000035, + "p95Ms": 0.09083333331716403, + "samples": 240, + "p99Ms": 0.23241666666962146 + }, + "vsync": 0, + "elapsedSeconds": 1.7039781666666727, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 7.105942468619225, + "p50Ms": 6.207166666662545, + "p95Ms": 16.04199999999878, + "samples": 239, + "p99Ms": 16.920791666663604 + }, + "card": "P9" + }, + { + "enabled": true, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.3707079861105633 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.1682434027775237 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.06706076388907078 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.002457118055421148 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.017083680555470455 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 2.799895833333347 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.4804137152770357 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.39820295138917794 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.10849843750110702 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.19974374999955913 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 3.999949305555095 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.005511631945095038 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.003470486111438002 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.0038769097223815647 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0012675347224562947 + }, + " update: music": { + "windows": 2, + "meanMs": 0.034332465277694034 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.046464062500106706 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.03322604166588287 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.02029479166682317 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.36730052083363185 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.3950276041662093 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.04054635416608221, + "p50Ms": 0.03187499999768306, + "p95Ms": 0.07137500000453656, + "samples": 240, + "p99Ms": 0.16833333333465816 + }, + "vsync": 0, + "elapsedSeconds": 1.7893360833333318, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 7.472740062761489, + "p50Ms": 6.275416666667866, + "p95Ms": 17.709166666662668, + "samples": 239, + "p99Ms": 18.821750000000748 + }, + "card": "P9" + } + ], + "limitations": [ + "sequential off/on samples; no causal whole-game speedup claim", + "Emerald does not exercise DS Nitro synthesis or GB palette uniforms", + "LOW native UI quad menus are covered by their separate allocation fixture" + ], + "measurement": { + "perSwitchWarmup": 180, + "profileWindow": 120, + "samples": 240, + "warmupNative": 240, + "warmupVoxel": 1500 + }, + "timing": "FrameProfile wall time; GPU submission, no completion fence; no screenshots inside intervals", + "runtime": [ + 12, + 0, + 0, + "Bestest Friend" + ], + "passed": true, + "mode": "native", + "gpu": { + "renderer": "Metal", + "device": "Apple M2", + "vendor": "", + "api": "2.1" + } + }, + "voxel": { + "phases": [ + { + "enabled": false, + "windows": 2, + "vsync": 0, + "fpsCap": 160, + "present": { + "meanMs": 0.022437326390445378, + "p50Ms": 0.022708333347054577, + "samples": 240, + "p99Ms": 0.033583333333808696, + "p95Ms": 0.02987499999562715 + }, + "intervals": { + "meanMs": 8.429272838214771, + "p50Ms": 8.931083333322931, + "samples": 239, + "p99Ms": 15.901875000025711, + "p95Ms": 14.817708333339397 + }, + "voxelLevel": 3, + "cameraMotion": "controlled 0.25 world-pixel pan per update", + "shadowCasts": { + "shadow.cast": 240 + }, + "world": "MAP_G00_N15", + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "elapsedSeconds": 2.017875874999987, + "card": "P7", + "sections": { + " hook world.tod <- Gen2Recomped-DramaticShapes @ mods/DRAMATIC_SHAPE/main.lua:1737": { + "windows": 2, + "meanMs": 0.1106043402774759 + }, + " hook input.step <- Gen2Recomped-DramaticShapes @ mods/DRAMATIC_SHAPE/lib/Horde.lua:623": { + "windows": 2, + "meanMs": 0.005437673611533948 + }, + " pipeline: voxel": { + "windows": 2, + "meanMs": 0.03548437499745205 + }, + " pipeline: tiltshift": { + "windows": 2, + "meanMs": 0.001681597222713549 + }, + " world pass: voxel": { + "windows": 2, + "meanMs": 2.715046180556395 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.03296944444469337 + }, + " voxel: sun pass": { + "windows": 2, + "meanMs": 0.9418126736137584 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.05559548610764391 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.2535001736090928 + }, + " voxel: terrain": { + "windows": 2, + "meanMs": 0.12729270833157597 + }, + " update: music": { + "windows": 2, + "meanMs": 0.014209027777075297 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.001570833334578007 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.04270399305331314 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.33433871527866665 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.001142708333408867 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0022684027774981055 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.2495055555549186 + }, + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.15033749999998253 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.01920902777818867 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 7.0878850694469975 + }, + " voxel: water": { + "windows": 2, + "meanMs": 0.034555208333012885 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.0026380208352586956 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.0247074652775107 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.002109548610581176 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.2197496527787261 + }, + " voxel: cards": { + "windows": 2, + "meanMs": 0.6550522569449176 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 0.8995496527781199 + } + } + }, + { + "enabled": true, + "windows": 2, + "vsync": 0, + "fpsCap": 160, + "present": { + "meanMs": 0.0238498263875897, + "p50Ms": 0.022458333319264057, + "samples": 240, + "p99Ms": 0.05699999999819738, + "p95Ms": 0.037333333324340856 + }, + "intervals": { + "meanMs": 8.493223152022395, + "p50Ms": 9.03358333334836, + "samples": 239, + "p99Ms": 15.26287499996215, + "p95Ms": 14.79208333333304 + }, + "voxelLevel": 3, + "cameraMotion": "controlled 0.25 world-pixel pan per update", + "shadowCasts": { + "shadow.static.cast": 9, + "shadow.dynamic.cast": 240 + }, + "world": "MAP_G00_N15", + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "elapsedSeconds": 2.032040875000007, + "card": "P7", + "sections": { + " hook world.tod <- Gen2Recomped-DramaticShapes @ mods/DRAMATIC_SHAPE/main.lua:1737": { + "windows": 2, + "meanMs": 0.08174618053497322 + }, + " hook input.step <- Gen2Recomped-DramaticShapes @ mods/DRAMATIC_SHAPE/lib/Horde.lua:623": { + "windows": 2, + "meanMs": 0.005720312502551413 + }, + " pipeline: voxel": { + "windows": 2, + "meanMs": 0.04609114583544738 + }, + " pipeline: tiltshift": { + "windows": 2, + "meanMs": 0.002034548607454667 + }, + " world pass: voxel": { + "windows": 2, + "meanMs": 2.7421758680560515 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.04112274305742384 + }, + " voxel: sun pass": { + "windows": 2, + "meanMs": 0.7756128472208227 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.05531111111451992 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.3278739583305897 + }, + " voxel: terrain": { + "windows": 2, + "meanMs": 0.15537361111069478 + }, + " update: music": { + "windows": 2, + "meanMs": 0.016452604168293306 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0019630208340023123 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.05418923611119434 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.4240703125014752 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.001363194443963304 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0018829861113308273 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.3233065972212993 + }, + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.12348958333336196 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.021127604167029784 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 7.098802951387503 + }, + " voxel: water": { + "windows": 2, + "meanMs": 0.0450168402782225 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.0034388888866014136 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.025685069443544727 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.0018961805600052153 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.2911411458314935 + }, + " voxel: cards": { + "windows": 2, + "meanMs": 0.7202407986106607 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 0.8555972222196099 + } + } + }, + { + "enabled": false, + "windows": 2, + "vsync": 0, + "fpsCap": 160, + "present": { + "meanMs": 0.023449131944275337, + "p50Ms": 0.022083333362843405, + "samples": 240, + "p99Ms": 0.058499999994410246, + "p95Ms": 0.03158333328201479 + }, + "intervals": { + "meanMs": 8.547566422594073, + "p50Ms": 8.64766666666128, + "samples": 239, + "p99Ms": 17.10745833332794, + "p95Ms": 14.692291666648316 + }, + "voxelLevel": 3, + "cameraMotion": "native camera follow", + "shadowCasts": { + "shadow.static.cast": 8, + "shadow.dynamic.cast": 240 + }, + "world": "MAP_G00_N15", + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "elapsedSeconds": 2.0467259166666736, + "card": "P8", + "sections": { + " hook world.tod <- Gen2Recomped-DramaticShapes @ mods/DRAMATIC_SHAPE/main.lua:1737": { + "windows": 2, + "meanMs": 0.0924618055156164 + }, + " hook input.step <- Gen2Recomped-DramaticShapes @ mods/DRAMATIC_SHAPE/lib/Horde.lua:623": { + "windows": 2, + "meanMs": 0.005585243054421577 + }, + " pipeline: voxel": { + "windows": 2, + "meanMs": 0.03462465277953204 + }, + " pipeline: tiltshift": { + "windows": 2, + "meanMs": 0.0015611111117882124 + }, + " world pass: voxel": { + "windows": 2, + "meanMs": 2.7714302083348725 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.02822482638672833 + }, + " voxel: sun pass": { + "windows": 2, + "meanMs": 0.6287888888902415 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.052896180548363717 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.21917690972242573 + }, + " voxel: terrain": { + "windows": 2, + "meanMs": 0.3438159722211509 + }, + " update: music": { + "windows": 2, + "meanMs": 0.013511805554955458 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0013590277793203618 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.04046874999919699 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.29497378472515356 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.2153925347261776 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0020411458341603366 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 7.281948263889385 + }, + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.1396723958335381 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.01575416666597107 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0016246527771102137 + }, + " voxel: water": { + "windows": 2, + "meanMs": 0.09131128471947156 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.002786979170347574 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.025137152779208805 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.0017555555556233078 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.19054322916633265 + }, + " voxel: cards": { + "windows": 2, + "meanMs": 0.7007552083329927 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 0.8670545138912189 + } + } + }, + { + "enabled": true, + "windows": 2, + "vsync": 0, + "fpsCap": 160, + "present": { + "meanMs": 0.02431753472092642, + "p50Ms": 0.023874999982353984, + "samples": 240, + "p99Ms": 0.054624999989982825, + "p95Ms": 0.04345833335150928 + }, + "intervals": { + "meanMs": 8.546358263598453, + "p50Ms": 8.908583333322895, + "samples": 239, + "p99Ms": 17.526000000003705, + "p95Ms": 15.047166666647627 + }, + "voxelLevel": 3, + "cameraMotion": "native camera follow", + "shadowCasts": { + "shadow.static.cast": 8, + "shadow.dynamic.cast": 240 + }, + "world": "MAP_G00_N15", + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "elapsedSeconds": 2.046862625000017, + "card": "P8", + "sections": { + " hook world.tod <- Gen2Recomped-DramaticShapes @ mods/DRAMATIC_SHAPE/main.lua:1737": { + "windows": 2, + "meanMs": 0.08897291666301044 + }, + " hook input.step <- Gen2Recomped-DramaticShapes @ mods/DRAMATIC_SHAPE/lib/Horde.lua:623": { + "windows": 2, + "meanMs": 0.006951041667354237 + }, + " pipeline: voxel": { + "windows": 2, + "meanMs": 0.05130121527834319 + }, + " pipeline: tiltshift": { + "windows": 2, + "meanMs": 0.003108680557180795 + }, + " world pass: voxel": { + "windows": 2, + "meanMs": 2.892305208333923 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.03943680555025253 + }, + " voxel: sun pass": { + "windows": 2, + "meanMs": 0.8711494791670068 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.05786961805966939 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.3501102430562971 + }, + " voxel: terrain": { + "windows": 2, + "meanMs": 0.18821770833289025 + }, + " update: music": { + "windows": 2, + "meanMs": 0.02190434027831145 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0017180555521652727 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.06118420138783165 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.45662743055541927 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0011550347210231848 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0018480902780263855 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.3460309027788119 + }, + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.17482638888897858 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.02158923611096005 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 7.091951388887688 + }, + " voxel: water": { + "windows": 2, + "meanMs": 0.047376562499816074 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.0031012152780599913 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.025607986110761278 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.0021526041647253655 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.3134807291682288 + }, + " voxel: cards": { + "windows": 2, + "meanMs": 0.6757993055547473 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 0.8896291666642261 + } + } + } + ], + "mode": "voxel", + "measurement": { + "warmupVoxel": 1500, + "perSwitchWarmup": 180, + "profileWindow": 120, + "readyWaitFrames": 18240, + "samples": 240, + "warmupNative": 240 + }, + "passed": true, + "limitations": [ + "sequential off/on samples; no causal whole-game speedup claim", + "Emerald does not exercise DS Nitro synthesis or GB palette uniforms", + "LOW native UI quad menus are covered by their separate allocation fixture" + ], + "timing": "FrameProfile wall time; GPU submission, no completion fence; no screenshots inside intervals", + "gpu": { + "device": "Apple M2", + "vendor": "", + "renderer": "Metal", + "api": "2.1" + }, + "runtime": [ + 12, + 0, + 0, + "Bestest Friend" + ], + "edition": "emerald", + "architecture": "arm64", + "simulationHz": 60 + } + }, + "freshIdentity": true, + "legacyMigrationBypassed": true, + "testAdaptations": [ + "Enable existing native pacing for the driver in the frozen copy", + "Quarter-pixel camera pan during P7; native update and draw remain active" + ], + "passed": true + }, + "forkOpenGL": { + "sourceCommit": "dae881de97e03fafbfa6e50f6f39215bda87843e", + "native": { + "architecture": "arm64", + "simulationHz": 60, + "phases": [ + { + "windows": 2, + "enabled": false, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 16.667387203626213, + "p50Ms": 16.66808333333325, + "p95Ms": 17.266375000001055, + "p99Ms": 17.619625000000028, + "samples": 239 + }, + "present": { + "meanMs": 14.672560243055655, + "p50Ms": 14.739583333332362, + "p95Ms": 15.451000000002324, + "p99Ms": 15.778916666668863, + "samples": 240 + }, + "elapsedSeconds": 3.999789708333336, + "card": "P1", + "sections": { + " update: gc step": { + "meanMs": 0.0016048611110930366, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.002357291666588157, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 0.39517500000003647, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.020693402777756848, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.2481880208333176, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.41677986111109766, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.034011111111006034, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.006581944444479977, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.14170520833319084, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.38464618055561084, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.2165138888889621, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.06932222222219378, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.8190932291667119, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.620315104166665, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 14.621406423611175, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.4214760416666093, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.20373315972222286, + "windows": 2 + }, + " update: music": { + "meanMs": 0.022903819444441975, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.002659895833242073, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.0038954861111088297, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.006481423611063667, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 60, + "vsync": 1 + }, + { + "windows": 2, + "enabled": true, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 16.673870641562075, + "p50Ms": 16.670291666667225, + "p95Ms": 17.413624999996102, + "p99Ms": 18.324291666662162, + "samples": 239 + }, + "present": { + "meanMs": 14.979631423611115, + "p50Ms": 15.034083333333115, + "p95Ms": 15.825416666665149, + "p99Ms": 16.317708333332348, + "samples": 240 + }, + "elapsedSeconds": 4.001582000000001, + "card": "P1", + "sections": { + " update: gc step": { + "meanMs": 0.0004954861110443669, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.003987673611168438, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 0.2674690972221727, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.017954687499995476, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.24440260416667137, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.3385887152777508, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.035574826389036154, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.0066156249999356955, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.12828524305561473, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.3094074652776705, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.20117499999997196, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.05807482638911694, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.752117361111182, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.5277243055554548, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 14.92901701388892, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.3432960069444343, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.19687760416675637, + "windows": 2 + }, + " update: music": { + "meanMs": 0.023178298610937453, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.0019244791665151695, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.0028041666666848526, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.005840104166709992, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 60, + "vsync": 1 + }, + { + "windows": 2, + "enabled": false, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.235281555090652, + "p50Ms": 6.279208333332065, + "p95Ms": 6.734624999999994, + "p99Ms": 7.179083333337388, + "samples": 239 + }, + "present": { + "meanMs": 1.1148600694444197, + "p50Ms": 0.6057083333317337, + "p95Ms": 3.741958333336015, + "p99Ms": 4.9237499999996714, + "samples": 240 + }, + "elapsedSeconds": 1.4958087499999984, + "card": "P2", + "sections": { + " update: gc step": { + "meanMs": 0.0037821180555989526, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.0013111111109539073, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.218259895833458, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.01284809027775798, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.15237760416654625, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.030271527778028922, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.00427725694459961, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.07504253472194004, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.17913472222195934, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.02605364583267248, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.11598749999990332, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 1.1140116319444893, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.2868946180555406, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.19106545138880549, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.19322482638881588, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.5152126736112391, + "windows": 2 + }, + " update: music": { + "meanMs": 0.011342708333161866, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.000982986111308648, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.001664756944341761, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.0032869791666871135, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": true, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.3571999651324935, + "p50Ms": 6.278958333336249, + "p95Ms": 6.820374999996659, + "p99Ms": 12.679916666666458, + "samples": 239 + }, + "present": { + "meanMs": 1.150429861110854, + "p50Ms": 0.4453333333316323, + "p95Ms": 4.427291666662114, + "p99Ms": 5.447958333327563, + "samples": 240 + }, + "elapsedSeconds": 1.5252069166666615, + "card": "P2", + "sections": { + " update: gc step": { + "meanMs": 0.00044270833345905203, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.0012154513887807639, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.101502604166951, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.01295503472215446, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.4976496527780612, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.2022562500001583, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.03378506944479535, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.0032618055554767267, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.17682951388883575, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.18297812500009947, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.03228177083408982, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.1337104166663957, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 1.1529953125000012, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.3050876736111115, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.5935704861109059, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.20462274305536413, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.17468350694427315, + "windows": 2 + }, + " update: music": { + "meanMs": 0.017961458333089673, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.0012517361111926326, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.0019406250002370247, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.0028975694443526363, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": false, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.2498349023709965, + "p50Ms": 6.267833333332362, + "p95Ms": 7.119416666665046, + "p99Ms": 8.127916666669677, + "samples": 239 + }, + "present": { + "meanMs": 1.1161321180553392, + "p50Ms": 0.33645833333295627, + "p95Ms": 4.195541666661029, + "p99Ms": 4.905958333335292, + "samples": 240 + }, + "elapsedSeconds": 1.499905833333333, + "card": "P3", + "sections": { + " update: gc step": { + "meanMs": 0.00048402777782878087, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.0011078125000008043, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.114880555555726, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.015697222221966456, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.5128435763889587, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.1911970486108583, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.04074270833357059, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.0027131944443444427, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.08369809027852781, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.171840972222137, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.1338819444447618, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.03896597222121656, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 1.1200206597217655, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.2927954861109712, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.6131821180552333, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.19364809027796842, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.16749774305579263, + "windows": 2 + }, + " update: music": { + "meanMs": 0.0192269097222475, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.002479166666550607, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.0018090277776957229, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.002604687500159149, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": true, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.231283124128319, + "p50Ms": 6.261208333331325, + "p95Ms": 7.001499999997662, + "p99Ms": 7.463500000000067, + "samples": 239 + }, + "present": { + "meanMs": 1.2672111111111515, + "p50Ms": 0.3423333333323342, + "p95Ms": 4.796791666667133, + "p99Ms": 5.50641666666607, + "samples": 240 + }, + "elapsedSeconds": 1.4951105000000027, + "card": "P3", + "sections": { + " update: gc step": { + "meanMs": 0.00033263888883361403, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.0010701388890736279, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.06861510416654, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.012214756944336122, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.5115572916669642, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.1811409722223563, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.028483854166664692, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.0028697916666597933, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.0755835069446172, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.16242621527797024, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.12084479166645974, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.032524652777550976, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 1.261551041666742, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.2777340277775912, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.5206501736111887, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.18335000000035961, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.1499586805554583, + "windows": 2 + }, + " update: music": { + "meanMs": 0.01663871527763187, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.000941666666465224, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.0017366319448116248, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.002830034722167696, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": false, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.229724721059962, + "p50Ms": 6.2338749999995, + "p95Ms": 6.930208333333354, + "p99Ms": 7.492708333330711, + "samples": 239 + }, + "present": { + "meanMs": 1.2278831597220061, + "p50Ms": 0.32112499999925603, + "p95Ms": 4.744166666664995, + "p99Ms": 5.4069583333351545, + "samples": 240 + }, + "elapsedSeconds": 1.4950517500000018, + "card": "P4", + "sections": { + " update: gc step": { + "meanMs": 0.0003310763887783471, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.000969618055535193, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.178820138888728, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.00876458333336494, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.5011140624998707, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.1555885416667735, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.024649305555553745, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.0019319444444517349, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.0617000000001718, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.13974027777763354, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.03135173611103308, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.11353420138894657, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 1.2234223958332326, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.2385585069445491, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.5006347222222078, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.15734357638857627, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.13805763888825595, + "windows": 2 + }, + " update: music": { + "meanMs": 0.015418750000077234, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.0015307291670059917, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.0015885416666379846, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.003576909722221208, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": true, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.231817468619242, + "p50Ms": 6.257208333330766, + "p95Ms": 7.405749999996658, + "p99Ms": 7.792083333328037, + "samples": 239 + }, + "present": { + "meanMs": 0.9956045138891589, + "p50Ms": 0.33050000000400814, + "p95Ms": 4.119125000002555, + "p99Ms": 4.78858333333676, + "samples": 240 + }, + "elapsedSeconds": 1.4952891250000064, + "card": "P4", + "sections": { + " update: gc step": { + "meanMs": 0.00035069444459523424, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.0011078125000748191, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.2933576388889465, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.01157673611148861, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.5029746527777472, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.2071057291665272, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.029269618055642137, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.002420833333053688, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.0750385416660393, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.1895786458331254, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.12370677083340573, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.03399965277880075, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 0.9976267361111013, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.3031071180558639, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.5355546875002541, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.20928802083330922, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.15870277777771946, + "windows": 2 + }, + " update: music": { + "meanMs": 0.01726649305557141, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.0010347222221559114, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.001778472222587387, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.0027508680554492555, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": false, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.277770746164587, + "p50Ms": 6.263708333335671, + "p95Ms": 6.964250000002892, + "p99Ms": 7.821249999999225, + "samples": 239 + }, + "present": { + "meanMs": 0.8983097222220534, + "p50Ms": 0.2876249999985703, + "p95Ms": 3.931000000001461, + "p99Ms": 4.967875000005506, + "samples": 240 + }, + "elapsedSeconds": 1.5064056666666659, + "card": "P5", + "sections": { + " update: gc step": { + "meanMs": 0.0003213541668619276, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.001024131944582507, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.53699114583325, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.01031006944443765, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.5055859375001267, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.14694895833358146, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.025333680555335768, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.0026815972223500255, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.07530277777823002, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.15551788194413757, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.10914913194414547, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.02923090277662984, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 0.9001652777776019, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.2673343750001204, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.1734963541663544, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.17553767361097408, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.4722008680552688, + "windows": 2 + }, + " update: music": { + "meanMs": 0.01566944444396275, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.0009607638892757583, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.0016673611113127151, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.0022078125000213333, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": true, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.288016562064135, + "p50Ms": 6.2477083333263295, + "p95Ms": 6.868000000004315, + "p99Ms": 7.630166666672267, + "samples": 239 + }, + "present": { + "meanMs": 1.1863953124998439, + "p50Ms": 0.3171249999951442, + "p95Ms": 4.988291666663258, + "p99Ms": 5.577625000000808, + "samples": 240 + }, + "elapsedSeconds": 1.5095238333333398, + "card": "P5", + "sections": { + " update: gc step": { + "meanMs": 0.0003803819446683102, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.0009340277780841196, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.252718749999959, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.010822222222461875, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.5122611111113488, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.16764635416614365, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.026926909722568837, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.002986111110943786, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.07106579861124729, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.15060434027767872, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.1183315972222469, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.03510555555526669, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 1.188424131944258, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.2549993055555847, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.4970548611111347, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.1696093749995538, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.14856579861142194, + "windows": 2 + }, + " update: music": { + "meanMs": 0.016086631944863445, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.0011151041664862285, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.0014835069445181168, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.002245659722680908, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": false, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.278589609483924, + "p50Ms": 6.26395833333504, + "p95Ms": 7.1881249999989905, + "p99Ms": 7.485791666667296, + "samples": 239 + }, + "present": { + "meanMs": 1.1705394097221007, + "p50Ms": 0.32845833333539076, + "p95Ms": 4.630125000005592, + "p99Ms": 5.722624999989989, + "samples": 240 + }, + "elapsedSeconds": 1.506839124999999, + "card": "P6", + "sections": { + " update: gc step": { + "meanMs": 0.00034010416645931707, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.001067881944501655, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.20900538194422, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.011357465277879916, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.5015076388888318, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.15868246527726745, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.03448628472186854, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.002503472222533295, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.06927222222182414, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.16029131944469793, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.13261822916685873, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.0359767361114649, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 1.1596720486112488, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.2714201388891446, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.1775411458331118, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.1797045138885641, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.5435371527780101, + "windows": 2 + }, + " update: music": { + "meanMs": 0.015531770833258443, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.0009668402776163987, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.0017440972220228446, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.0024164930555237163, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": true, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.23306328451883, + "p50Ms": 6.255083333336131, + "p95Ms": 7.198875000014482, + "p99Ms": 7.57250000000198, + "samples": 239 + }, + "present": { + "meanMs": 0.9339451388887218, + "p50Ms": 0.29008333333280234, + "p95Ms": 4.043541666661099, + "p99Ms": 4.722125000007793, + "samples": 240 + }, + "elapsedSeconds": 1.495526958333329, + "card": "P6", + "sections": { + " update: gc step": { + "meanMs": 0.00039097222244895613, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.000847569444169712, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.505499652777445, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.009628298610072267, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.5035550347219402, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.1346505208331905, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.026045486111285026, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.0020236111114198723, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.06297934027739889, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.13628732638893598, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.030362152777326422, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.1092288194445068, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 0.9217019097225905, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.2629105902777873, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.15155989583295337, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.15499218749989535, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.45328940972249043, + "windows": 2 + }, + " update: music": { + "meanMs": 0.035219618056044524, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.0009321180554818417, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.001254513888918988, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.001953819444692565, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": false, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.229652719665299, + "p50Ms": 6.2589166666811025, + "p95Ms": 7.280583333326263, + "p99Ms": 7.749541666669302, + "samples": 239 + }, + "present": { + "meanMs": 0.91216111111135, + "p50Ms": 0.3208750000140981, + "p95Ms": 3.651374999989798, + "p99Ms": 4.524875000001316, + "samples": 240 + }, + "elapsedSeconds": 1.495454500000001, + "card": "P9", + "sections": { + " update: gc step": { + "meanMs": 0.0009611111111122266, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.001731944444098114, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.314396354166246, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.014539583332862094, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.5120800347215553, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.22618732638874187, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.029385937500029755, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.1308651041671105, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.08679409722350448, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.20707690972248116, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.03550347222057344, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.0023960069438129965, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 0.9088880208338423, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.3375545138884384, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.002890972222916825, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.22935729166627303, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.1510756944449317, + "windows": 2 + }, + " update: music": { + "meanMs": 0.01913645833354257, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.001617708333541875, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.002508333333928192, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.5573109374990299, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": true, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.231109309623432, + "p50Ms": 6.238791666667964, + "p95Ms": 6.786041666664744, + "p99Ms": 7.130583333335494, + "samples": 239 + }, + "present": { + "meanMs": 1.1638343750006424, + "p50Ms": 0.36958333333814153, + "p95Ms": 4.549416666662864, + "p99Ms": 5.23783333332517, + "samples": 240 + }, + "elapsedSeconds": 1.4956047916666577, + "card": "P9", + "sections": { + " update: gc step": { + "meanMs": 0.00046892361051931175, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.0012071180552908345, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.127126388889243, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.012624479167122615, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.5193821180554595, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.2057081597218513, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.033097222222681445, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.002634375000424664, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.08020607638874466, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.18604531249971265, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.11129149305582806, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.029175173610962446, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 1.1652595486112047, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.314084027777195, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.5189491319447015, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.20847239583341448, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.149564062500455, + "windows": 2 + }, + " update: music": { + "meanMs": 0.019894270833316813, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.0015460069450116503, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.0019810763885352856, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.001560590277686439, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + } + ], + "passed": true, + "limitations": [ + "sequential off/on samples; no causal whole-game speedup claim", + "Emerald does not exercise DS Nitro synthesis or GB palette uniforms", + "LOW native UI quad menus are covered by their separate allocation fixture" + ], + "mode": "native", + "measurement": { + "warmupNative": 240, + "warmupVoxel": 1500, + "perSwitchWarmup": 180, + "profileWindow": 120, + "samples": 240 + }, + "edition": "emerald", + "runtime": [ + 12, + 0, + 0, + "Bestest Friend" + ], + "timing": "FrameProfile wall time; GPU submission, no completion fence; no screenshots inside intervals", + "gpu": { + "device": "Apple M2", + "api": "4.1 Metal - 89.4", + "vendor": "Apple", + "renderer": "OpenGL" + } + }, + "voxel": { + "qualified": false, + "status": "stopped by the shared 20 GiB free-space reserve after the P7 baseline; no complete voxel result is claimed" + } + }, + "selectedFrameProfileRows": { + "P1": { + "section": "frame pacing: sleep", + "OpenGL native": { + "off": 0.39517500000003647, + "on": 0.2674690972221727 + }, + "Metal": { + "off": 10.707645138888878, + "on": 0.3171631944444586 + } + }, + "P2": { + "section": " update: gc step", + "OpenGL native": { + "off": 0.0037821180555989526, + "on": 0.00044270833345905203 + }, + "Metal": { + "off": 0.008072916666771826, + "on": 0.003772916666762901 + } + }, + "P3": { + "section": "draw: whole frame", + "OpenGL native": { + "off": 0.6131821180552333, + "on": 0.5206501736111887 + }, + "Metal": { + "off": 2.4563439236110858, + "on": 2.833618750000122 + } + }, + "P4": { + "section": " world: entities", + "OpenGL native": { + "off": 0.13805763888825595, + "on": 0.15870277777771946 + }, + "Metal": { + "off": 0.2992987847228494, + "on": 0.11899236111077016 + } + }, + "P5": { + "section": " renderer: zone blit", + "OpenGL native": { + "off": 0.025333680555335768, + "on": 0.026926909722568837 + }, + "Metal": { + "off": 0.02149461805635392, + "on": 0.022706770833380574 + } + }, + "P6": { + "section": " update: music", + "OpenGL native": { + "off": 0.015531770833258443, + "on": 0.035219618056044524 + }, + "Metal": { + "off": 0.03811163194399707, + "on": 0.044542708333346795 + } + }, + "P9": { + "section": "draw: whole frame", + "OpenGL native": { + "off": 0.5573109374990299, + "on": 0.5189491319447015 + }, + "Metal": { + "off": 3.4080913194446323, + "on": 3.999949305555095 + } + }, + "P7": { + "section": " voxel: sun pass", + "Metal": { + "off": 0.9418126736137584, + "on": 0.7756128472208227 + }, + "castsOff": { + "shadow.cast": 240 + }, + "castsOn": { + "shadow.static.cast": 9, + "shadow.dynamic.cast": 240 + } + }, + "P8": { + "section": " voxel: water", + "Metal": { + "off": 0.09131128471947156, + "on": 0.047376562499816074 + } + } + }, + "limitations": [ + "Sequential off/on samples, no causal universal speedup claim; slower sections are retained", + "Metal qualifies all nine cards; the additional OpenGL replay qualifies native cards only", + "GPU completion timing and full-story checkpoint playthroughs are not measured", + "Emerald does not exercise DS Nitro audio, GB palette uniforms or UI quad-heavy menus; separate component acceptance applies", + "Physical Android, Switch and handheld performance is not measured; the measured Apple M2 is ARM64" + ] +} From 867b00197f618ade06fd6ac8ba4e8aaa70e608d7 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:45:56 +0100 Subject: [PATCH 135/138] Audit current native effect registrations and register cure text sources --- src/battle/Gen4MoveEffects.lua | 10 +++++++--- tests/goldens/fixture_fingerprint.md | 11 +++++++++++ tests/goldens/fixture_fingerprint.txt | 2 +- tests/goldens/fixture_fingerprint_sections.txt | 2 +- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/battle/Gen4MoveEffects.lua b/src/battle/Gen4MoveEffects.lua index 2bd10da81..4f1447ee2 100644 --- a/src/battle/Gen4MoveEffects.lua +++ b/src/battle/Gen4MoveEffects.lua @@ -509,9 +509,13 @@ F.REMOVE_PROTECT = { local FLAVOR_STAT = { [11] = "attack", [12] = "spatk", [13] = "speed", [14] = "spdef", [15] = "defense" } local STAT_UP = { [16] = "attack", [17] = "defense", [18] = "speed", [19] = "spatk", [20] = "spdef" } local CURE = { [1] = "PAR", [2] = "SLP", [3] = "PSN", [4] = "BRN", [5] = "FRZ" } -local CURE_TEXT = { PAR = "%s was\ncured of paralysis!", SLP = "%s woke up!", - PSN = "%s was\ncured of poisoning!", BRN = "%s's\nburn was healed!", - FRZ = "%s thawed out!" } +local CURE_TEXT = { + PAR = Strings.source("%s was\ncured of paralysis!"), + SLP = Strings.source("%s woke up!"), + PSN = Strings.source("%s was\ncured of poisoning!"), + BRN = Strings.source("%s's\nburn was healed!"), + FRZ = Strings.source("%s thawed out!"), +} local function eat(battle, eater, effect, param, def, out) local max = maxHp(eater) diff --git a/tests/goldens/fixture_fingerprint.md b/tests/goldens/fixture_fingerprint.md index d8cc8e062..55c09281f 100644 --- a/tests/goldens/fixture_fingerprint.md +++ b/tests/goldens/fixture_fingerprint.md @@ -34,3 +34,14 @@ To audit a future change, load a fresh `tests.modkit` fixture with by section, and then update only reviewed pins. Run the gate again and keep the mutation assertions enabled. The ROM-dependent vanilla golden is separate and was not re-blessed by this update. + +## Current upstream Gen4 effect registrations + +Upstream `8d5b4387` adds 53 native Gen4 effect registrations through +`Gen4MoveEffects` and the existing non-destructive registry merge. Comparing +canonical fixture surfaces against `ed5189c2` changes only `move_effects`: +53 additions, no removals, and no changed existing kind/revision records. +The other six populated sections remain byte-identical. The whole fingerprint +is now `884a4b379d3e2f83`, and the effect section is `1c01a759e13618d5`. +The production serializer, digest and all gate mutation assertions remain +unchanged. These pins acknowledge the new upstream registry surface. diff --git a/tests/goldens/fixture_fingerprint.txt b/tests/goldens/fixture_fingerprint.txt index 77ad32cd7..d43cb64a0 100644 --- a/tests/goldens/fixture_fingerprint.txt +++ b/tests/goldens/fixture_fingerprint.txt @@ -1 +1 @@ -41e69caf4afa35ab +884a4b379d3e2f83 diff --git a/tests/goldens/fixture_fingerprint_sections.txt b/tests/goldens/fixture_fingerprint_sections.txt index 2e9081fa0..4437d24e9 100644 --- a/tests/goldens/fixture_fingerprint_sections.txt +++ b/tests/goldens/fixture_fingerprint_sections.txt @@ -2,6 +2,6 @@ pokemon 0ad4d8a59c0daa59 moves 54b3cb2c1776ed48 type_chart cc5bd80d003bd459 statuses 68247ced2ed19a21 -move_effects 3bef37494ca4a845 +move_effects 1c01a759e13618d5 constants e4cf6f91ff122fa5 mods 6a9ee9de68f78902 From 094d8627e173715907d5d7c913b3b2b23415c911 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:52:14 +0100 Subject: [PATCH 136/138] Verify current native Gen4 fog and poison-step band presentation --- tests/engine/gen4_performance_merge.lua | 64 +++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tests/engine/gen4_performance_merge.lua diff --git a/tests/engine/gen4_performance_merge.lua b/tests/engine/gen4_performance_merge.lua new file mode 100644 index 000000000..ddf7221cd --- /dev/null +++ b/tests/engine/gen4_performance_merge.lua @@ -0,0 +1,64 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- New native fog and poison-step bands must survive the draw-path merge. +local T = require("tests.harness") +love = require("tests.love_stub") +local Version = require("src.core.GameVersion") +local Performance = require("src.core.Performance") +local Fixture = require("tests.world_draw.fixture") +local GymFx = require("src.world.Gen4GymFx") +Version.set("platinum") +local originalFog = GymFx.spriteFog +for _, enabled in ipairs({ false, true }) do + Performance.worldDrawHoist = enabled + local scene = Fixture.new(9) + scene.world.map.def = { gen4DepthFog = {} } + local fogBuilds, drawn = 0, 0 + GymFx.spriteFog = function() + fogBuilds = fogBuilds + 1 + return function() return 0.25 end + end + for _, entity in ipairs(scene.world.entities) do + entity.draw = function() + drawn = drawn + 1 + local r, g, b, a = love.graphics.getColor() + T.same({ r, g, b, a }, { 0.25, 0.25, 0.25, 1 }, "native fog reaches each ordinary and top-layer sprite") + end + end + scene.world:drawWorld() + T.eq(fogBuilds, 1, "native fog provider is evaluated once per world pass") + T.eq(drawn, 9, "all native sprites retain their draw") + T.same({ love.graphics.getColor() }, { 1, 1, 1, 1 }, "sprite tint is restored after fog") +end +GymFx.spriteFog = originalFog +local Renderer = require("src.render.Renderer") +Renderer:init() +local originalBlit = Renderer.blitCanvas +for _, enabled in ipairs({ false, true }) do + Performance.hotPathHoist = enabled + Renderer:beginFrame() + Renderer:beginWorldPass() + Renderer:endWorldPass() + Renderer.worldWobble = 2 + local bands, positive, negative = 0, false, false + local firstX + Renderer.blitCanvas = function(self, canvas, sx, sy, zones, zsx, zsy, ox, oy, cx, cy, cw, ch, dpiX, dpiY) + if canvas ~= Renderer.worldCanvas then + return originalBlit(self, canvas, sx, sy, zones, zsx, zsy, ox, oy, cx, cy, cw, ch, dpiX, dpiY) + end + T.eq(self, Renderer, "native bands keep the renderer receiver") + T.eq(canvas, Renderer.worldCanvas, "native bands keep the world canvas") + T.check(type(dpiX) == "number" and type(dpiY) == "number", "band calls keep both DPI arguments") + T.check(type(cy) == "number" and ch > 0, "band calls retain a nonempty clipped region") + bands = bands + 1 + firstX = firstX or ox + if ox > firstX then positive = true end + if ox < firstX then negative = true end + return originalBlit(self, canvas, sx, sy, zones, zsx, zsy, ox, oy, cx, cy, cw, ch, dpiX, dpiY) + end + Renderer:endFrame() + T.check(bands > 2, "native poison wobble retains multiple world bands") + T.check(positive or negative, "native bands alternate their horizontal displacement") + T.eq(Renderer.worldWobble, nil, "native poison wobble remains a one-frame effect") +end +Renderer.blitCanvas = originalBlit +T.finish("current native Gen4 render integration") From 98366e1b2edf4e2dfe50224d1db13d9f97757039 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:48:31 +0100 Subject: [PATCH 137/138] Record current upstream qualification and explicit standalone dependency scopes --- docs/performance-program-status.md | 25 ++ docs/qa/program-current-upstream.json | 476 ++++++++++++++++++++++++++ 2 files changed, 501 insertions(+) create mode 100644 docs/qa/program-current-upstream.json diff --git a/docs/performance-program-status.md b/docs/performance-program-status.md index ae6a5cc96..f1517b12e 100644 --- a/docs/performance-program-status.md +++ b/docs/performance-program-status.md @@ -109,3 +109,28 @@ P1 includes P2 idle GC within the pacing section. P7 replaces 240 legacy static casts with 9 static and 240 small dynamic casts in the controlled moving-camera Metal scene. P9 world timing does not exercise quad-heavy menus. See [full source hashes and all samples](qa/performance-full-game-p1-p9.json). + +## Current upstream and fork integration + +Upstream `8d5b4387` and concurrent fork `8a362c6c` are integrated. The full +engine suite passes **561/561** at `a6f947a5`. Subsequent runtime +`86f9e593` passes **12/12 targeted engine suites**, every SDK/gallery/contract/ +unchanged-fixture gate and **14,520 actual-LÖVE assertions on each of 11.5 +and 12**. Its complete external entrypoint count is **17/17**, using an actual +native FireRed NPC and checking owned presentation, unchanged callback +arguments and owner retirement. The full engine count is not relabeled as +a full rerun of this later revision. + +Public M4 `d2551170` passes **168/168 engine suites**, all four other gates +and **14,520 assertions per native host**. Public P9 `094d8627` passes +**180/180** and all four other gates. The programme's 19 existing upstream +PRs are ready and mergeable; the callback-owner integration fix is also +offered on existing companion draft #327. + +Earlier standalone PRs retain explicit dependency boundaries: P3–P5 have +green engine/SDK suites but lack later acceptance tooling/fixtures; M5–M7 +have green engine/SDK/gallery/contract gates and require the existing +asset-pack/follower offers for complete external fixture entrypoints. M8 +and P6–P9 pass all five gates. See [exact revisions and outcomes](qa/program-current-upstream.json). +FrameProfile samples above remain evidence of their recorded revisions; +this integration does not invent or relabel measurements. diff --git a/docs/qa/program-current-upstream.json b/docs/qa/program-current-upstream.json new file mode 100644 index 000000000..60f0833bf --- /dev/null +++ b/docs/qa/program-current-upstream.json @@ -0,0 +1,476 @@ +{ + "schema": 2, + "date": "2026-10-26", + "upstream": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d", + "fork": { + "head": "86f9e5935baa5bbb7531c0ab880be3008d0cffc5", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + }, + "engineSuites": { + "passed": 561, + "total": 561, + "sourceCommit": "a6f947a5f9f9e836fafd4f57dae961a631e4193c" + }, + "contractComparisons": 200, + "completeFixtures": 17, + "sdkSuites": 3, + "galleryChecks": 253, + "focusedChecks": { + "Native checkpoint actor continuation": 432, + "Native checkpoint battle continuation": 1118, + "Native trainer checkpoint producer": 236, + "Native checkpoint story continuation": 756 + }, + "editionSelections": [ + "red", + "blue", + "yellow", + "gold", + "silver", + "crystal", + "firered", + "leafgreen", + "ruby", + "sapphire", + "emerald", + "platinum", + "heartgold", + "soulsilver", + "black", + "white", + "prism", + "polishedcrystal" + ], + "engineScope": "Full suite at the exact baseline below; twelve targeted engine suites after the subsequent native link-result and follower-resource integration. No full-suite rerun is claimed for the later revision.", + "fullEngineBaseline": { + "head": "a6f947a5f9f9e836fafd4f57dae961a631e4193c", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 1 + } + }, + "targetedEngineSuites": { + "sourceCommit": "86f9e5935baa5bbb7531c0ab880be3008d0cffc5", + "passed": 12, + "total": 12, + "exitCodes": { + "link_battle_ended": 0, + "api3_compat": 0, + "gate_meta_coverage": 0, + "mod_supports": 0, + "unified_mod_callbacks": 0, + "gate_fingerprint": 0, + "gate_hooks": 0, + "gate_events": 0, + "checkpoint_battle_continuation": 0, + "native_follower_callback_owner": 0, + "native_session_callbacks": 0, + "native_companion_runtime": 0 + } + } + }, + "publicM4": { + "head": "d25511709163eaf8fd80f55a1692ef806d63a372", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + }, + "engineSuites": { + "passed": 168, + "total": 168 + }, + "contractComparisons": 48, + "completeFixtures": 17, + "sdkSuites": 3, + "galleryChecks": 253, + "focusedChecks": { + "Native checkpoint actor continuation": 360, + "Native checkpoint battle continuation": 858, + "Native trainer checkpoint producer": 197, + "Native checkpoint story continuation": 630 + }, + "editionSelections": [ + "red", + "blue", + "yellow", + "gold", + "silver", + "crystal", + "firered", + "emerald", + "platinum", + "heartgold", + "soulsilver", + "black", + "white", + "prism", + "polishedcrystal" + ] + }, + "branches": { + "upstream-perf-p3": { + "head": "7e3d306b6f740197f6c4308badcf5d69651d8b34", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 1, + "api3": 2, + "fixtures": 2 + }, + "engineSuites": { + "passed": 80, + "total": 80 + }, + "scope": "Standalone engine/SDK pass; this early host retains inherited missing gallery files and lacks later contract/external-fixture tooling. Complete compatibility is qualified on the composed fork/P9 stack." + }, + "upstream-perf-p4": { + "head": "6d2e0c1749c1435c9e84aef02b8a6f079762e295", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 1, + "api3": 2, + "fixtures": 2 + }, + "engineSuites": { + "passed": 81, + "total": 81 + }, + "scope": "Standalone engine/SDK pass; this early host retains inherited missing gallery files and lacks later contract/external-fixture tooling. Complete compatibility is qualified on the composed fork/P9 stack." + }, + "upstream-perf-p5": { + "head": "c89369556cf30328264a41b2d0f523785cb0c0ee", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 1, + "api3": 2, + "fixtures": 2 + }, + "engineSuites": { + "passed": 82, + "total": 82 + }, + "scope": "Standalone engine/SDK pass; this early host retains inherited missing gallery files and lacks later contract/external-fixture tooling. Complete compatibility is qualified on the composed fork/P9 stack." + }, + "upstream-perf-p6": { + "head": "538a636833d175b02ed2e796fddacabce1a0da70", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + }, + "engineSuites": { + "passed": 169, + "total": 169 + }, + "scope": "All five gates pass, including 17 complete unchanged external entrypoints." + }, + "upstream-perf-p7": { + "head": "cc32b57423f4cecfd7c93e1a08cae25098e8aca1", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + }, + "engineSuites": { + "passed": 169, + "total": 169 + }, + "scope": "All five gates pass, including 17 complete unchanged external entrypoints." + }, + "upstream-perf-p8": { + "head": "5a1b786ebe2b66734b06491b9f09f313088576ee", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + }, + "engineSuites": { + "passed": 169, + "total": 169 + }, + "scope": "All five gates pass, including 17 complete unchanged external entrypoints." + }, + "upstream-perf-p9": { + "head": "094d8627e173715907d5d7c913b3b2b23415c911", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + }, + "engineSuites": { + "passed": 180, + "total": 180 + }, + "scope": "All five gates pass, including 17 complete unchanged external entrypoints." + }, + "upstream-compat-m5": { + "head": "9fbf292c785bf2ec42dbec4b599f84a54d4a5bde", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 1 + }, + "engineSuites": { + "passed": 90, + "total": 90 + }, + "scope": "Intermediate dependency host: engine/SDK/gallery/contract pass; 12/14 external entrypoints load. Existing asset-pack/follower offers complete the stack on M8/P9." + }, + "upstream-compat-m6": { + "head": "3e9b01b19b6d99d66e6d684087925b35c5c61e82", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 1 + }, + "engineSuites": { + "passed": 94, + "total": 94 + }, + "scope": "Intermediate dependency host: engine/SDK/gallery/contract pass; 12/14 external entrypoints load. Existing asset-pack/follower offers complete the stack on M8/P9." + }, + "upstream-compat-m7": { + "head": "36d78277688d3a34ddbfee9c6ad466ccedbaa871", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 1 + }, + "engineSuites": { + "passed": 114, + "total": 114 + }, + "scope": "Intermediate dependency host: engine/SDK/gallery/contract pass; 12/14 external entrypoints load. Existing asset-pack/follower offers complete the stack on M8/P9." + }, + "upstream-compat-m8": { + "head": "886a0090f7229d138d95a46a248a77d652953364", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + }, + "engineSuites": { + "passed": 162, + "total": 162 + }, + "scope": "All five gates pass, including 17 complete unchanged external entrypoints.", + "fixtureFollowUp": { + "head": "0fafaa48", + "completeEntrypoints": 17, + "nativeObjectConstructors": true + } + } + }, + "pullRequests": [ + { + "number": 102, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "aa406f27fa4467ac9b50acd533ad5a8a32e494ce", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 122, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "ce62c61b31d0efb6e092dcabd4930499343a0426", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 155, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "91f14c97ebdf892a0373e747d3b6aa96cd9620a6", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 272, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "01b6b2480e75e2604c6a07625ed922ed847aa8db", + "baseRefOid": "13af468b97dc37015d14ad5c71d7f067c3ee810d" + }, + { + "number": 274, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "fd332722acba0edad6a2d9deb13450d9a50e3ee9", + "baseRefOid": "13af468b97dc37015d14ad5c71d7f067c3ee810d" + }, + { + "number": 279, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "7d8d81f69fd21356c2b30fd8541259b22520c51e", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 283, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "5507ef2f5f5a4b869470d7861ad6fec39daa1422", + "baseRefOid": "ed5189c247ee7a2ccca666784e21fd4fba8df781" + }, + { + "number": 288, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "cb62f9404efd9bfbf34c92c5187d2357c0084cd2", + "baseRefOid": "ed5189c247ee7a2ccca666784e21fd4fba8df781" + }, + { + "number": 290, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "7e3d306b6f740197f6c4308badcf5d69651d8b34", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 291, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "6d2e0c1749c1435c9e84aef02b8a6f079762e295", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 293, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "c89369556cf30328264a41b2d0f523785cb0c0ee", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 303, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "9fbf292c785bf2ec42dbec4b599f84a54d4a5bde", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 305, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "3e9b01b19b6d99d66e6d684087925b35c5c61e82", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 306, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "36d78277688d3a34ddbfee9c6ad466ccedbaa871", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 312, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "0fafaa48846c3583c45141b044e33902b47ae63a", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 316, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "538a636833d175b02ed2e796fddacabce1a0da70", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 319, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "cc32b57423f4cecfd7c93e1a08cae25098e8aca1", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 321, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "5a1b786ebe2b66734b06491b9f09f313088576ee", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 323, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "094d8627e173715907d5d7c913b3b2b23415c911", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + } + ], + "performanceEvidence": "Full-game Metal and partial OpenGL samples retain their original recorded source revisions. They are not new measurements of this subsequent integration.", + "earlierHosts": { + "api3-acceptance-pr": { + "head": "aa406f27fa4467ac9b50acd533ad5a8a32e494ce", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 1 + }, + "engineSuites": { + "passed": 88, + "total": 88 + }, + "scope": "Preparation/early dependency host. Engine/SDK pass; raw exit codes retain inherited gallery/tooling gaps. Complete unchanged-fixture acceptance is qualified on the composed M8/P9 stack." + }, + "legacy-datasets-pr": { + "head": "ce62c61b31d0efb6e092dcabd4930499343a0426", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 1, + "api3": 2, + "fixtures": 2 + }, + "engineSuites": { + "passed": 110, + "total": 110 + }, + "scope": "Preparation/early dependency host. Engine/SDK pass; raw exit codes retain inherited gallery/tooling gaps. Complete unchanged-fixture acceptance is qualified on the composed M8/P9 stack." + }, + "legacy-storage-pr": { + "head": "91f14c97ebdf892a0373e747d3b6aa96cd9620a6", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 1, + "api3": 2, + "fixtures": 2 + }, + "engineSuites": { + "passed": 120, + "total": 120 + }, + "scope": "Preparation/early dependency host. Engine/SDK pass; raw exit codes retain inherited gallery/tooling gaps. Complete unchanged-fixture acceptance is qualified on the composed M8/P9 stack." + } + } +} From b98db32601e5653a6f387e5f194dffc29ecd4f79 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:56:16 +0100 Subject: [PATCH 138/138] Record final qualified fork integration without relabeling earlier full-suite evidence --- docs/performance-program-status.md | 6 +++ docs/qa/program-current-upstream.json | 56 ++++++++++++++++++++++++--- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/docs/performance-program-status.md b/docs/performance-program-status.md index f1517b12e..acfbba626 100644 --- a/docs/performance-program-status.md +++ b/docs/performance-program-status.md @@ -1,5 +1,11 @@ # Performance and mod compatibility programme +Final concurrent integration: `8e336d45` includes the additional friend/suspend +and native Gen2 output commits through `fc46bd85`. Its **22/22 affected +engine suites**, all required compatibility gates and **14,520 native +checkpoint assertions per LÖVE host** pass. Full-engine **561/561** evidence +remains pinned to `a6f947a5`; it is not relabeled as a full rerun of this merge. + A1/A2, M1–M8 and P1–P9 are implemented in the composed fork and offered through their existing upstream PRs. Existing acceptance repairs are reused. diff --git a/docs/qa/program-current-upstream.json b/docs/qa/program-current-upstream.json index 60f0833bf..4fb01f5ce 100644 --- a/docs/qa/program-current-upstream.json +++ b/docs/qa/program-current-upstream.json @@ -3,7 +3,7 @@ "date": "2026-10-26", "upstream": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d", "fork": { - "head": "86f9e5935baa5bbb7531c0ab880be3008d0cffc5", + "head": "8e336d459619d26f7099825248a655f2d5385940", "exitCodes": { "engine": 0, "modkit": 0, @@ -46,7 +46,7 @@ "prism", "polishedcrystal" ], - "engineScope": "Full suite at the exact baseline below; twelve targeted engine suites after the subsequent native link-result and follower-resource integration. No full-suite rerun is claimed for the later revision.", + "engineScope": "Full suite at a6f947a5; 22 affected engine suites and every required compatibility gate at the final concurrent integration.", "fullEngineBaseline": { "head": "a6f947a5f9f9e836fafd4f57dae961a631e4193c", "exitCodes": { @@ -58,9 +58,9 @@ } }, "targetedEngineSuites": { - "sourceCommit": "86f9e5935baa5bbb7531c0ab880be3008d0cffc5", - "passed": 12, - "total": 12, + "sourceCommit": "8e336d459619d26f7099825248a655f2d5385940", + "passed": 22, + "total": 22, "exitCodes": { "link_battle_ended": 0, "api3_compat": 0, @@ -73,7 +73,17 @@ "checkpoint_battle_continuation": 0, "native_follower_callback_owner": 0, "native_session_callbacks": 0, - "native_companion_runtime": 0 + "native_companion_runtime": 0, + "suspend_continue": 0, + "suspend_journal": 0, + "suspend_store": 0, + "screen_space_ui": 0, + "friend_wager": 0, + "friend_activity": 0, + "friend_chat": 0, + "friend_maps": 0, + "account_workspace": 0, + "friends_menu": 0 } } }, @@ -472,5 +482,39 @@ }, "scope": "Preparation/early dependency host. Engine/SDK pass; raw exit codes retain inherited gallery/tooling gaps. Complete unchanged-fixture acceptance is qualified on the composed M8/P9 stack." } + }, + "finalConcurrentIntegration": { + "incoming": "fc46bd85c919b6faef1c470597397d7ef02b96bb", + "head": "8e336d459619d26f7099825248a655f2d5385940", + "fullEngineBaseline": "a6f947a5f9f9e836fafd4f57dae961a631e4193c", + "scope": "Twenty-two targeted engine suites after integrating the separately qualified native link-result, suspend/friends and Gen2 menu publication, plus every required SDK/gallery/contract/unchanged-fixture gate. Full engine qualification is recorded at its exact baseline revision.", + "exitCodes": { + "link_battle_ended": 0, + "api3_compat": 0, + "gate_meta_coverage": 0, + "mod_supports": 0, + "unified_mod_callbacks": 0, + "gate_fingerprint": 0, + "gate_hooks": 0, + "gate_events": 0, + "checkpoint_battle_continuation": 0, + "native_follower_callback_owner": 0, + "native_session_callbacks": 0, + "native_companion_runtime": 0, + "suspend_continue": 0, + "suspend_journal": 0, + "suspend_store": 0, + "screen_space_ui": 0, + "friend_wager": 0, + "friend_activity": 0, + "friend_chat": 0, + "friend_maps": 0, + "account_workspace": 0, + "friends_menu": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + } } }