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 01/75] 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 02/75] 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 03/75] 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 04/75] 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 05/75] 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 06/75] 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 07/75] 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 08/75] 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 09/75] 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 10/75] 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 11/75] 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 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 12/75] 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 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 13/75] 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 14/75] 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 15/75] 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 04b8021f922650f63d428c029d3151a3aae5d456 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 16/75] 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 9848cb5e6eba9e16327b4e4889859b4b5228edd8 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 17/75] 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 1d37f18d5d0bbec72cd825aed95ea8bd8f20abeb 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 18/75] 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 9ebaed9e51b51c70c7196a032f518b5d6d2965a6 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 19/75] 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 da32b7471943d2483f982f07d39c1cbfa24de304 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 20/75] 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 2c46c6685bc63537a64d8e7d3f689f2a5c15b9e1 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 21/75] 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 b6b11e302ca435e39ff29d0b518ca306a5e0c49c 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 22/75] 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 264429db49a219e1d7a1076c1483e3953a54b7ec Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 04:19:27 +0100 Subject: [PATCH 23/75] 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 aa75d3eaa..7a5c33749 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") @@ -199,7 +200,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 aa406f27fa4467ac9b50acd533ad5a8a32e494ce 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 24/75] 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 d79f7d2ffdc1c3359a2326a345363cd1e5048d1c Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 07:55:40 +0100 Subject: [PATCH 25/75] Bind source contexts to existing API3 records and native classic callback buses --- .../callback-context-ownership.md | 33 +++++ src/mods/CallbackLease.lua | 71 +++++++++ src/mods/CallbackRecords.lua | 45 ++++-- src/mods/Events.lua | 8 +- src/mods/Hooks.lua | 16 +- src/mods/UnifiedCallbacks.lua | 35 +++-- .../native_callback_context_ownership.lua | 137 ++++++++++++++++++ 7 files changed, 325 insertions(+), 20 deletions(-) create mode 100644 docs/qa/native-foundation/callback-context-ownership.md create mode 100644 src/mods/CallbackLease.lua create mode 100644 tests/engine/native_callback_context_ownership.lua diff --git a/docs/qa/native-foundation/callback-context-ownership.md b/docs/qa/native-foundation/callback-context-ownership.md new file mode 100644 index 000000000..cee40c08d --- /dev/null +++ b/docs/qa/native-foundation/callback-context-ownership.md @@ -0,0 +1,33 @@ +# Source callback context ownership + +An API3 owner that imports the Game3/battle namespace receives the same admitted +battle identity in callback contexts as `Battle.getState()`. Its battler IDs use +the actual native slots, and move numbers derive from imported move definitions. +Native/follower-only consumers retain their native handles, including event +payload identities. Namespace selection already exists; no mod rewrite or +manifest-specific conversion is introduced. + +A source can copy a bound context to wrap its RNG, retaining the same native +transaction and opaque battle identity. Foreign battle identities and changed +move-number facts cannot be committed. A copied context remains a record, not a +second native action or combat executor. An RNG override is lent to the actual +native invocation and restored afterwards if still owned by that transaction. + +Only actual record views create a coroutine lease. Native-handle hooks such as +core.update can legitimately replace a native save during their body. Record +transactions validate phase/cohort/battle before staging and before applying +writes. Their RNG handles require the same visible native ancestry and close +with the transaction. Public RNG closures hold weak leases. Source event +transactions also close on return/error. + +Both native hook walks reject revoked coroutines before entering/falling back to +vanilla and before returning a cached result. A source yielding before or after +next cannot use a fallback or late commit to advance HP/RNG after its parent +phase has returned. Existing ordinary failure fallback/once-only/nil-tuple +behavior remains covered by the native hook and callback regressions. + +This is prerequisite safety and callback data interoperability. It does not +preempt already-suspended arbitrary Lua statements, provide a complete native +HP/RNG continuation, install a resolver or enable Action Card. Native default +combat, Gen2 and native-owner ABI qualification are separate explicit checks. +Player saves and private mod files are not changed by QA. diff --git a/src/mods/CallbackLease.lua b/src/mods/CallbackLease.lua new file mode 100644 index 000000000..555e0e72a --- /dev/null +++ b/src/mods/CallbackLease.lua @@ -0,0 +1,71 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Call-local authority for source-facing GBA record transactions and RNG. +local Native=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local CoroutineScopes=require('src.compat.gen1.CoroutineScopes') +local scopes=CoroutineScopes.new() +local M={} +function M.new(loader) + local game=Native.game() + local lease={open=true,game=game,loader=loader,content=loader.content,edition=Version.get(), + data=game and game.data,save=game and game.save,mods=game and game.mods,stack=game and game.stack, + functions={},functionViews={},functionIds={},battles={}} + lease.frame=scopes:push('records',lease) + return lease +end +function M.check(lease) + assert(lease and lease.open,'callback transaction is closed') + local frame=scopes:find('records') + while frame and frame~=lease.frame do frame=frame.parent end + assert(frame and not frame.closed and not CoroutineScopes.retired(),'native callback transaction retired') + assert(Native.game()==lease.game and Version.get()==lease.edition and lease.loader.content==lease.content, + 'native callback cohort retired') + local game=lease.game + assert(not game or game.data==lease.data and game.save==lease.save and game.mods==lease.mods and game.stack==lease.stack, + 'native callback cohort retired') + local battle=package.loaded['src.core.game3.battle'] + for raw,view in pairs(lease.battles)do + assert(battle and battle._screen(view)==raw,'native callback battle retired') + end + return lease +end +function M.battle(lease,raw) + if not(raw and raw.isBattleState)then return raw end + M.check(lease) + local battle=package.loaded['src.core.game3.battle'] + if not battle or battle._screen()~=raw then return nil end + local view=battle.getState();lease.battles[raw]=view;return view +end +function M.rng(lease,fn) + local index=lease.functionIds[fn] + if index then return lease.functionViews[index]end + index=#lease.functions+1;lease.functionIds[fn]=index;lease.functions[index]=fn + -- An external RNG handle must not root its abandoned transaction/native + -- coroutine. Only the live callback owns this lease strongly. + local refs=setmetatable({lease},{__mode='v'}) + local view=function(...) + local active=refs[1];assert(active,'native callback RNG retired') + M.check(active);return active.functions[index](...) + end + lease.functionViews[index]=view;return view +end +function M.writeRng(lease,raw,value) + M.check(lease) + lease.restores=lease.restores or{} + lease.restores[#lease.restores+1]={raw,raw.rng,value} + raw.rng=value +end +function M.close(lease) + if not lease or not lease.open then return end + lease.open=false + -- A source RNG override is lent to this native invocation. Do not leave + -- a closed public closure installed on a caller's reusable native context. + for i=#(lease.restores or{}),1,-1 do + local change=lease.restores[i] + if change[1].rng==change[3]then change[1].rng=change[2]end + end + scopes:close(lease.frame) + lease.game,lease.loader,lease.content,lease.data,lease.save,lease.mods,lease.stack=nil,nil,nil,nil,nil,nil,nil + lease.functions,lease.functionViews,lease.functionIds,lease.battles,lease.restores=nil,nil,nil,nil,nil +end +return M diff --git a/src/mods/CallbackRecords.lua b/src/mods/CallbackRecords.lua index 4ce1679c5..7d49a039d 100644 --- a/src/mods/CallbackRecords.lua +++ b/src/mods/CallbackRecords.lua @@ -140,7 +140,7 @@ local function array(t, label, max) assert(count == n, label .. " must be dense") end -function R.new(loader, name, generation, api3) +function R.new(loader, name, generation, api3, source) local ids = Ids.new(loader) local portable = api3 and Api3Records or nil local function readStatus(value) @@ -159,6 +159,8 @@ function R.new(loader, name, generation, api3) local byRaw, byView, entries = {}, {}, {} local view, restore, commit, refresh local open = true + local sourceRecords=source and api3 and generation==3 + local Lease,lease local function read(kind, id) return ids:read(kind, id) end @@ -234,7 +236,7 @@ function R.new(loader, name, generation, api3) elseif kind == "battler" then return { mon = view(raw.mon, "mon"), - id = raw.id, + id = lease and(raw.position~=nil and raw.position or raw.id)or 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 }), } @@ -303,7 +305,11 @@ function R.new(loader, name, generation, api3) if name == "pokemon.before_give" and k == "ctx" then role = nil end - if k == "status" then + if lease and k=='battle'and type(v)=='table'then + out[k]=Lease.battle(lease,v) + elseif lease and k=='rng'and type(v)=='function'then + out[k]=Lease.rng(lease,v) + elseif k == "status" then out[k] = readStatus(v) elseif k == "move" and type(v) ~= "table" then out[k] = read("moves", v) @@ -332,12 +338,18 @@ function R.new(loader, name, generation, api3) end end end + if lease and kind=='context'and type(raw.move)=='table'and raw.moveNum==nil then + out.moveNum=tonumber(raw.move.index or raw.move.numId) + end return out end view = function(raw, kind) if type(raw) ~= "table" then return raw end + if sourceRecords and not lease then + Lease=require('src.mods.CallbackLease');lease=Lease.new(loader) + end byRaw[raw] = byRaw[raw] or {} if byRaw[raw][kind] then return byRaw[raw][kind].public @@ -432,7 +444,8 @@ function R.new(loader, name, generation, api3) local function stage(raw, key, value) assert(type(raw) == "table", "native record is missing") pending[#pending + 1] = function() - raw[key] = value + if lease and key=='rng'and type(value)=='function'then Lease.writeRng(lease,raw,value) + else raw[key] = value end end end local function movesNative(public, old, baseline) @@ -457,7 +470,7 @@ function R.new(loader, name, generation, api3) end return out end - restore = function(value, kind) + restore = function(value, kind, original) if type(value) ~= "table" then assert( value == nil or (kind == "encounter" and value == false), @@ -467,15 +480,20 @@ function R.new(loader, name, generation, api3) end local e = byView[value] if not e then + local sourceContext=lease and kind=='context'and type(original)=='table' + and byRaw[original]and byRaw[original].context assert( kind == "action" or kind == "encounter" or kind == "trainerMon" or kind == "candidates" - or kind == "trainerParty", + or kind == "trainerParty" + or sourceContext, "replacement " .. tostring(kind) .. " must be a bound record" ) - e = { public = value, kind = kind, raw = {}, before = {} } + e = { public = value, kind = kind, raw = sourceContext and original or {}, + before = sourceContext and sourceContext.before or {} } + if sourceContext then byView[value]=e;entries[#entries+1]=e end end if visiting[e] then return e.raw @@ -483,6 +501,10 @@ function R.new(loader, name, generation, api3) visiting[e] = true local raw, before = e.raw, e.before kind = e.kind + if lease and kind=='context'then + assert(value.battle==before.battle,'native callback battle identity is read-only') + assert(value.moveNum==before.moveNum,'native callback move number is read-only') + end if kind == "move" or kind == "species" or kind == "item" or kind == "battler" then if kind == "battler" then assert( @@ -642,6 +664,7 @@ function R.new(loader, name, generation, api3) for k in pairs(keys) do local v = value[k] local old = raw[k] + if lease and k=='rng'and v~=before[k]then assert(type(v)=='function','native callback RNG must be callable')end if type(v) == "table" and byView[v] then local restored = restore(v) if v ~= before[k] then @@ -675,6 +698,7 @@ function R.new(loader, name, generation, api3) end commit = function(extra) assert(open, "callback transaction is closed") + if lease then Lease.check(lease)end pending, visiting = {}, {} for _, e in ipairs(entries) do restore(e.public, e.kind) @@ -683,12 +707,14 @@ function R.new(loader, name, generation, api3) if extra then result = extra() end + if lease then Lease.check(lease)end for _, op in ipairs(pending) do op() end return result end refresh = function() + if lease then Lease.check(lease)end -- Reuse public identities, but refresh after vanilla mutates native data. for _, e in ipairs(entries) do local nextValue = fields(e.raw, e.kind) @@ -716,10 +742,11 @@ function R.new(loader, name, generation, api3) return { close = function() open = false + if lease then Lease.close(lease)end end, view = view, - restore = function(v, kind) - return restore(v, kind) + restore = function(v, kind, original) + return restore(v, kind, original) end, commit = commit, refresh = refresh, diff --git a/src/mods/Events.lua b/src/mods/Events.lua index 88d27e79b..071173eea 100644 --- a/src/mods/Events.lua +++ b/src/mods/Events.lua @@ -58,7 +58,13 @@ function Events:emit(name, payload) -- lumping them together names the seam instead of the culprit. local close = FrameProfile.section( " mod " .. tostring(entry.owner or "?") .. ": " .. tostring(name)) - local ok, err = pcall(entry.callback, payload) + local facade = package.loaded['src.core.Game3'] + local ok, err + if facade and facade._usesCallbacks and facade._usesCallbacks(entry.owner) then + ok, err = pcall(facade._invokeEvent, entry.owner, name, entry.callback, payload) + else + ok, err = pcall(entry.callback, payload) + end close() if not ok then Logger.error("[%s] event %s: %s", diff --git a/src/mods/Hooks.lua b/src/mods/Hooks.lua index e56b04a83..da58c28fe 100644 --- a/src/mods/Hooks.lua +++ b/src/mods/Hooks.lua @@ -11,6 +11,10 @@ local function pack(...) return { n = select("#", ...), ... } end -- attributed to a mod link or retried; they ride out wrapped under this key -- so every guard re-raises instead of skipping local PASS = {} +local function live() + local scopes=package.loaded['src.compat.gen1.CoroutineScopes'] + assert(not(scopes and scopes.retired()),'native hook coroutine retired') +end function Hooks.new() return setmetatable({ chains = {} }, Hooks) @@ -65,14 +69,17 @@ local function siteOf(entry) end function Hooks:call(name, vanilla, ...) + live() local chain = self.chains[name] if not chain or #chain == 0 then return vanilla(...) end local args = pack(...) local ranVanilla = false local function run(index) + live() if index > #chain then ranVanilla = true local res = pack(pcall(vanilla, unpack(args, 1, args.n))) + live() if res[1] then return unpack(res, 2, res.n) end error({ [PASS] = res[2] }, 0) end @@ -100,12 +107,19 @@ function Hooks:call(name, vanilla, ...) return unpack(downstream, 1, downstream.n) end local started = measuring and FrameProfile.now() or nil - local res = pack(pcall(entry.callback, nextFn, unpack(args, 1, args.n))) + local facade = package.loaded['src.core.Game3'] + local res + if facade and facade._usesCallbacks and facade._usesCallbacks(entry.owner) then + res = pack(pcall(facade._invokeHook, entry.owner, entry.callback, nextFn, unpack(args, 1, args.n))) + else + res = pack(pcall(entry.callback, nextFn, unpack(args, 1, args.n))) + end if started then FrameProfile.add(" hook " .. tostring(name) .. " <- " .. siteOf(entry), (FrameProfile.now() - started) - below) end + live() if res[1] then return unpack(res, 2, res.n) end local err = res[2] if type(err) == "table" and err[PASS] ~= nil then error(err, 0) end diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 5e6a6195d..36e4f4bc1 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -86,6 +86,11 @@ function C.install(loader, api, owner, generation) local ids = Ids.new(loader) local api3 = (api.manifest.api or 1) >= 3 local extra = api3 and Api3Catalog + local function records(name) + local facade=package.loaded['src.core.Game3'] + local source=api3 and generation==3 and facade and facade._usesSourceRecords and facade._usesSourceRecords(owner) + return Records.new(loader,name,generation,api3,source),source + end for _, kind in ipairs({ "pokemon", "moves", "items" }) do ids:entries(kind) end @@ -106,13 +111,20 @@ 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, 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 - boundary.commit() + local boundary,source = records(name) + local public + if source then + local ok,value=pcall(boundary.view,payload,name=='pokemon.before_give'and'gift'or'context') + if not ok then boundary.close();error(value,0)end + public=value + else public=boundary.view(payload,name=='pokemon.before_give'and'gift'or'context')end + local values=pack(pcall(callback,public)) + if values[1]and name=='pokemon.before_give'then + local ok,why=pcall(boundary.commit);if not ok then values={n=2,false,why}end end - return unpack(values, 1, values.n) + boundary.close() + if not values[1]then error(values[2],0)end + return unpack(values,2,values.n) end end if once then @@ -138,7 +150,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, api3) + local boundary,source = records(name) local roles = positions[name] or {} local function translate(args) local out = { n = args.n } @@ -162,13 +174,18 @@ function C.install(loader, api, owner, generation) if role == "items" or role == "moves" then v = boundary.native(role, v, original[i]) elseif role then - v = boundary.restore(v, role) + v = boundary.restore(v,role,source and role=="context"and original[i]or nil) end out[i] = v end return out end - local public = translate(original) + local public + if source and #roles>0 then + local ok,value=pcall(translate,original) + if not ok then boundary.close();error(value,0)end + public=value + else public=translate(original)end local called, downstream, publicResult = false, nil, nil local active = true local function nextPublic(...) diff --git a/tests/engine/native_callback_context_ownership.lua b/tests/engine/native_callback_context_ownership.lua new file mode 100644 index 000000000..3db505a80 --- /dev/null +++ b/tests/engine/native_callback_context_ownership.lua @@ -0,0 +1,137 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Probe=dofile('tests/native_action_plan/turn.lua') +local Json=require('src.link.Json') +local G=require('src.core.Game3') +local B=require('src.core.game3.battle') +local Hooks=require('src.mods.Hooks') +local Runtime=require('src.mods.Runtime') +local saved={Runtime.events,Runtime.hooks,Runtime.errors,Hooks.lean} +local entry=[[ + local mod=... + local B=require('src.core.game3.battle') + local e=mod.exports;e.mode='identity' + mod.hooks:wrap('core.update',function(nextFn,g,dt)local value=nextFn(g,dt);e.corePost=true;return value end) + mod.hooks:wrap('battle.damage',function(nextFn,c) + e.sameBattle=c.battle==B.getState();e.user=c.user.id;e.target=c.target.id + e.rng=c.rng;e.moveNum=c.moveNum + if e.mode=='yield-after'then + local value=nextFn(c);coroutine.yield('contact') + c.target.mon.hp=c.target.mon.hp-7;return value + elseif e.mode=='yield-next'or e.mode=='yield-return'then + coroutine.yield('contact') + c.target.mon.hp=c.target.mon.hp-7 + if e.mode=='yield-return'then return 13 end + elseif e.mode=='copy'then + local copy={};for k,v in pairs(c)do copy[k]=v end + copy.rng=function(lo,hi)e.wrapped=(e.wrapped or 0)+1;return c.rng(lo,hi)end + local ok,value=pcall(nextFn,copy);e.copyOk=ok;e.copyError=not ok and tostring(value)or nil + if not ok then error(value,0)end + return value + end + return nextFn(c) + end) +]] +local function open(edition,body) + local f,b=Probe.open(edition);b:syncSides() + local files={['mods/source_probe/manifest.json']=Json.encode({id='source_probe',name='Probe',version='1.0.0', + api=3,entry='main.lua',games={'gen3'},permissions={'engine_internals'}}),['mods/source_probe/main.lua']=body or entry} + local run=T.sdk.loadMods({'mods/source_probe'},{data=f.data,fs=T.sdk.memfs(files)}) + T.eq(#run.errors,0);f.game.mods=run.loader;run.loader.game=f.game + local e=assert(run.loader.exports.source_probe) + local rolls=0 + local c={battle=b,user=b.player,target=b.enemy,move=f.data.moves.MOVE_033,moveId='MOVE_033', + rng=function(lo)rolls=rolls+1;return lo end} + return f,b,run,e,c,function()return rolls end +end +for _,edition in ipairs{'firered','leafgreen','ruby','sapphire','emerald'}do + local f,b,run,e,c,rolls=open(edition);local calls=0;local nativeRng=c.rng + local function native(raw) + calls=calls+1;T.eq(raw.battle,b);T.eq(raw.user,b.player);T.eq(raw.target,b.enemy) + return raw.rng(2,5) + end + T.eq(run.loader.hooks:call('battle.damage',native,c),2) + T.eq(e.sameBattle,true,'source callbacks share the actual admitted battle identity') + T.eq(e.user,0,'source battler callback identifies the actual native player slot') + T.eq(e.target,1,'source battler callback identifies the actual native enemy slot') + T.eq(e.moveNum,33,'source move numbers come from the actual native imported definition') + T.eq(calls,1);T.eq(rolls(),1) + local count=rolls();T.eq(pcall(e.rng,1,2),false,'retained callback RNG closes with its transaction') + T.eq(rolls(),count,'closed callback cannot advance native RNG') + e.mode='copy';T.eq(run.loader.hooks:call('battle.damage',native,c),2) + T.eq(e.copyOk,true,'a bound source context copy can decorate its native RNG') + T.eq(e.wrapped,1);T.eq(calls,2) + T.eq(c.rng,nativeRng,'source RNG replacement is scoped to its native invocation') + f.game.stack:pop();B._service.release() + local replacement=require('src.mods.Merge').deepCopy(f.game.save) + T.eq(run.loader.hooks:call('core.update',function(raw) + T.eq(raw,f.game);raw.save=replacement;return 29 + end,f.game,1/60),29,'native-handle callback permits a genuine native save transition') + T.eq(e.corePost,true,'native save transition is not misreported as a record-transaction error') + T.eq(f.game.save,replacement) + + Probe.close(f) +end +for _,lean in ipairs(Hooks.callLean and{false,true}or{false})do for _,mode in ipairs{'yield-next','yield-return','yield-after'}do + Hooks.lean=lean + local f,b,run,e,c,rolls=open('emerald');e.mode=mode + local co;local calls=0 + G._withCallbackOwner('native-root',function() + co=coroutine.create(function() + local damage=run.loader.hooks:call('battle.damage',function(raw) + calls=calls+1;raw.rng(0,1);return 10 + end,c) + b.enemy.mon.hp=b.enemy.mon.hp-damage + return damage + end) + local ok,value=coroutine.resume(co);T.eq(ok,true);T.eq(value,'contact') + end) + local hp=b.enemy.mon.hp;local count=rolls();local beforeCalls=calls + T.eq(pcall(e.rng,1,2),false,'suspended callback RNG is unavailable to an independent caller') + T.eq(rolls(),count) + local ok,why=coroutine.resume(co) + T.eq(ok,false,mode..' cannot complete an expired native hook') + T.eq(tostring(why):find('retired',1,true)~=nil,true) + T.eq(b.enemy.mon.hp,hp,'revoked source commit cannot write native HP') + T.eq(rolls(),count,'revoked hook fallback cannot consume native RNG') + T.eq(calls,beforeCalls,'revoked hook fallback cannot execute or retry the native body') + Probe.close(f) +end end +-- Native API3 consumers and follower-only attribution keep their raw handles. +for _,kind in ipairs{'native','follower'}do + local body=(kind=='follower'and"require('src.compat.game3.Follower')\n"or'')..[[ + local mod=...;local e=mod.exports + mod.events:on('game.ready',function(p)e.event=p end) + mod.hooks:wrap('battle.damage',function(nextFn,c) + e.battle,e.user,e.rng=c.battle,c.user.id,c.rng;return nextFn(c) + end) + ]] + local f,b,run,e,c,rolls=open('emerald',body) + local nativeRng=c.rng + run.loader.hooks:call('battle.damage',function(raw)T.eq(raw.battle,b);return raw.rng(0,1)end,c) + T.eq(e.battle,b);T.eq(e.user,b.player.id,'native consumer battler ID ABI is preserved') + T.eq(e.rng,nativeRng,'native consumer RNG handle ABI is preserved') + T.eq(c.rng,nativeRng) + local payload={game=f.game};run.loader.events:emit('game.ready',payload) + T.eq(e.event,payload,'native/follower-only event payload identity is preserved') + T.eq(e.event.game,f.game,'native/follower-only events keep native Game handles') + Probe.close(f) +end +-- Complete native turns: the same Loader/API3 setup with a native versus +-- source namespace pass-through must retain every HP/PP/RNG/frame record. +for _,edition in ipairs{'firered','leafgreen','ruby','sapphire','emerald'}do + local traces={} + for _,source in ipairs{false,true}do + Runtime.install(saved[1],saved[2],saved[3]);G.release() + traces[#traces+1]=Probe.turn(edition,true,function(f,b) + local body=source and entry or[[local mod=...;mod.hooks:wrap('battle.damage',function(nextFn,c)return nextFn(c)end)]] + local files={['mods/turn_probe/manifest.json']=Json.encode({id='turn_probe',name='Turn probe',version='1.0.0', + api=3,entry='main.lua',games={'gen3'},permissions={'engine_internals'}}),['mods/turn_probe/main.lua']=body} + local run=T.sdk.loadMods({'mods/turn_probe'},{data=f.data,fs=T.sdk.memfs(files)}) + T.eq(#run.errors,0);f.game.mods=run.loader;run.loader.game=f.game + end) + end + T.same(traces[1],traces[2],edition..' source pass-through retains complete native turn HP/PP/RNG/frame traces') +end +Runtime.install(saved[1],saved[2],saved[3]);Hooks.lean=saved[4] +T.finish('Actual source callback identities, bound context copies, RNG lifetime and pre-commit/fallback retirement') From 7cba4427bd4d60d56643b5ea55f238cec2d20ea6 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 08:17:50 +0100 Subject: [PATCH 26/75] Declare callback boundaries for existing public presentation and follower hooks --- src/mods/UnifiedCallbacks.lua | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 36e4f4bc1..1fbc33004 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -16,6 +16,8 @@ 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.caught_marker_visible battle.menu_auxiliary field.weather.sound + render.output render.output_enabled render.viewport render.window world.follower.spawn 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 @@ -39,6 +41,9 @@ C.events = names([[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) local positions = { + ["battle.caught_marker_visible"] = { "context" }, + ["battle.menu_auxiliary"] = { false, "context" }, + ["world.follower.spawn"] = { false, false }, ["battle.accuracy"] = { "context" }, ["battle.damage"] = { "context" }, ["battle.crit"] = { "context" }, From 305832a23181157ab72467f7f26c8c13a4149e00 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 08:20:11 +0100 Subject: [PATCH 27/75] Record exact older-public callback qualification and prerequisites --- .../callback-context-ownership.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/qa/native-foundation/callback-context-ownership.md b/docs/qa/native-foundation/callback-context-ownership.md index cee40c08d..351b112c6 100644 --- a/docs/qa/native-foundation/callback-context-ownership.md +++ b/docs/qa/native-foundation/callback-context-ownership.md @@ -20,8 +20,8 @@ writes. Their RNG handles require the same visible native ancestry and close with the transaction. Public RNG closures hold weak leases. Source event transactions also close on return/error. -Both native hook walks reject revoked coroutines before entering/falling back to -vanilla and before returning a cached result. A source yielding before or after +The existing classic hook walk rejects revoked coroutines before entering/falling +back to vanilla and before returning a cached result. A source yielding before or after next cannot use a fallback or late commit to advance HP/RNG after its parent phase has returned. Existing ordinary failure fallback/once-only/nil-tuple behavior remains covered by the native hook and callback regressions. @@ -31,3 +31,17 @@ preempt already-suspended arbitrary Lua statements, provide a complete native HP/RNG continuation, install a resolver or enable Action Card. Native default combat, Gen2 and native-owner ABI qualification are separate explicit checks. Player saves and private mod files are not changed by QA. + +Older-public qualification: component `7cba4427`, exact composition +`1b6ef135a5`, passes60 selected CPU groups, including186 source context checks, +1516 existing unified callback checks and111 API3 entry checks. This host has +one classic hook walk; no newer native hook/controller body was overlaid. +Declared foundations are the already offered API3 entry packet #102 and native +source/session packets #265/#329/#346. Existing API3 changes were merged against +the actual older files, resolving only the battle command registration, loader +retirement ordering and retained native TextBox reveal/update behavior. Three +already-owned added helpers were retained. The absent Gen4MoveEffects preimage +was explicitly omitted from this Gen3 callback composition: this is not full +API3/Gen4 qualification. Eight existing native presentation/weather/follower +hooks now have explicit callback decisions; absent native hooks are not invented. +The current-fork qualification and locked-Mac Ruby run above are separate evidence. From af2a073872478861eb84e6be601366a699c93c8c Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 08:27:35 +0100 Subject: [PATCH 28/75] Decorate actual native move calls with scoped source resolver ownership --- src/battle/BattleState.lua | 10 ++- src/core/game3/battle/engine.lua | 89 +++++++++++++++++++++++ tests/engine/native_move_resolution.lua | 97 +++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 src/core/game3/battle/engine.lua create mode 100644 tests/engine/native_move_resolution.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index d72139edc..976dd9d4b 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -8062,7 +8062,7 @@ local WEATHER_BALL_ANIM = { SUN = 1, RAIN = 2, SANDSTORM = 3, HAIL = 4, } -function BattleState:performMove(user, target, moveInst, isCalled, suppressAnimation) +local function nativePerformMove(self, user, target, moveInst, isCalled, suppressAnimation) local move = self:moveDef(moveInst) if not move then Logger.warn("unknown move instance %s", tostring(moveInst.id)) @@ -8312,6 +8312,14 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima end end +function BattleState:performMove(user, target, moveInst, isCalled, suppressAnimation, spreadContinuation) + local resolver=GameVersion.isGen3()and package.loaded['src.core.game3.battle.engine'] + if resolver then + return resolver._native(self,user,target,moveInst,isCalled,suppressAnimation,spreadContinuation,nativePerformMove) + end + return nativePerformMove(self,user,target,moveInst,isCalled,suppressAnimation,spreadContinuation) +end + function BattleState:continueTrapping(user, target) self:sayNext(self:romText("_AttackContinuesText", "%s's\nattack continues!", displayName(user))) -- .MultiturnMoveCheck (core.asm:3554-3566) prints AttackContinuesText diff --git a/src/core/game3/battle/engine.lua b/src/core/game3/battle/engine.lua new file mode 100644 index 000000000..4b7378b44 --- /dev/null +++ b/src/core/game3/battle/engine.lua @@ -0,0 +1,89 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Decoration of the actual native move invocation, before Encore, PP, RNG, +-- announcement and item frames. This is not an alternate combat executor. +local Battle=require('src.core.game3.battle') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local CoroutineScopes=require('src.compat.gen1.CoroutineScopes') +local Call=require('src.compat.gen1.SourceCall') +local Version=require('src.core.GameVersion') +local Ids=require('src.compat.gen1.BattleIds') +local scopes=CoroutineScopes.new() +local M={} +local unpack=table.unpack or unpack +local function pack(...)return {n=select('#',...),...}end +-- Newer native owners bind action records to a learned PP slot; older +-- owners debit the move instance itself. Observe the owner already in use. +local function ppOwner(move) + local choices=package.loaded['src.battle.EnemyMoveChoice'] + local resolver=choices and choices.slot + return resolver and resolver(move)or move,choices,resolver +end +local function live(lease) + assert(lease and not lease.closed and not lease.frame.closed and not CoroutineScopes.retired(), + 'native move resolution retired') + local game,screen=lease.game,lease.screen + assert(Version.get()==lease.edition and Battle._screen(lease.state)==screen + and screen.game==game and screen.data==lease.data and game.data==lease.data + and game.save==lease.save and game.mods==lease.mods and game.stack==lease.stack + and game.stack:top()==screen and screen.queue==lease.queue and not screen.result + and (type(game.mods)=='table'and game.mods.content or nil)==lease.content + and lease.ids:valid(),'native move resolution cohort retired') + assert(lease.user.position==lease.userPosition and lease.target.position==lease.targetPosition + and screen:battlerAt(lease.userPosition)==lease.user + and screen:battlerAt(lease.targetPosition)==lease.target + and lease.state.battlers[lease.userPosition]==lease.publicUser + and lease.state.battlers[lease.targetPosition]==lease.publicTarget, + 'native move resolution actors retired') + assert(lease.move.id==lease.moveId and screen.data.moves[lease.moveId]==lease.definition, + 'native move resolution identity retired') + local selected,choices,resolver=ppOwner(lease.move) + assert(selected==lease.selected and choices==lease.choices and resolver==lease.slotResolver + and (not lease.slot or (lease.user.curMoves or lease.user.mon.moves or{})[lease.slot]==lease.selected), + 'native move resolution PP owner retired') + return lease +end +function M.resolveMove(user,target,move,slot,adapter,state,out,...) + local frame=scopes:find('move');local lease=frame and live(frame.value) + if not lease then return nil,'move resolution belongs to its native invocation'end + assert(user==lease.publicUser and target==lease.publicTarget and move==lease.moveId + and slot==lease.slot and adapter==nil and state==lease.state and out==nil + and select('#',...)==0,'native move resolution arguments changed') + return Boundary.invoke('battle.resolveMove') +end +function M._native(screen,user,target,move,isCalled,suppressAnimation,spreadContinuation,body) + local state=Battle.getState() + if not Version.isGen3()or Battle._screen(state)~=screen then + return body(screen,user,target,move,isCalled,suppressAnimation,spreadContinuation) + end + if not(user and target and user.position~=nil and target.position~=nil + and screen:battlerAt(user.position)==user and screen:battlerAt(target.position)==target + and state.battlers[user.position]and state.battlers[target.position])then + return body(screen,user,target,move,isCalled,suppressAnimation,spreadContinuation) + end + local game=screen.game + local lease={game=game,screen=screen,state=state,data=screen.data,save=game.save,mods=game.mods, + stack=game.stack,content=type(game.mods)=='table'and game.mods.content or nil,queue=screen.queue,edition=Version.get(),ids=Ids.new(screen.data), + user=user,target=target,userPosition=user.position,targetPosition=target.position,move=move,moveId=move.id,definition=screen.data.moves[move.id]} + lease.publicUser,lease.publicTarget=state.battlers[user.position],state.battlers[target.position] + local selected,choices,resolver=ppOwner(move) + lease.selected,lease.choices,lease.slotResolver=selected,choices,resolver + for index,entry in ipairs(user.curMoves or user.mon.moves or{})do + if entry==selected then lease.slot=index;break end + end + local frame=scopes:push('move',lease);lease.frame=frame + local result=pack(pcall(function() + return Boundary.dispatch('battle.resolveMove',function() + live(lease) + return body(screen,user,target,move,isCalled,suppressAnimation,spreadContinuation) + end,function() + live(lease) + return Call.call(M,'resolveMove',lease.publicUser,lease.publicTarget,lease.moveId,lease.slot,nil,state,nil) + end) + end)) + local retired=frame.closed or CoroutineScopes.retired() + lease.closed=true;scopes:close(frame) + if not result[1]then error(result[2],0)end + if retired then error('native move resolution retired while suspended',0)end + return unpack(result,2,result.n) +end +return M diff --git a/tests/engine/native_move_resolution.lua b/tests/engine/native_move_resolution.lua new file mode 100644 index 000000000..3b3dd0a3f --- /dev/null +++ b/tests/engine/native_move_resolution.lua @@ -0,0 +1,97 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Probe=dofile('tests/native_action_plan/turn.lua') +local B=require('src.core.game3.battle') +local E=require('src.core.game3.battle.engine') +local function open(edition)local f,b=Probe.open(edition);b:syncSides();return f,b end +local base=E.resolveMove +local function pack(...)return {n=select('#',...),...}end +local nothing,why=E.resolveMove();T.eq(nothing,nil);T.eq(type(why),'string') +for _,edition in ipairs{'firered','leafgreen','ruby','sapphire','emerald'}do + local baseline=Probe.turn(edition,true,function()package.loaded['src.core.game3.battle.engine']=nil end) + local calls=0 + local decorated=Probe.turn(edition,true,function() + package.loaded['src.core.game3.battle.engine']=E + E.resolveMove=function(u,t,m,slot,adapter,st,out,...) + calls=calls+1 + T.eq(st,B.getState());T.eq(u,st.battlers[u.id]);T.eq(t,st.battlers[t.id]) + T.eq(m,'MOVE_033');T.eq(slot,1);T.eq(adapter,nil);T.eq(out,nil) + return base(u,t,m,slot,adapter,st,out,...) + end + end) + E.resolveMove=base + T.eq(calls,2,edition..' reaches the actual two native move invocations') + T.same(baseline,decorated,edition..' native HP/PP/every RNG/frame/phase trace survives a pass-through resolver') +end +for _,changed in ipairs{'user','target','move','slot','state','extra'}do + local f,b=open('emerald');local pp=b.player.mon.moves[1].pp;local rolls=0 + b.rng=function(lo)rolls=rolls+1;return lo or 0 end + E.resolveMove=function(u,t,m,slot,ad,st,out) + if changed=='user'then u=t elseif changed=='target'then t=u elseif changed=='move'then m='OTHER' + elseif changed=='slot'then slot=2 elseif changed=='state'then st={}end + if changed=='extra'then return base(u,t,m,slot,ad,st,out,true)end + return base(u,t,m,slot,ad,st,out) + end + T.raises(function()b:performMove(b.player,b.enemy,b.player.mon.moves[1])end,'arguments changed') + T.eq(b.player.mon.moves[1].pp,pp,'foreign resolver arguments cannot spend PP') + T.eq(rolls,0,'foreign resolver arguments cannot consume RNG');T.eq(#b.queue,0) + E.resolveMove=base;Probe.close(f) +end +for _,changed in ipairs{'data','save','mods','stack','queue','definition','actor'}do + local f,b=open('emerald');local pp=b.player.mon.moves[1].pp;local rolls=0 + b.rng=function(lo)rolls=rolls+1;return lo or 0 end + local prior=changed=='definition'and b.data.moves.MOVE_033 or changed=='actor'and b.player or changed=='queue'and b.queue or f.game[changed] + E.resolveMove=function(...) + if changed=='queue'then b.queue={} + elseif changed=='definition'then b.data.moves.MOVE_033={} + elseif changed=='actor'then local copy={};for k,v in pairs(b.player)do copy[k]=v end;b.player=copy + else f.game[changed]={}end + return base(...) + end + local ok=pcall(b.performMove,b,b.player,b.enemy,b.player.mon.moves[1]) + T.eq(ok,false,'changed '..changed..' cannot start a retained move') + T.eq(rolls,0,'cohort guard precedes native RNG') + if changed=='definition'then b.data.moves.MOVE_033=prior elseif changed=='actor'then b.player=prior elseif changed=='queue'then b.queue=prior else f.game[changed]=prior end + E.resolveMove=base;Probe.close(f) +end +-- Replacing the selected PP slot cannot redirect a lent native invocation. +do + local f,b=open('emerald');local moves=b.player.curMoves;local original=moves[1];local calls=0 + E.resolveMove=function(...)moves[1]={id=original.id,pp=99};return base(...)end + T.raises(function()E._native(b,b.player,b.enemy,original,false,false,false,function()calls=calls+1 end)end,'PP owner retired') + T.eq(calls,0);T.eq(original.pp,10);moves[1]=original + E.resolveMove=base;Probe.close(f) +end +-- Native nil tuples and the once-only result use the actual dispatch body. +do + local f,b=open('ruby');local calls=0 + E.resolveMove=function(...) + local one=pack(base(...));local two=pack(base(...));T.same(one,two) + return unpack(two,1,two.n) + end + local result=pack(E._native(b,b.player,b.enemy,b.player.mon.moves[1],false,false,false,function() + calls=calls+1;return nil,7,nil + end)) + T.eq(calls,1);T.eq(result.n,3);T.eq(result[1],nil);T.eq(result[2],7);T.eq(result[3],nil) + E.resolveMove=base;Probe.close(f) +end +-- A yielded source call is never a durable claim over a different native phase. +do + local f,b=open('ruby');local co;local calls=0 + E.resolveMove=function(...) + local args=pack(...) + co=coroutine.create(function()coroutine.yield('pause');return base(unpack(args,1,args.n))end) + T.eq(coroutine.resume(co),true) + end + E._native(b,b.player,b.enemy,b.player.mon.moves[1],false,false,false,function()calls=calls+1 end) + local resumed,value,reason=coroutine.resume(co);T.eq(resumed,true);T.eq(value,nil);T.eq(type(reason),'string');T.eq(calls,0) + E.resolveMove=base;Probe.close(f) +end +-- Failure after consuming the native body cannot replay it. +do + local f,b=open('ruby');local calls=0 + E.resolveMove=function(...)base(...);error('owned resolver failure')end + T.raises(function()E._native(b,b.player,b.enemy,b.player.mon.moves[1],false,false,false,function()calls=calls+1 end)end,'owned resolver failure') + T.eq(calls,1);E.resolveMove=base;Probe.close(f) +end +T.finish('Actual native move resolver, exact arguments, once-only results and unchanged five-edition turns') From 19cd12bf5b8f5cda89c15fab40e8885224e39ebe Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 08:30:03 +0100 Subject: [PATCH 29/75] Initialize native battler placement in owned turn fixtures --- tests/engine/native_move_resolution.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/engine/native_move_resolution.lua b/tests/engine/native_move_resolution.lua index 3b3dd0a3f..3dfb031eb 100644 --- a/tests/engine/native_move_resolution.lua +++ b/tests/engine/native_move_resolution.lua @@ -8,9 +8,11 @@ local base=E.resolveMove local function pack(...)return {n=select('#',...),...}end local nothing,why=E.resolveMove();T.eq(nothing,nil);T.eq(type(why),'string') for _,edition in ipairs{'firered','leafgreen','ruby','sapphire','emerald'}do - local baseline=Probe.turn(edition,true,function()package.loaded['src.core.game3.battle.engine']=nil end) + -- The owned probe skips native enter; explicitly run its native slot placement. + local baseline=Probe.turn(edition,true,function(_,b)b:syncSides();package.loaded['src.core.game3.battle.engine']=nil end) local calls=0 - local decorated=Probe.turn(edition,true,function() + local decorated=Probe.turn(edition,true,function(_,b) + b:syncSides() package.loaded['src.core.game3.battle.engine']=E E.resolveMove=function(u,t,m,slot,adapter,st,out,...) calls=calls+1 From 96f758ecdab78951c13584377da67b3af208f4a3 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 08:32:59 +0100 Subject: [PATCH 30/75] Pin native mon identities for each admitted move --- src/core/game3/battle/engine.lua | 5 +++-- tests/engine/native_move_resolution.lua | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/core/game3/battle/engine.lua b/src/core/game3/battle/engine.lua index 4b7378b44..e0fb2a094 100644 --- a/src/core/game3/battle/engine.lua +++ b/src/core/game3/battle/engine.lua @@ -28,7 +28,8 @@ local function live(lease) and game.stack:top()==screen and screen.queue==lease.queue and not screen.result and (type(game.mods)=='table'and game.mods.content or nil)==lease.content and lease.ids:valid(),'native move resolution cohort retired') - assert(lease.user.position==lease.userPosition and lease.target.position==lease.targetPosition + assert(lease.user.mon==lease.userMon and lease.target.mon==lease.targetMon + and lease.user.position==lease.userPosition and lease.target.position==lease.targetPosition and screen:battlerAt(lease.userPosition)==lease.user and screen:battlerAt(lease.targetPosition)==lease.target and lease.state.battlers[lease.userPosition]==lease.publicUser @@ -63,7 +64,7 @@ function M._native(screen,user,target,move,isCalled,suppressAnimation,spreadCont local game=screen.game local lease={game=game,screen=screen,state=state,data=screen.data,save=game.save,mods=game.mods, stack=game.stack,content=type(game.mods)=='table'and game.mods.content or nil,queue=screen.queue,edition=Version.get(),ids=Ids.new(screen.data), - user=user,target=target,userPosition=user.position,targetPosition=target.position,move=move,moveId=move.id,definition=screen.data.moves[move.id]} + user=user,target=target,userMon=user.mon,targetMon=target.mon,userPosition=user.position,targetPosition=target.position,move=move,moveId=move.id,definition=screen.data.moves[move.id]} lease.publicUser,lease.publicTarget=state.battlers[user.position],state.battlers[target.position] local selected,choices,resolver=ppOwner(move) lease.selected,lease.choices,lease.slotResolver=selected,choices,resolver diff --git a/tests/engine/native_move_resolution.lua b/tests/engine/native_move_resolution.lua index 3dfb031eb..ede593a0d 100644 --- a/tests/engine/native_move_resolution.lua +++ b/tests/engine/native_move_resolution.lua @@ -39,21 +39,22 @@ for _,changed in ipairs{'user','target','move','slot','state','extra'}do T.eq(rolls,0,'foreign resolver arguments cannot consume RNG');T.eq(#b.queue,0) E.resolveMove=base;Probe.close(f) end -for _,changed in ipairs{'data','save','mods','stack','queue','definition','actor'}do +for _,changed in ipairs{'data','save','mods','stack','queue','definition','actor','mon'}do local f,b=open('emerald');local pp=b.player.mon.moves[1].pp;local rolls=0 b.rng=function(lo)rolls=rolls+1;return lo or 0 end - local prior=changed=='definition'and b.data.moves.MOVE_033 or changed=='actor'and b.player or changed=='queue'and b.queue or f.game[changed] + local prior=changed=='definition'and b.data.moves.MOVE_033 or changed=='actor'and b.player or changed=='mon'and b.player.mon or changed=='queue'and b.queue or f.game[changed] E.resolveMove=function(...) if changed=='queue'then b.queue={} elseif changed=='definition'then b.data.moves.MOVE_033={} elseif changed=='actor'then local copy={};for k,v in pairs(b.player)do copy[k]=v end;b.player=copy + elseif changed=='mon'then local copy={};for k,v in pairs(b.player.mon)do copy[k]=v end;b.player.mon=copy else f.game[changed]={}end return base(...) end local ok=pcall(b.performMove,b,b.player,b.enemy,b.player.mon.moves[1]) T.eq(ok,false,'changed '..changed..' cannot start a retained move') T.eq(rolls,0,'cohort guard precedes native RNG') - if changed=='definition'then b.data.moves.MOVE_033=prior elseif changed=='actor'then b.player=prior elseif changed=='queue'then b.queue=prior else f.game[changed]=prior end + if changed=='definition'then b.data.moves.MOVE_033=prior elseif changed=='actor'then b.player=prior elseif changed=='mon'then b.player.mon=prior elseif changed=='queue'then b.queue=prior else f.game[changed]=prior end E.resolveMove=base;Probe.close(f) end -- Replacing the selected PP slot cannot redirect a lent native invocation. From 75e9a259e518439fe0a8782ee2da8e8da5a66dcd Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 08:38:58 +0100 Subject: [PATCH 31/75] Record current and older-public native resolver qualification --- .../move-resolution-scope.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/qa/native-foundation/move-resolution-scope.md diff --git a/docs/qa/native-foundation/move-resolution-scope.md b/docs/qa/native-foundation/move-resolution-scope.md new file mode 100644 index 000000000..a20a6c918 --- /dev/null +++ b/docs/qa/native-foundation/move-resolution-scope.md @@ -0,0 +1,43 @@ +# Native move resolution scope + +`battle.engine.resolveMove` decorates the actual `BattleState:performMove` +invocation before the existing native body starts. Encore/target correction, +PP, RNG, announcement, called/spread moves, Substitute and item frames remain +inside that unchanged body. There is no second combat executor or event queue. + +An admitted source receives the existing battle/battler view identities, +the actual move key and its learned-slot index when one exists. Adapter/output +arguments are absent rather than fabricated. Base calls must return the exact +admitted arguments. The base consumes the native body at most once, caches nil +return tuples and cannot retry a failed native body. Calls outside the native +invocation cannot resolve a move. + +Before consuming the body, the scope checks edition, dataset/identifier tables, +save, loader/content cohort, stack/top, queue, result, actor/position identity, +move-definition identity and the selected PP owner. Current native action records +use their already-loaded slot owner; older native bodies debit the move instance +itself. No newer action/AI module is imported to change the older native body. +A retained callback cannot move its claim to another action or native phase. + +Default native and Gen2 paths remain direct. Merely importing the source module +on a non-admitted native battle does not instantiate another controller. This +packet supplies a real resolver boundary, but does not project an action-ready +phase, change UI busy semantics, permit a suspended contact continuation or +provide ownership/obedience facts. Those remain separate native-owner work. +Action Card/contact timing and complete source animation playback are not +qualified by this packet. + +The initial broad source `b53a8e0875` passes155 selected CPU groups, including +133 initial resolver checks. Its exact pre-resolver source comparison +`73ad374572` matches complete HP, PP, every RNG call and frame/phase traces for +all five GBA editions, both with the new module absent and with it loaded. +Final actor/mon/PP-slot ownership guards expand the focused suite to138 checks. +Final frozen `c00886af4b` passes155 selected CPU groups. Older-public component +`96f758ec`, exact composition `64b35cafd8`, passes61 groups including the same +138 resolver checks. The owned fixture runs native battler placement explicitly +because it bypasses the native enter animation; no placement body was changed. +Only the owned method declaration/wrapper was adapted over the older native +move body, which remains intact. No newer controller/AI body was copied. + +Only public engine and owned QA files change. QA uses fresh owned profiles and +a20GiB free-space floor. Player saves and private mod bytes are untouched. From bd8ac983ec47f0fe1dad0ed08cf349f7de3ae964 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 08:44:11 +0100 Subject: [PATCH 32/75] Record current-main move-scope and predecessor results --- docs/qa/native-foundation/move-resolution-scope.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/qa/native-foundation/move-resolution-scope.md b/docs/qa/native-foundation/move-resolution-scope.md index a20a6c918..b6cdaf802 100644 --- a/docs/qa/native-foundation/move-resolution-scope.md +++ b/docs/qa/native-foundation/move-resolution-scope.md @@ -41,3 +41,5 @@ move body, which remains intact. No newer controller/AI body was copied. Only public engine and owned QA files change. QA uses fresh owned profiles and a20GiB free-space floor. Player saves and private mod bytes are untouched. + +Merged current main `73b2c0f8` as `80b4cc2f`: exact frozen `269e87ae07` passes164 selected CPU groups, including the new GSC sleep/status/SFX, native audio shutdown and five-edition field-return/weather battle groups. Exact actual-main predecessor `aff668b6b2` compares the unmodified `73b2c0f8` native source with this merged candidate, both module-absent and module-loaded: full HP/PP/every-RNG/frame-phase traces match in all five GBA editions. From 4f3c930dea0987d6855ab492f60510396f41cc49 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 08:49:23 +0100 Subject: [PATCH 33/75] Expose native battle ownership, Encore and move-lock facts without writes --- src/compat/gen1/BattleView.lua | 252 ++++++++++++++++++++++ src/core/game3/battle/engine.lua | 9 + src/pokemon/Gen3Ownership.lua | 23 ++ src/ui/Gen3SummaryMenu.lua | 12 +- tests/engine/native_battle_move_facts.lua | 64 ++++++ 5 files changed, 349 insertions(+), 11 deletions(-) create mode 100644 src/compat/gen1/BattleView.lua create mode 100644 src/pokemon/Gen3Ownership.lua create mode 100644 tests/engine/native_battle_move_facts.lua diff --git a/src/compat/gen1/BattleView.lua b/src/compat/gen1/BattleView.lua new file mode 100644 index 000000000..62870cda3 --- /dev/null +++ b/src/compat/gen1/BattleView.lua @@ -0,0 +1,252 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Live, typed source observations over one admitted native battle. Views own +-- their arrays; they never lend the native controller/save under source names. +local Ids=require('src.compat.gen1.BattleIds') +local Iteration=require('src.compat.gen1.TableIteration') +local M={} +local residents=setmetatable({},{__mode="k"}) +function M.nativeMon(view,readOnly) + local d=residents[view];if not d then return nil,false end + local e=d.environment + if not e.context or not e.valid()then return nil,true end + if not readOnly then e:flush()end + return d.entry.raw,true,e.context.game,e.context.screen +end +local statNames={attack='attack',defense='defense',speed='speed',spAtk='spatk',spDef='spdef',hp='hp'} +local statusBits={PSN=8,BRN=16,FRZ=32,PAR=64,TOX=128} +local function status(mon) + if mon.status=='SLP'then return math.max(1,math.min(7,tonumber(mon.sleepTurns)or 1))end + if mon.status=='PSN'and mon.toxic then return 128 end + return statusBits[mon.status]or 0 +end +local function integer(v,lo,hi)return type(v)=='number'and v==v and v%1==0 and v>=lo and v<=hi end +local monKeys={'species','nickname','level','hp','maxHp','attack','defense','speed','spAtk','spDef', + 'moves','pp','maxPp','item','status','ability','personality','friendship','isEgg','otId','otSecretId','otName','fatefulEncounter'} +local battlerKeys={'id','side','flank','mon','species','type1','type2','ability','item','status','fainted','stages','partyIndex','expLockedMove','expEncoreMove'} +local stateKeys={'player','enemy','battlers','party','playerParty','enemyParty','turn','wild','double','over','result', + 'weather','weatherTurns','session','playerIndex','enemyIndex','trainerClass','trainerName','trainerPicId', + 'link','safari','tutorial','oldManTutorial','pokedude','firstBattle','ghostBattle','kinds','rng'} +local reserved={} +for _,key in ipairs(stateKeys)do reserved[key]=true end +function M.new(game,screen,valid,identifiers) + local e={context={game=game,screen=screen,data=game.data},entries=setmetatable({},{__mode='k'}), + caches={},arrays={},valid=valid,retired=false} + local ids=identifiers or Ids.new(game.data) + local function at(pos) + local s=e.context and e.context.screen;if not s then return end + if pos==0 then return s.player elseif pos==1 then return s.enemy end + return s.battlerAt and s:battlerAt(pos) + end + local read + local function collection(name,values,convert) + local a=e.arrays[name] + if not a then a={view={}};e.arrays[name]=a end + local out=a.view;local old=#out + for i,value in ipairs(values or{})do out[i]=convert and convert(value,i)or value end + for i=#(values or{})+1,old do out[i]=nil end + return out + end + local function proxy(kind,raw) + if not raw then return nil end + local cache=e.caches[kind];if not cache then cache=setmetatable({},{__mode='kv'});e.caches[kind]=cache end + local existing=cache[raw];if existing then return existing end + local d={kind=kind,raw=raw};local view={};cache[raw]=view;e.entries[view]=d + if kind=="mon"then residents[view]={entry=d,environment=e}end + setmetatable(view,{__index=function(_,key) + if d.snapshot then return d.snapshot[key]end + if not e.context or not e.valid()then return nil end + return read(d,key) + end,__newindex=function(t,key,value) + assert(e.context and e.valid(),'native battle view retired') + if kind=='mon'and(key=='hp'or key=='nickname'or key=='item'or key=='status')then + local mon=d.raw + if key=='hp'then assert(integer(value,0,mon.stats.hp),'invalid native HP');mon.hp=value + elseif key=='nickname'then assert(type(value)=='string'and#value<=32,'invalid native nickname');mon.nickname=value + elseif key=='item'then + local item=ids:native('items',value);assert(value==0 or item,'unknown native held item');mon.item=item + else + assert(integer(value,0,255),'invalid native status') + local inverse={[8]='PSN',[16]='BRN',[32]='FRZ',[64]='PAR',[128]='PSN'} + assert(value==0 or value<=7 or inverse[value],'unsupported combined native status') + mon.status=value>0 and(value<=7 and'SLP'or inverse[value])or nil + mon.sleepTurns=value>0 and value<=7 and value or nil;mon.toxic=value==128 or nil + end + elseif kind=='state'and not reserved[key]then + -- Presentation extensions belong to this view; they cannot replace the + -- controller's command queue, party aliases or completion result. + rawset(t,key,value) + else error('native battle '..kind..' '..tostring(key)..' is not directly writable',2)end + end}) + -- LuaJIT does not consult __pairs. Keep registry values weak so its + -- weak key cannot retain itself through this environment's public view. + local observation=setmetatable({environment=e,entry=d},{__mode='v'}) + Iteration.register(view,function(spec,source) + local environment,entry=spec.environment,spec.entry + assert(environment and entry,'native battle view retired') + local out={};for key,value in Iteration.rawPairs(source)do out[key]=value end + if entry.snapshot then for key,value in pairs(entry.snapshot)do out[key]=value end;return out end + assert(environment.context and environment.valid(),'native battle view retired') + local keys=entry.kind=='state'and stateKeys or entry.kind=='battler'and battlerKeys or entry.kind=='mon'and monKeys + or{'attack','defense','speed','spAtk','spDef','accuracy','evasion'} + for _,key in ipairs(keys)do out[key]=environment.read(entry,key)end + return out + end,observation) + return view + end + local function partyIndex(party,mon)for i,value in ipairs(party or{})do if value==mon then return i end end end + local function party()local c=e.context;return c.screen.playerParty or c.game.save.party or{}end + local function moveArrays(mon) + local d=e.moveArrays and e.moveArrays[mon] + if not d then + e.moveArrays=e.moveArrays or{};d={moves={},pp={},maxPp={},baseline={}};e.moveArrays[mon]=d + end + -- PP edits use an explicit native transaction before the next native body. + if not d.pending then + for _,key in ipairs{'moves','pp','maxPp'}do for i in pairs(d[key])do d[key][i]=nil end end + d.baseline={};d.ids={};d.maximum={} + for i,mv in ipairs(mon.moves or{})do + d.moves[i]=ids:read('moves',mv.id);d.pp[i]=mv.pp;d.baseline[i]=mv.pp + local def=(e.context.data.moves or{})[mv.id] + d.maxPp[i]=def and def.pp and def.pp+math.floor(def.pp/5)*(mv.ppUps or 0)or nil + d.ids[i]=d.moves[i];d.maximum[i]=d.maxPp[i] + end + d.pending=true + end + return d + end + read=function(d,key) + local raw=d.raw;local c=e.context + if d.kind=='mon'then + if key=='species'then return ids:read('pokemon',raw.species)end + if key=='item'then return ids:read('items',raw.item)end + if key=='ability'then + local b;for pos=0,3 do local actual=at(pos);if actual and actual.mon==raw then b=actual;break end end + return ids:read('abilities',b and b.ability or raw.ability) + end + if key=='hp'then return raw.hp end + if key=='maxHp'then return raw.stats and raw.stats.hp end + if statNames[key]then return raw.stats and raw.stats[statNames[key]]end + if key=='status'then return status(raw)end + if key=='moves'or key=='pp'or key=='maxPp'then return moveArrays(raw)[key]end + if key=='fatefulEncounter'then return require('src.pokemon.Gen3Ownership').fateful(raw)end + if key=='otName'then return raw.ot end + if key=='otId'then return raw.otId or raw.otID end + if key=='otSecretId'then return raw.otSecretId or raw.secretId end + if key=='friendship'then return raw.friendship or raw.happiness end + if key=='nickname'or key=='level'or key=='personality'or key=='isEgg'then return raw[key]end + elseif d.kind=='battler'then + local pos=raw.position + if pos==nil then for i=0,3 do if at(i)==raw then pos=i;break end end end + if key=='id'then return pos end + if key=='side'then return pos and(pos%2==0 and'player'or'enemy')end + if key=='flank'then return pos and(pos<2 and'left'or'right')end + if key=='mon'then return proxy('mon',raw.mon)end + if key=='species'then return ids:read('pokemon',raw.species or raw.mon.species)end + if key=='ability'then return ids:read('abilities',raw.ability)end + if key=='item'then return ids:read('items',raw.mon.item)end + if key=='status'then return status(raw.mon)end + if key=='fainted'then return raw.fainted==true or raw.mon.hp<=0 end + if key=='partyIndex'then return partyIndex(pos and pos%2==0 and party()or c.screen.enemyParty,raw.mon)or 1 end + if key=='stages'then return proxy('stages',raw.stages or{})end + if key=='expLockedMove'then + local action=c.screen.menuLockedAction and c.screen:menuLockedAction(raw) + return action and(action.id or action.special) + end + if key=='expEncoreMove'then + local owner=package.loaded['src.battle.Gen3Encore'] + if owner and owner.menu then local action=owner.menu(raw);return action and action.id end + -- The earlier native fight owner checks the live Encore fields and PP. + -- Observe that decision without invoking its invalid-encore cleanup. + if raw.encoreTurns and raw.encoreMove then + for _,move in ipairs(raw.curMoves or raw.mon.moves or{})do + if move.id==raw.encoreMove and(move.pp or 0)>0 then return move.id end + end + end + end + if key=='type1'or key=='type2'then return ids:read('types',(raw.curTypes or{})[key=='type1'and 1 or 2])end + elseif d.kind=='stages'then return raw[statNames[key]or key] + elseif d.kind=='state'then + if key=='player'or key=='enemy'then return proxy('battler',raw[key])end + if key=='battlers'then + e.battlers=e.battlers or{};for pos=0,3 do e.battlers[pos]=proxy('battler',at(pos))end;return e.battlers + end + if key=='party'or key=='playerParty'then return collection('party',party(),function(mon)return proxy('mon',mon)end)end + if key=='enemyParty'then + e.wildParty=e.wildParty or{};e.wildParty[1]=raw.enemy and raw.enemy.mon + return collection('enemyParty',raw.enemyParty or e.wildParty,function(mon)return proxy('mon',mon)end) + end + if key=='turn'then return raw.turnCount or 0 end + if key=='wild'then return raw.kind=='wild'end + if key=='double'then return raw.isDouble and raw:isDouble()or raw.double==true end + if key=='over'then return raw.result~=nil or raw.dead==true end + if key=='result'then return raw.result end + if key=='weather'then return raw.field and raw.field.weather end + if key=='weatherTurns'then return raw.field and raw.field.weatherTurns end + if key=='playerIndex'then return partyIndex(party(),raw.player and raw.player.mon)end + if key=='enemyIndex'then return raw.enemyIndex or(raw.enemy and 1)end + if key=='trainerClass'then return raw.trainer and raw.trainer.class or raw.trainerClass end + if key=='trainerName'then return raw.trainerName or raw.trainer and raw.trainer.name end + if key=='trainerPicId'then return raw.trainer and(raw.trainer.picIndex or raw.trainer.index)end + if key=='session'then return require('src.core.Game3').forNative(c.game).session end + if key=='link'then return raw.kind=='link'end + if key=='safari'then return raw.safari~=nil and raw.safari~=false end + if key=='ghostBattle'then return raw.ghost==true end + if key=='tutorial'or key=='oldManTutorial'then return raw.demo==true or raw.dudeDemo==true end + if key=='pokedude'then return raw.pokedudeDemo~=nil and raw.pokedudeDemo~=false end + if key=='firstBattle'then return raw.firstBattle==true end + if key=='kinds'then + e.kinds=e.kinds or{};e.kinds.tutorial=raw.pokedudeDemo and'pokedude'or(raw.demo and'oldMan'or nil);return e.kinds + end + if key=='rng'then return e.rng end + end + end + e.read=read + e.view=proxy('state',screen) + e.rng=function(...)assert(e.context and e.valid(),'native battle RNG retired');return e.context.screen.rng(...)end + function e:flush() + if not self.context or not self.valid()then return false end + local writes={} + for mon,d in pairs(self.moveArrays or{})do + for i,value in pairs(d.moves)do assert(value==d.ids[i],'source move replacement requires native move learning')end + for i,value in pairs(d.ids)do assert(d.moves[i]==value,'source move replacement requires native move learning')end + for i,value in pairs(d.maxPp)do assert(value==d.maximum[i],'source maximum PP is read-only')end + for i,value in pairs(d.maximum)do assert(d.maxPp[i]==value,'source maximum PP is read-only')end + for i in pairs(d.baseline)do assert(d.pp[i]~=nil,'source PP slot cannot be removed')end + for i,value in pairs(d.pp)do + assert(d.baseline[i]~=nil,'unknown source PP slot') + if value~=d.baseline[i]then + local mv=mon.moves and mon.moves[i];local maximum=d.maxPp[i] + assert(mv and mv.pp==d.baseline[i],'native move PP changed during source edit') + assert(integer(value,0,maximum or 99),'invalid source move PP') + writes[#writes+1]={move=mv,value=value} + end + end + end + for _,write in ipairs(writes)do write.move.pp=write.value end + for mon,d in pairs(self.moveArrays or{})do d.pending=false;moveArrays(mon)end + return true + end + function e:retire() + if self.retired then return end + for mon,d in pairs(self.moveArrays or{})do d.pending=false;moveArrays(mon)end + -- Populate only declared observations. No native queue, functions, save or + -- data table is copied into the completed record or retained by its views. + local seen={} + repeat + local pending=false + for view,d in pairs(self.entries)do if not seen[view]then + pending=true;seen[view]=true;local snapshot={} + for _,key in ipairs(d.kind=='state'and stateKeys or d.kind=='battler'and battlerKeys or d.kind=='mon'and monKeys or{'attack','defense','speed','spAtk','spDef','accuracy','evasion'})do + if key~='session'and key~='rng'then snapshot[key]=read(d,key)end + end + d.snapshot=snapshot + end end + if not pending then break end + until false + for _,d in pairs(self.entries)do d.raw=nil end + self.context,self.moveArrays,self.wildParty,self.valid=nil,nil,nil,function()return false end + self.retired=true;ids=nil + end + return e +end +return M diff --git a/src/core/game3/battle/engine.lua b/src/core/game3/battle/engine.lua index e0fb2a094..3d24eba4f 100644 --- a/src/core/game3/battle/engine.lua +++ b/src/core/game3/battle/engine.lua @@ -43,6 +43,15 @@ local function live(lease) 'native move resolution PP owner retired') return lease end +-- Read the same ownership decision used by the native summary, from a +-- resident of this exact admitted battle. Queries never flush staged PP. +function M.isTradedMon(state,mon) + local screen=Battle._screen(state) + if not screen then return nil,'native battle admission required'end + local raw,known,game,owner=require('src.compat.gen1.BattleView').nativeMon(mon,true) + if not(known and raw and game==screen.game and owner==screen)then return nil,'foreign native Pokemon record'end + return not require('src.pokemon.Gen3Ownership').owns(raw,(game.save or{}).player) +end function M.resolveMove(user,target,move,slot,adapter,state,out,...) local frame=scopes:find('move');local lease=frame and live(frame.value) if not lease then return nil,'move resolution belongs to its native invocation'end diff --git a/src/pokemon/Gen3Ownership.lua b/src/pokemon/Gen3Ownership.lua new file mode 100644 index 000000000..e2a43c545 --- /dev/null +++ b/src/pokemon/Gen3Ownership.lua @@ -0,0 +1,23 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- The native summary's existing OT-name/ID ownership decision, shared by +-- typed battle observations. Missing historical OT fields remain unknown-own. +local M={} +function M.owns(mon,player) + if not(mon and player)then return true end + if mon.ot==nil and mon.otId==nil then return true end + if mon.ot~=nil and player.name~=nil and mon.ot~=player.name then return false end + if mon.otId~=nil and player.id~=nil and mon.otId~=player.id then return false end + return true +end +-- Preserve a known flag or the actual encoded GBA ribbon word. An absent +-- record stays unknown; it is never replaced by a fabricated default flag. +function M.fateful(mon) + local word=mon.gen3Ribbons + if word==nil then word=mon.ribbons end + if type(word)=='number'and word==word and word%1==0 and word>=0 and word<4294967296 then + return math.floor(word/2147483648)%2==1 + end + if type(mon.fatefulEncounter)=='boolean'then return mon.fatefulEncounter end + if type(mon.fateful)=='boolean'then return mon.fateful end +end +return M diff --git a/src/ui/Gen3SummaryMenu.lua b/src/ui/Gen3SummaryMenu.lua index 9d54ee41b..1e2e1a9ba 100644 --- a/src/ui/Gen3SummaryMenu.lua +++ b/src/ui/Gen3SummaryMenu.lua @@ -1201,17 +1201,7 @@ end -- gets a different half of the memo, and that is the point of the block -- -- your game did not watch it be caught and does not claim it did. function Gen3SummaryMenu:memoOwnMon() - local mon, save = self.mon, self.game and self.game.save - local player = save and save.player - if not (mon and player) then return true end - if mon.ot == nil and mon.otId == nil then return true end - if mon.ot ~= nil and player.name ~= nil and mon.ot ~= player.name then - return false - end - if mon.otId ~= nil and player.id ~= nil and mon.otId ~= player.id then - return false - end - return true + return require('src.pokemon.Gen3Ownership').owns(self.mon,(self.game and self.game.save or{}).player) end -- Which of the eight the cartridge would choose, from the same facts diff --git a/tests/engine/native_battle_move_facts.lua b/tests/engine/native_battle_move_facts.lua new file mode 100644 index 000000000..7cdfd189b --- /dev/null +++ b/tests/engine/native_battle_move_facts.lua @@ -0,0 +1,64 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Probe=dofile('tests/native_action_plan/turn.lua') +local B=require('src.core.game3.battle') +local Engine=require('src.core.game3.battle.engine') +local Ownership=require('src.pokemon.Gen3Ownership') +local Json=require('src.link.Json') +local function open(edition)local f,b=Probe.open(edition);b:syncSides();return f,b end +for _,edition in ipairs{'firered','leafgreen','ruby','sapphire','emerald'}do + local f,b=open(edition);local st=B.getState();local u=st.player;local raw=b.player;local mon=raw.mon + local rolls=0;b.rng=function(lo)rolls=rolls+1;return lo or 0 end + mon.ot,mon.otId='PROBE',1000 + T.eq(u.mon.otName,'PROBE');T.eq(Engine.isTradedMon(st,u.mon),false) + mon.ot='OTHER';T.eq(Engine.isTradedMon(st,u.mon),true,'different OT name is foreign') + mon.ot,mon.otId='PROBE',1001;T.eq(Engine.isTradedMon(st,u.mon),true,'matching names do not hide a foreign ID') + mon.ot,mon.otId=nil,nil;T.eq(Engine.isTradedMon(st,u.mon),false,'retain the native historical missing-OT policy') + T.eq(Engine.isTradedMon(st,{}),nil,'detached records cannot claim a battle resident') + T.eq(Engine.isTradedMon({},u.mon),nil,'foreign battle views cannot borrow ownership') + -- Merely reading native ownership must not commit a pending PP edit. + local pp=u.mon.pp;pp[1]=8 + T.eq(Engine.isTradedMon(st,u.mon),false);T.eq(mon.moves[1].pp,10) + pp[1]=10;B._flushNative(b) + T.eq(u.expLockedMove,nil) + raw.mustRecharge=true;T.eq(u.expLockedMove,'recharge');raw.mustRecharge=nil + raw.charging=mon.moves[1];T.eq(u.expLockedMove,'MOVE_033');raw.charging=nil + raw.thrashTurns,raw.thrashMove=2,mon.moves[1];T.eq(u.expLockedMove,'MOVE_033');raw.thrashTurns,raw.thrashMove=nil,nil + raw.rageMove=mon.moves[1];T.eq(u.expLockedMove,'MOVE_033');raw.rageMove=nil + raw.encoreTurns,raw.encoreMove,raw.encoreSlot=2,'MOVE_033',1 + T.eq(u.expEncoreMove,'MOVE_033','Encore observes the native forced move') + local before=Json.encode(f.game.save);local frame=b.frame + for _=1,30 do T.eq(u.expEncoreMove,'MOVE_033');T.eq(Engine.isTradedMon(st,u.mon),false)end + T.eq(Json.encode(f.game.save),before);T.eq(b.frame,frame);T.eq(rolls,0) + raw.encoreMove='MISSING';T.eq(u.expEncoreMove,nil);T.eq(raw.encoreTurns,2,'invalid Encore observation never performs native cleanup') + raw.encoreTurns,raw.encoreMove,raw.encoreSlot=nil,nil,nil + T.eq(u.mon.fatefulEncounter,nil,'an unknown native flag is not fabricated') + mon.fatefulEncounter=false;T.eq(u.mon.fatefulEncounter,false) + mon.fatefulEncounter=true;T.eq(u.mon.fatefulEncounter,true) + mon.gen3Ribbons=0;T.eq(u.mon.fatefulEncounter,false,'native encoded ribbon word is authoritative') + mon.gen3Ribbons=2147483648;T.eq(u.mon.fatefulEncounter,true) + mon.gen3Ribbons=2147483647;T.eq(u.mon.fatefulEncounter,false,'other ribbons do not imply fateful encounter') + mon.gen3Ribbons=nil;mon.fatefulEncounter=nil + local facts={};for k,v in pairs(u)do facts[k]=v end;T.eq(facts.expLockedMove,nil) + mon.ot='VISIBLE';T.eq(require('src.compat.gen1.TableIteration').snapshot(u.mon).otName,'VISIBLE') + T.raises(function()u.expLockedMove='OTHER'end,'not directly writable') + T.raises(function()u.mon.fatefulEncounter=true end,'not directly writable') + local foreign,other=open(edition) + T.eq(Engine.isTradedMon(B.getState(),u.mon),nil,'another live battle cannot accept the earlier resident') + Probe.close(foreign);Probe.close(f) + T.eq(Engine.isTradedMon(st,u.mon),nil,'retired battle cannot grant an ownership query') +end +-- Keep the summary's existing decision, including missing historical fields. +local Summary=require('src.ui.Gen3SummaryMenu') +for _,case in ipairs{ + {mon={},player={},own=true}, + {mon={ot='MAY',otId=123},player={name='MAY',id=123},own=true}, + {mon={ot='MAY',otId=123},player={name='MAY',id=124},own=false}, + {mon={ot='MAY',otId=123},player={name='BRENDAN',id=123},own=false}, + {mon={ot='MAY'},player={},own=true}, +}do + T.eq(Ownership.owns(case.mon,case.player),case.own) + T.eq(Summary.memoOwnMon({mon=case.mon,game={save={player=case.player}}}),case.own) +end +for _,word in ipairs{-1,4294967296,0/0,.5}do T.eq(Ownership.fateful({gen3Ribbons=word}),nil,'malformed encoded flags remain unknown')end +T.finish('Native battle ownership, live move locks, Encore and fateful facts without combat or PP side effects') From 1b86bdb5bbc158e5f77e22c3e03019cc34ca080c Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 08:57:08 +0100 Subject: [PATCH 34/75] Record native facts provenance and older-public qualification --- .../qa/native-foundation/battle-move-facts.md | 31 +++++++++++++++++++ src/pokemon/Gen3Ownership.lua | 3 +- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 docs/qa/native-foundation/battle-move-facts.md diff --git a/docs/qa/native-foundation/battle-move-facts.md b/docs/qa/native-foundation/battle-move-facts.md new file mode 100644 index 000000000..5746d63af --- /dev/null +++ b/docs/qa/native-foundation/battle-move-facts.md @@ -0,0 +1,31 @@ +# Native battle move and ownership facts + +Expose the actual native menu move lock, native Encore move, OT name and known +fateful mark through the admitted battle views. Recharge uses its actual native +special kind; move locks retain their actual native move key. Encore reads the +existing native owner when loaded; the older native controller's live field/PP +decision is observed without running its invalid-encore cleanup. Queries do not +change combat, RNG, native frames, queue rows or a pending PP edit. + +`engine.isTradedMon(state, mon)` accepts only a resident of that exact active +battle. It shares the existing native summary's OT name plus ID decision, +including its historical policy for missing OT fields. The predicate is factored +from the summary without changing its rules. Native ribbon words determine a +known encoded fateful mark; otherwise an existing boolean is read, and absent or +malformed metadata remains unknown. No true/false default is fabricated. + +The fields participate in existing typed iteration/snapshots and remain +read-only. Foreign/retired residents cannot lend ownership authority. Native +obedience, source contact continuation and a scoped interactive-choice policy +remain separate work; this packet does not enable Action Card. + +Frozen current-main source `a6c22718d3` passes167 selected CPU groups, including +474 targeted fact checks,138 resolver checks,216 callback checks and native +summary regressions. Older-public component `4f3c930d`, composition `89c8349e34`, +passes62 groups including the same474 fact checks on the older Encore owner. +The existing owned BattleView prerequisite from #329 is reused; its older PP +calculation is retained. No newer native controller/AI body is overlaid. +After qualification, only the helper's authorship/provenance comment changed. + +Native/player save profiles and private mod bytes are untouched. QA uses fresh +owned profiles and the20GiB free-space floor, preserving the requested8GiB. diff --git a/src/pokemon/Gen3Ownership.lua b/src/pokemon/Gen3Ownership.lua index e2a43c545..1b63f9e17 100644 --- a/src/pokemon/Gen3Ownership.lua +++ b/src/pokemon/Gen3Ownership.lua @@ -1,4 +1,5 @@ --- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native OT predicate factored from Gen3SummaryMenu; existing upstream rights apply. +-- Added observation glue (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. -- The native summary's existing OT-name/ID ownership decision, shared by -- typed battle observations. Missing historical OT fields remain unknown-own. local M={} From 4f7b6576150eea003fcd07c783571ea696d84f43 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 09:22:05 +0100 Subject: [PATCH 35/75] Record current-source unchanged Ruby scene qualification --- docs/qa/native-foundation/battle-move-facts.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/qa/native-foundation/battle-move-facts.md b/docs/qa/native-foundation/battle-move-facts.md index 5746d63af..0421ff2d2 100644 --- a/docs/qa/native-foundation/battle-move-facts.md +++ b/docs/qa/native-foundation/battle-move-facts.md @@ -29,3 +29,11 @@ After qualification, only the helper's authorship/provenance comment changed. Native/player save profiles and private mod bytes are untouched. QA uses fresh owned profiles and the20GiB free-space floor, preserving the requested8GiB. + +Unchanged OASC109 `bddb5181`, Ruby `8714d38018`, frozen `a6c22718d3`: +native new-game/GRUNT_003 trainer entry, paper/native/terrarium/native/paper, +move swap/cancel, actual RAGE PP20 to19 / enemy HP66 to58 / player HP63, +dex retirement and clean source teardown pass without loader errors. Terrarium +and after-move captures were inspected upright. Owned fresh profile; this is +not full story, contact timing, all-terrain, visible-desktop or stable-FPS +acceptance. All own GPU children and managed copies are cleaned up;30GiB free. From 265bc733a45f704c75383d24ca996b99e1acb5f3 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 09:41:41 +0100 Subject: [PATCH 36/75] Attribute native lifecycle and option installs to their actual subscribers --- src/mods/Events.lua | 16 +- src/mods/Loader.lua | 4 + src/mods/ModSeams.lua | 49 +++++- tests/engine/native_option_seam_ownership.lua | 148 ++++++++++++++++++ 4 files changed, 207 insertions(+), 10 deletions(-) create mode 100644 tests/engine/native_option_seam_ownership.lua diff --git a/src/mods/Events.lua b/src/mods/Events.lua index 071173eea..e390f7c61 100644 --- a/src/mods/Events.lua +++ b/src/mods/Events.lua @@ -60,10 +60,22 @@ function Events:emit(name, payload) " mod " .. tostring(entry.owner or "?") .. ": " .. tostring(name)) local facade = package.loaded['src.core.Game3'] local ok, err + -- Native lifecycle/UI option changes are synchronous transactions. Capture only + -- the current bus's real subscriber; preserve coroutine callback ABI by + -- leaving non-main dispatch on its existing path. Frame events stay lean. + local lifecycle=name=='mods.loaded'or name=='game.ready' + local options=(lifecycle or name=='mod.options_changed'or name=='mod.option_action') + and Runtime.events==self and(lifecycle or entry.owner~=nil and entry.owner~='base') + if options then local co,main=coroutine.running();options=not co or main end + local seams=options and package.loaded['src.mods.ModSeams'] + local seamOwner=entry.owner + if seams and(entry.owner==nil or entry.owner=='base')then seamOwner='lifecycle:'..name end if facade and facade._usesCallbacks and facade._usesCallbacks(entry.owner) then - ok, err = pcall(facade._invokeEvent, entry.owner, name, entry.callback, payload) + if seams then ok,err=seams.run(seamOwner,facade._invokeEvent,entry.owner,name,entry.callback,payload) + else ok,err=pcall(facade._invokeEvent,entry.owner,name,entry.callback,payload)end else - ok, err = pcall(entry.callback, payload) + if seams then ok,err=seams.run(seamOwner,entry.callback,payload) + else ok,err=pcall(entry.callback,payload)end end close() if not ok then diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index ba46218d0..1ebf26313 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1504,6 +1504,10 @@ function Loader:load(data) local LIFECYCLE = { ["mods.loaded"] = true, ["game.ready"] = true } events.emit = function(bus, name, payload) if not LIFECYCLE[name] then return emit0(bus, name, payload) end + -- The native bus attributes synchronous subscribers individually. Keep + -- the legacy aggregate for coroutine callers without changing their ABI. + local co,main=coroutine.running() + if not co or main then return emit0(bus,name,payload)end local token = ModSeams.open("lifecycle:" .. tostring(name)) local ok, err = pcall(emit0, bus, name, payload) ModSeams.close(token) diff --git a/src/mods/ModSeams.lua b/src/mods/ModSeams.lua index f4d06a01c..db9429dda 100644 --- a/src/mods/ModSeams.lua +++ b/src/mods/ModSeams.lua @@ -39,11 +39,15 @@ -- left as it is -- the next load sets it again, and guessing that a number is -- mod residue rather than engine state is how a teardown corrupts a game. -- --- WHAT IS NOT SEEN: anything a mod changes later, during play (a wrap made on --- the first battle), and fields below the top level of a table. Those are --- still the mod's to guard; this takes away the common case, not the need. +-- WHAT IS NOT SEEN: arbitrary later changes during play (a wrap made on +-- the first battle), or fields below the top level of a table. Synchronous +-- native option/action callbacks are captured by the current Events bus. +-- Other later changes remain the mod's to guard. local ModSeams = {} +-- Mutation journals must remain operable while an entry has temporarily +-- replaced global iteration (including a wrapper that throws). +local pairs,next,rawget,rawset=pairs,next,rawget,rawset -- every change, oldest first: { tbl, key, before, after, owner } local journal = {} @@ -105,17 +109,40 @@ end -- open a window. Returns a token for close(). Never throws. function ModSeams.open(owner) local ok, token = pcall(function() - local snap = {} + local snap, seen = {}, {} for _, entry in ipairs(watched()) do snap[#snap + 1] = { t = entry.t, label = entry.label, before = copyOf(entry.t) } + seen[entry.t] = true end -- an engine module required for the FIRST time inside this window is -- snapshotted the moment it loads, before the mod gets it back to wrap - local fresh = {} + local state={active=true,snap=snap,seen=seen,fresh={},owner=owner} local real = rawget(_G, "require") local function require2(name, ...) + if not state.active then return real(name,...)end + local snap,seen,fresh,owner=state.snap,state.seen,state.fresh,state.owner local had = package.loaded[name] local result = real(name, ...) + if not state.active then return result end + if name=='src.core.game3.field_move_show_mon'and type(result)=='table'and result._observeMod then + result._observeMod(owner) + end + if name=='src.world.game3.Follower'or name=='src.compat.game3.Follower'then + local facade=package.loaded['src.core.Game3']or real('src.core.Game3') + if facade and facade._observeActorMod then facade._observeActorMod(owner)end + end + -- Importing the distinct GBA game/runtime namespace declares that + -- this owner's game-bearing callbacks use that facade. No manifest + -- rewrite or mod-id-specific conversion is needed. + if name == 'src.core.Game3' or name == 'src.core.game3.runtime' or name == 'src.core.game3.battle' or name == 'src.core.game3.battle_bridge' + or type(name)=='string' and name:match('^src%.core%.game3%.battle%.') then + local facade = package.loaded['src.core.Game3'] or real('src.core.Game3') + if facade and facade._observeMod then facade._observeMod(owner) end + if type(facade)=='table' and not seen[facade] and not fresh[facade] then + fresh[facade]=true + snap[#snap+1]={t=facade,label='src.core.Game3',before=copyOf(facade)} + end + end if had == nil and type(name) == "string" and name:match("^src%.") and type(result) == "table" and not fresh[result] then fresh[result] = true @@ -124,7 +151,7 @@ function ModSeams.open(owner) return result end rawset(_G, "require", require2) - return { owner = owner, snap = snap, require2 = require2, real = real } + return { owner = owner, snap = snap, require2 = require2, real = real, state=state } end) return ok and token or nil end @@ -133,6 +160,10 @@ end -- changes recorded. function ModSeams.close(token) if not token then return 0 end + if token.state then + if not token.state.active then return 0 end + token.state.active=false + end local ok, count = pcall(function() if rawget(_G, "require") == token.require2 then rawset(_G, "require", token.real) @@ -163,15 +194,17 @@ function ModSeams.close(token) end return n end) + if token.state then token.state.snap,token.state.seen,token.state.fresh,token.state.owner=nil,nil,nil,nil end return ok and count or 0 end -- run fn(...) inside a window for owner; returns pcall-style results function ModSeams.run(owner, fn, ...) local token = ModSeams.open(owner) - local results = { pcall(fn, ...) } + local function pack(...)return {n=select("#",...),...}end + local results = pack(pcall(fn, ...)) ModSeams.close(token) - return unpack(results) + return unpack(results,1,results.n) end local function unwind(filter) diff --git a/tests/engine/native_option_seam_ownership.lua b/tests/engine/native_option_seam_ownership.lua new file mode 100644 index 000000000..e5f017cfd --- /dev/null +++ b/tests/engine/native_option_seam_ownership.lua @@ -0,0 +1,148 @@ +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 Seams=require('src.mods.ModSeams') +local B=require('src.core.game3.battle') +local E=require('src.core.game3.battle.engine') +local G=require('src.core.Game3') +local Call=require('src.compat.gen1.SourceCall') +local Manager=require('src.mods.ManagerState') +local Json=require('src.link.Json') +local originalB,originalE,originalDraw=B.update,E.resolveMove,B.draw +local body=[[ + local mod=...;local e=mod.exports + mod.options:define{{key='enabled',type='toggle',default=false},{key='fault',type='action',label='FAULT'}} + local function install() + local B=require('src.core.game3.battle');local E=require('src.core.game3.battle.engine') + if e.update then return end + local oldB,oldE=B.update,E.resolveMove + e.oldB,e.oldE=oldB,oldE + e.update=function(...)e.calls=(e.calls or 0)+1;return oldB(...)end + e.resolve=function(...)return oldE(...)end + B.update,E.resolveMove=e.update,e.resolve + end + mod.events:on('mod.options_changed',function(p) + if p.mod~=mod.id or p.key~='enabled'then return end + e.thread=coroutine.running() + if p.value then install() + else + local B=require('src.core.game3.battle');local E=require('src.core.game3.battle.engine') + if B.update==e.update then B.update=e.oldB end + if E.resolveMove==e.resolve then E.resolveMove=e.oldE end + e.update,e.resolve=nil,nil + end + end) + mod.events:on('mod.option_action',function(p) + if p.mod~=mod.id or p.key~='fault'then return end + local B=require('src.core.game3.battle');local previous=B.draw + e.draw=function(...)return previous(...)end;B.draw=e.draw + error('owned option failure') + end) +]] +for _,edition in ipairs{'firered','leafgreen','ruby','sapphire','emerald','crystal'}do + Version.set(edition) + local api=edition=='crystal'and 2 or 3 + local files={['mods/option_probe/manifest.json']=Json.encode{id='option_probe',name='Option probe',version='1.0.0',api=api,entry='main.lua'},['mods/option_probe/main.lua']=body} + local run=T.sdk.loadMods({'mods/option_probe'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#run.errors,0) + local e=assert(run.loader.exports.option_probe) + local writes=0 + local game={mods=run.loader,save={options={}},writeOptions=function()writes=writes+1 end} + local manager=Manager.new(game) + local before=Seams.summary().option_probe or 0 + T.eq(B.update,originalB,'entry itself installs no battle wrapper') + manager:setOption('option_probe','enabled',true) + T.eq(e.thread,coroutine.running(),'synchronous native option callbacks keep the caller coroutine') + T.eq(B.update,e.update);T.eq(E.resolveMove,e.resolve) + T.eq(Call.owner(B,'update'),'option_probe','late native update wrapper has its actual SDK owner') + T.eq(Call.owner(E,'resolveMove'),'option_probe','late resolver wrapper has its actual SDK owner') + T.eq(writes,1,'the real manager producer invokes only the owned persistence seam') + if api==3 then T.eq(G._usesSourceRecords('option_probe'),true,'late namespace imports declare the source ABI')end + manager:setOption('option_probe','enabled',false) + T.eq(B.update,originalB);T.eq(E.resolveMove,originalE) + manager:setOption('option_probe','enabled',true) + local rows=manager:buildOptionRows({id='option_probe'},{{key='fault',type='action',label='FAULT'}}) + local action + for _,row in ipairs(rows)do if row.id=='fault'then action=row end end + T.check(action and type(action.activate)=='function') + T.eq(action.activate(),true,'real native action row contains callback failures') + T.eq(B.draw,e.draw);T.eq(#run.errors,1,'existing diagnostics retain the option failure') + Seams.unwindOwner('option_probe') + T.eq(B.draw,originalDraw,'a partial option action remains attributable for cleanup') + T.eq(B.update,originalB);T.eq(E.resolveMove,originalE) + -- A later foreign replacement cannot be torn off by this owner's unwind. + manager:setOption('option_probe','enabled',false) + manager:setOption('option_probe','enabled',true) + local own=e.update;local foreign=function(...)return own(...)end;B.update=foreign + Seams.unwindOwner('option_probe') + T.eq(B.update,foreign,'owner retirement preserves a foreign later replacement') + -- Both functions here belong only to this owned fixture. + B.update,E.resolveMove=originalB,originalE + run.release() +end +-- Native load-time events also retain the real subscriber, rather than a +-- shared lifecycle label that would mask a later installed battle controller. +Version.set('emerald') +do + local run=T.sdk.loadMods({'mods/init_probe'},{data=T.fixtures.fresh(),fs=T.sdk.memfs({ + ['mods/init_probe/manifest.json']=Json.encode{id='init_probe',name='Init probe',version='1.0.0',api=3,entry='main.lua'}, + ['mods/init_probe/main.lua']=[[ + local mod=...;local e=mod.exports + mod.events:on('mods.loaded',function() + local B=require('src.core.game3.battle');local old=B.update + e.update=function(...)return old(...)end;B.update=e.update + end) + mod.events:on('game.ready',function() + local E=require('src.core.game3.battle.engine');local old=E.resolveMove + e.resolve=function(...)return old(...)end;E.resolveMove=e.resolve + e.thread=coroutine.running() + end) + ]], + })}) + T.eq(#run.errors,0) + T.eq(Call.owner(B,'update'),'init_probe','real Loader mods.loaded producer preserves subscriber ownership') + run.loader.events:emit('game.ready',{}) + T.eq(Call.owner(E,'resolveMove'),'init_probe','game.ready subscriber owns its actual late resolver') + T.eq(run.loader.exports.init_probe.thread,coroutine.running()) + Seams.unwindOwner('init_probe');T.eq(B.update,originalB);T.eq(E.resolveMove,originalE) + run.release() +end +-- A retained require has no authority after its snapshot window closes, and +-- must not retain the engine snapshot it originally captured. +Version.set('emerald') +local marker={};local weak=setmetatable({marker},{__mode='v'}) +local module={anchor=marker};package.loaded['src.qa.OptionWindowProbe']=module +local token=Seams.open('expired-window');local retained=require +module.anchor=nil;marker=nil +Seams.close(token);T.eq(Seams.close(token),0,'window close is idempotent');token=nil +for _=1,5 do collectgarbage('collect')end +T.eq(weak[1],nil,'retained require does not root closed native snapshot bytes') +G._retireMod('expired-window');retained('src.core.game3.battle.engine') +T.eq(G._usesSourceRecords('expired-window'),false,'closed require cannot re-admit a retired owner') +package.loaded['src.qa.OptionWindowProbe']=nil +local closing +local closeName='src.core.game3.battle.qa_close_during_require' +package.preload[closeName]=function()Seams.close(closing);return{}end +G._retireMod('close-in-require');closing=Seams.open('close-in-require') +local result=require(closeName) +T.eq(type(result),'table');T.eq(G._usesSourceRecords('close-in-require'),false,'require completion cannot re-admit a window closed during module loading') +package.preload[closeName],package.loaded[closeName]=nil,nil +local function pack(...)return{n=select('#',...),...}end +local r=pack(Seams.run('tuple',function()return nil,7,nil end)) +T.eq(r.n,4);T.eq(r[1],true);T.eq(r[2],nil);T.eq(r[3],7);T.eq(r[4],nil) +-- Frame buses and coroutine callbacks keep their original paths and ABI. +local Events=require('src.mods.Events');local bus=Events.new();local errors={} +Runtime.install(bus,Runtime.hooks,errors) +local opens=0;local open=Seams.open +Seams.open=function(...)opens=opens+1;return open(...)end +local same +bus:on('world.tick',function()end,0,'plain') +for _=1,20 do bus:emit('world.tick',{})end +T.eq(opens,0,'frame events never open a module snapshot') +bus:on('mod.option_action',function()same=coroutine.running()end,0,'plain') +local co=coroutine.create(function()bus:emit('mod.option_action',{})end) +T.eq(coroutine.resume(co),true);T.eq(same,co);T.eq(opens,0,'non-main callbacks retain their existing coroutine ABI') +Seams.open=open +Seams.unwindOwner('tuple');G.release() +T.finish('Native option producer ownership, late namespace imports, partial failure cleanup and closed require lifetime') From 5987b723fe55418f1d0802b7015996c7ef7ea5c6 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 09:43:14 +0100 Subject: [PATCH 37/75] Report attributed late-callback errors through the existing native diagnostics feed --- src/mods/Events.lua | 15 ++++++- tests/engine/event_runtime_errors.lua | 64 +++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 tests/engine/event_runtime_errors.lua diff --git a/src/mods/Events.lua b/src/mods/Events.lua index e390f7c61..95f3d4e04 100644 --- a/src/mods/Events.lua +++ b/src/mods/Events.lua @@ -1,5 +1,6 @@ local Logger = require("src.core.Logger") local FrameProfile = require("src.core.FrameProfile") +local Runtime = require("src.mods.Runtime") local Events = {} Events.__index = Events @@ -79,8 +80,18 @@ function Events:emit(name, payload) end close() if not ok then - Logger.error("[%s] event %s: %s", - tostring(entry.owner or "?"), name, tostring(err)) + local message=tostring(err) + local feed=Runtime.events==self and Runtime.errors + local reportable=feed and entry.owner~=nil and entry.owner~='base' + if not reportable or entry._failureFeed~=feed or entry._failureMessage~=message then + Logger.error("[%s] event %s: %s",tostring(entry.owner or "?"),name,message) + if reportable then + Runtime.reportError(entry.owner,'event '..name..': '..message) + entry._failureFeed,entry._failureMessage=feed,message + end + end + else + entry._failureFeed,entry._failureMessage=nil,nil end end end diff --git a/tests/engine/event_runtime_errors.lua b/tests/engine/event_runtime_errors.lua new file mode 100644 index 000000000..d88d46e43 --- /dev/null +++ b/tests/engine/event_runtime_errors.lua @@ -0,0 +1,64 @@ +-- Failures during game.ready/update must reach the real loader diagnostics. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Runtime = require("src.mods.Runtime") +local Events = require("src.mods.Events") +local run = T.sdk.loadMods({ "mods/event_probe" }, { + data = T.fixtures.fresh(), + fs = T.sdk.memfs({ + ["mods/event_probe/manifest.json"] = [[{ + "id":"event_probe", "name":"Event probe", "version":"1.0.0", + "api":2, "entry":"main.lua", "affects_link":false + }]], + ["mods/event_probe/main.lua"] = [[ + local mod = ... + mod.exports.message = "first callback fault" + mod.exports.fail = true + mod.exports.healthy = 0 + mod.events:on("game.ready", function() + if mod.exports.fail then error(mod.exports.message) end + end, 10) + mod.events:on("game.ready", function() + mod.exports.healthy = mod.exports.healthy + 1 + end, 0) + ]], + }), +}) +T.eq(#run.errors, 0, "entry succeeds before the lifecycle callback runs") +local exported = assert(run.loader.exports.event_probe) +local events = run.loader.events +T.check(Runtime.events == events, "probe uses the installed native bus") +T.check(pcall(Runtime.emit, "game.ready", { game = {} }), "callback error does not abort native startup") +T.eq(exported.healthy, 1, "remaining callbacks still execute") +T.eq(#run.loader:status().errors, 1, "actual loader diagnostics receive lifecycle failure") +local first = run.errors[1] or "" +T.check(first:find("event_probe: event game.ready:", 1, true) ~= nil, "diagnostics retain owner and event") +T.check(first:find("first callback fault", 1, true) ~= nil, "diagnostics retain the cause") +for _ = 1, 100 do Runtime.emit("game.ready", { game = {} }) end +T.eq(#run.errors, 1, "persistent frame/lifecycle failure cannot grow the feed every frame") +T.eq(exported.healthy, 101, "deduplication does not suppress healthy callbacks") +exported.message = "second callback fault" +Runtime.emit("game.ready", { game = {} }) +T.eq(#run.errors, 2, "a different cause is reported") +exported.fail = false +Runtime.emit("game.ready", { game = {} }) +exported.fail = true +Runtime.emit("game.ready", { game = {} }) +T.eq(#run.errors, 3, "recurrence after recovery is a new error episode") +local oldBus = events +local nextBus, nextErrors = Events.new(), {} +Runtime.install(nextBus, Runtime.hooks, nextErrors) +oldBus:emit("game.ready", { game = {} }) +T.eq(#nextErrors, 0, "retired bus cannot write errors into the next session") +T.eq(#run.errors, 3, "retired bus does not append to the old live diagnostics") +local after = 0 +nextBus:on("update", function() error("current session fault") end, 0, "next_probe") +nextBus:on("update", function() after = after + 1 end, -1, "next_probe") +T.check(pcall(Runtime.emit, "update", {}), "new session keeps normal event dispatch") +T.eq(#nextErrors, 1, "current bus owns its new diagnostics") +T.eq(after, 1, "current healthy callback runs after a failure") +nextBus:removeOwner("next_probe") +Runtime.emit("update", {}) +T.eq(#nextErrors, 1, "unsubscribed callback cannot add errors") +run.release() +T.finish("native event failures in loader diagnostics") From bc0aadc1010f9113898a30537dcf67da192313e0 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 09:52:42 +0100 Subject: [PATCH 38/75] Qualify subscriber-owned option and lifecycle wrappers on both native bases --- .../option-subscriber-ownership.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/qa/native-foundation/option-subscriber-ownership.md diff --git a/docs/qa/native-foundation/option-subscriber-ownership.md b/docs/qa/native-foundation/option-subscriber-ownership.md new file mode 100644 index 000000000..59276a82d --- /dev/null +++ b/docs/qa/native-foundation/option-subscriber-ownership.md @@ -0,0 +1,39 @@ +# Native lifecycle and option subscriber ownership + +Wrappers installed by a native option toggle or action must belong to the +subscriber that installed them, so the existing source-call attribution and +owner teardown can find them. Synchronous `mods.loaded`, `game.ready`, +`mod.options_changed` and `mod.option_action` callbacks on the current session's +bus now use the existing shallow mutation journal with their actual owner. +The old aggregate lifecycle journal remains for coroutine callers. Callback +coroutine identity, event ordering, error containment and the native frame-event +paths are retained. Unknown/foreign later replacements remain untouched by an +owner's unwind. This does not capture arbitrary per-frame module mutations. + +A retained require closure loses its snapshot references and namespace admission +authority when the window closes, including a close during module loading. +Close is idempotent. Journal execution preserves trailing nil return values. +No private controller body, manifest rewrite or alternate gameplay loop is used. + +Qualification on fork main `73b2c0f8`: + +- Frozen source `18e8a8ee37`: 168 selected CPU groups pass, including 133 new + checks through actual SDK loading, the native options manager, action rows, + five GBA editions and Crystal. Focused run `4a7aafe473`: 10 groups pass. +- The identical 133-check fixture on unmodified native predecessor `83836402`, + run `90b90e0413`, reproduces 44 failures. Native controllers were not overlaid. +- Older-public component `5987b723`, composition `5ab6f3e79f`: 64 groups pass, + including the same 133 checks and 17 native diagnostic checks. It retains the + older classic event walk, reuses the already offered namespace-journal + prerequisite, and adapts attributed errors to its existing Runtime feed. + The Loader change is a checked four-line hunk over the previously qualified + older composition, not a replacement with the newer Loader. +- The fork's 10,000 single-listener event probe remains at 0 KB allocations on + its lean path with JIT off. This is a frame-event allocation check, not FPS + or older-public lean-path acceptance. + +These are local CPU qualifications; hosted CI and unchanged-mod GPU acceptance +of this packet remain separate. The earlier Ruby scene qualification predates +these event changes. Typed contact continuation and Action Card remain pending. +All runs use owned QA profiles/copies and a 20 GiB free-space floor, preserving +the requested 8 GiB reserve. No player profiles or private mod files are edited. From a65fbb8888eb1239554eaf0b51ed62dc8791cac1 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 10:02:38 +0100 Subject: [PATCH 39/75] Scope source choice policy to actual native move invocations --- src/compat/gen1/BattleView.lua | 11 ++- src/core/game3/battle/engine.lua | 39 ++++++++- tests/engine/native_choice_policy_scope.lua | 97 +++++++++++++++++++++ 3 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 tests/engine/native_choice_policy_scope.lua diff --git a/src/compat/gen1/BattleView.lua b/src/compat/gen1/BattleView.lua index 62870cda3..352ac2793 100644 --- a/src/compat/gen1/BattleView.lua +++ b/src/compat/gen1/BattleView.lua @@ -25,7 +25,7 @@ local monKeys={'species','nickname','level','hp','maxHp','attack','defense','spe local battlerKeys={'id','side','flank','mon','species','type1','type2','ability','item','status','fainted','stages','partyIndex','expLockedMove','expEncoreMove'} local stateKeys={'player','enemy','battlers','party','playerParty','enemyParty','turn','wild','double','over','result', 'weather','weatherTurns','session','playerIndex','enemyIndex','trainerClass','trainerName','trainerPicId', - 'link','safari','tutorial','oldManTutorial','pokedude','firstBattle','ghostBattle','kinds','rng'} + 'link','safari','tutorial','oldManTutorial','pokedude','firstBattle','ghostBattle','kinds','rng','interactiveChoices'} local reserved={} for _,key in ipairs(stateKeys)do reserved[key]=true end function M.new(game,screen,valid,identifiers) @@ -71,6 +71,10 @@ function M.new(game,screen,valid,identifiers) mon.status=value>0 and(value<=7 and'SLP'or inverse[value])or nil mon.sleepTurns=value>0 and value<=7 and value or nil;mon.toxic=value==128 or nil end + elseif kind=='state'and key=='interactiveChoices'then + local engine=package.loaded['src.core.game3.battle.engine'] + assert(engine and engine._setInteractiveChoices,'native move choice policy is unavailable') + engine._setInteractiveChoices(e.context.screen,t,value) elseif kind=='state'and not reserved[key]then -- Presentation extensions belong to this view; they cannot replace the -- controller's command queue, party aliases or completion result. @@ -198,6 +202,11 @@ function M.new(game,screen,valid,identifiers) e.kinds=e.kinds or{};e.kinds.tutorial=raw.pokedudeDemo and'pokedude'or(raw.demo and'oldMan'or nil);return e.kinds end if key=='rng'then return e.rng end + if key=='interactiveChoices'then + local engine=package.loaded['src.core.game3.battle.engine'] + if engine and engine._interactiveChoices then return engine._interactiveChoices(raw,e.view)end + return raw.interactiveChoices + end end end e.read=read diff --git a/src/core/game3/battle/engine.lua b/src/core/game3/battle/engine.lua index 3d24eba4f..dde7da70e 100644 --- a/src/core/game3/battle/engine.lua +++ b/src/core/game3/battle/engine.lua @@ -9,6 +9,7 @@ local Version=require('src.core.GameVersion') local Ids=require('src.compat.gen1.BattleIds') local scopes=CoroutineScopes.new() local M={} +local MAIN_CHOICES={} local unpack=table.unpack or unpack local function pack(...)return {n=select('#',...),...}end -- Newer native owners bind action records to a learned PP slot; older @@ -18,7 +19,7 @@ local function ppOwner(move) local resolver=choices and choices.slot return resolver and resolver(move)or move,choices,resolver end -local function live(lease) +local function cohort(lease) assert(lease and not lease.closed and not lease.frame.closed and not CoroutineScopes.retired(), 'native move resolution retired') local game,screen=lease.game,lease.screen @@ -28,6 +29,11 @@ local function live(lease) and game.stack:top()==screen and screen.queue==lease.queue and not screen.result and (type(game.mods)=='table'and game.mods.content or nil)==lease.content and lease.ids:valid(),'native move resolution cohort retired') + return lease +end +local function live(lease) + cohort(lease) + local screen=lease.screen assert(lease.user.mon==lease.userMon and lease.target.mon==lease.targetMon and lease.user.position==lease.userPosition and lease.target.position==lease.targetPosition and screen:battlerAt(lease.userPosition)==lease.user @@ -43,6 +49,32 @@ local function live(lease) 'native move resolution PP owner retired') return lease end +-- Source choice policy belongs only to this actual native invocation. It +-- never writes the persistent battle field or survives a suspended phase. +function M._interactiveChoices(screen,state) + local frame=scopes:find('move');local lease=frame and frame.value + if lease and lease.screen==screen and(not state or state==lease.state)then + cohort(lease) + local choice=scopes:find('choices') + if choice and choice.value.lease==lease then return choice.value.value end + end + return screen.interactiveChoices +end +function M._setInteractiveChoices(screen,state,value) + assert(value==nil or type(value)=='boolean','native interactive choices must be boolean or nil') + local frame=scopes:find('move');local lease=frame and frame.value + assert(lease and lease.screen==screen and lease.state==state,'interactive choices belong to their native move invocation') + cohort(lease) + local co,main=coroutine.running();co=main and MAIN_CHOICES or co or MAIN_CHOICES + lease.choiceFrames=lease.choiceFrames or{} + local choice=lease.choiceFrames[co] + if choice then choice.value.value=value + else + choice=scopes:push('choices',{lease=lease,value=value}) + lease.choiceFrames[co]=choice + if choice.creator==lease.frame.creator then lease.ownChoice=choice end + end +end -- Read the same ownership decision used by the native summary, from a -- resident of this exact admitted battle. Queries never flush staged PP. function M.isTradedMon(state,mon) @@ -91,7 +123,10 @@ function M._native(screen,user,target,move,isCalled,suppressAnimation,spreadCont end) end)) local retired=frame.closed or CoroutineScopes.retired() - lease.closed=true;scopes:close(frame) + -- A same-thread policy frame is a normal nested scope. Close it first + -- so the registry restores its local head before retiring borrowed children. + if lease.ownChoice then scopes:close(lease.ownChoice)end + lease.closed=true;scopes:close(frame);lease.choiceFrames,lease.ownChoice=nil,nil if not result[1]then error(result[2],0)end if retired then error('native move resolution retired while suspended',0)end return unpack(result,2,result.n) diff --git a/tests/engine/native_choice_policy_scope.lua b/tests/engine/native_choice_policy_scope.lua new file mode 100644 index 000000000..3c2425fa4 --- /dev/null +++ b/tests/engine/native_choice_policy_scope.lua @@ -0,0 +1,97 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Probe=dofile('tests/native_action_plan/turn.lua') +local B=require('src.core.game3.battle') +local E=require('src.core.game3.battle.engine') +local base=E.resolveMove +local function open(edition)local f,b=Probe.open(edition);b:syncSides();return f,b end +local function native(b,fn) + return E._native(b,b.player,b.enemy,b.player.mon.moves[1],false,false,false,fn) +end +for _,edition in ipairs{'firered','leafgreen','ruby','sapphire','emerald'}do + local f,b=open(edition);local st=B.getState() + T.eq(st.interactiveChoices,nil) + T.raises(function()st.interactiveChoices=false end,'native move invocation') + T.eq(rawget(st,'interactiveChoices'),nil,'choice policy cannot become a persistent view extension') + b.interactiveChoices=true + T.eq(st.interactiveChoices,true,'default reads the native owner') + E.resolveMove=function(...) + local args={...};local prior=st.interactiveChoices + st.interactiveChoices=false + T.eq(st.interactiveChoices,false);T.eq(b.interactiveChoices,true) + T.eq(require('src.compat.gen1.TableIteration').snapshot(st).interactiveChoices,false) + T.raises(function()st.interactiveChoices='off'end,'boolean or nil') + local result=base(unpack(args,1,7)) + st.interactiveChoices=prior + return result + end + local calls=0 + native(b,function()calls=calls+1;T.eq(E._interactiveChoices(b),false);return 7 end) + T.eq(calls,1);T.eq(st.interactiveChoices,true);T.eq(b.interactiveChoices,true) + E.resolveMove=base + T.raises(function()st.interactiveChoices=false end,'native move invocation') + -- An exception does not leak a false override into the next actual move. + E.resolveMove=function(...)st.interactiveChoices=false;error('owned choice failure')end + T.raises(function()native(b,function()error('unreachable native body')end)end,'owned choice failure') + E.resolveMove=base + native(b,function()T.eq(E._interactiveChoices(b),true)end) + T.eq(st.interactiveChoices,true) + Probe.close(f) +end +-- Default and pass-through decoration preserve all five complete native turns. +for _,edition in ipairs{'firered','leafgreen','ruby','sapphire','emerald'}do + E.resolveMove=base + local before=Probe.turn(edition,true,function(_,b)b:syncSides()end) + E.resolveMove=function(u,t,m,slot,ad,st,out) + local prior=st.interactiveChoices;st.interactiveChoices=false + local result=base(u,t,m,slot,ad,st,out) + st.interactiveChoices=prior;return result + end + local after=Probe.turn(edition,true,function(_,b)b:syncSides()end) + T.same(after,before,edition..' no-op choice scope keeps every native HP/PP/RNG/frame/phase') +end +E.resolveMove=base +-- A child may borrow the active invocation, but a suspended child lends no +-- choice policy to the main body or a sibling, and cannot use a later phase. +do + local f,b=open('ruby');b.interactiveChoices=true;local st=B.getState();local child + E.resolveMove=function(...) + child=coroutine.create(function() + st.interactiveChoices=false;T.eq(E._interactiveChoices(b),false) + local grand=coroutine.create(function()T.eq(E._interactiveChoices(b),false)end) + T.eq(coroutine.resume(grand),true) + coroutine.yield('choice pause') + st.interactiveChoices=false + end) + T.eq(coroutine.resume(child),true) + T.eq(st.interactiveChoices,true,'suspended sibling cannot lend its choice policy') + local sibling=coroutine.create(function()T.eq(E._interactiveChoices(b),true)end) + T.eq(coroutine.resume(sibling),true) + return base(...) + end + native(b,function()T.eq(E._interactiveChoices(b),true)end) + E.resolveMove=base + native(b,function() + local ok,why=coroutine.resume(child);T.eq(ok,false);T.eq(type(why),'string') + T.eq(E._interactiveChoices(b),true,'retired child cannot change a fresh invocation') + end) + Probe.close(f) +end +-- Nested real invocations do not inherit a surrounding override implicitly. +do + local f,b=open('emerald');b.interactiveChoices=true;local st=B.getState();local depth=0 + E.resolveMove=function(...) + depth=depth+1 + if depth==1 then + st.interactiveChoices=false + native(b,function()T.eq(E._interactiveChoices(b),true,'nested move uses its own native default')end) + T.eq(st.interactiveChoices,false,'nested completion retains the outer dynamic policy') + end + local result=base(...);depth=depth-1;return result + end + native(b,function()T.eq(E._interactiveChoices(b),false)end) + E.resolveMove=base;T.eq(st.interactiveChoices,true) + Probe.close(f) +end +T.finish('Native invocation-local choice policy and coroutine visibility') From 642055376c6a4ee827ec09d46a463c99249e29df Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 10:06:27 +0100 Subject: [PATCH 40/75] Lend visible choice policies to called native moves without sibling leakage --- src/core/game3/battle/engine.lua | 5 ++++- tests/engine/native_choice_policy_scope.lua | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/core/game3/battle/engine.lua b/src/core/game3/battle/engine.lua index dde7da70e..bdfef09f3 100644 --- a/src/core/game3/battle/engine.lua +++ b/src/core/game3/battle/engine.lua @@ -56,7 +56,10 @@ function M._interactiveChoices(screen,state) if lease and lease.screen==screen and(not state or state==lease.state)then cohort(lease) local choice=scopes:find('choices') - if choice and choice.value.lease==lease then return choice.value.value end + if choice and choice.value.lease.screen==screen and choice.value.lease.state==lease.state then + cohort(choice.value.lease) + return choice.value.value + end end return screen.interactiveChoices end diff --git a/tests/engine/native_choice_policy_scope.lua b/tests/engine/native_choice_policy_scope.lua index 3c2425fa4..54ceefaf5 100644 --- a/tests/engine/native_choice_policy_scope.lua +++ b/tests/engine/native_choice_policy_scope.lua @@ -78,15 +78,18 @@ do end) Probe.close(f) end --- Nested real invocations do not inherit a surrounding override implicitly. +-- A called move borrows its live ancestor policy; its own override is local. do local f,b=open('emerald');b.interactiveChoices=true;local st=B.getState();local depth=0 E.resolveMove=function(...) depth=depth+1 if depth==1 then st.interactiveChoices=false - native(b,function()T.eq(E._interactiveChoices(b),true,'nested move uses its own native default')end) + native(b,function()T.eq(E._interactiveChoices(b),nil,'nested override can restore a nil native choice')end) T.eq(st.interactiveChoices,false,'nested completion retains the outer dynamic policy') + else + T.eq(st.interactiveChoices,false,'called move inherits its visible ancestor policy') + st.interactiveChoices=nil end local result=base(...);depth=depth-1;return result end From 31dfa145740b3b96521098b4bac10947dd1cee8f Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 10:15:03 +0100 Subject: [PATCH 41/75] Qualify typed choice scopes and record the older native consumer gap --- .../native-foundation/choice-policy-scope.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/qa/native-foundation/choice-policy-scope.md diff --git a/docs/qa/native-foundation/choice-policy-scope.md b/docs/qa/native-foundation/choice-policy-scope.md new file mode 100644 index 000000000..edbc6f9d1 --- /dev/null +++ b/docs/qa/native-foundation/choice-policy-scope.md @@ -0,0 +1,43 @@ +# Choice policy scoped to native move resolution + +The typed battle state's `interactiveChoices` observation reads the actual +native preference. Boolean/nil overrides are admitted only inside that exact +live native move invocation. They live in the existing coroutine scope registry, +never on the persistent battle or as a raw view extension. Called native moves +borrow their visible ancestor's policy; a nested override retires with its own +scope. A suspended child cannot lend its policy to the caller or a sibling, or +write it into a later invocation. Error paths restore the registry's local head. + +The fork's existing Gen3BatonPass owner consults this scoped policy at its current +native selection branch. Ordinary native preference, explicit native chooser, +link-request ownership, authored effect composition, switch/HP/item scheduling +and PP spending stay with their existing owners. No party-choice interpreter, +new coroutine runner or persistent preference is introduced. + +Qualification on fork main `73b2c0f8`: frozen `b5adb2acd0` passes175 selected CPU +groups, including the final production scope code, native Baton Pass UI/link/ +composition/packet checks and full five-edition HP/PP/every-RNG/frame/phase +no-op traces. The final focused fixture splits the original140-check combined +case into101 dynamic-scope checks plus65 native choice/called-move checks. +The added called-move cases retain all native effect records in their fixture; +earlier malformed partial-registry failures remain recorded. No production +change was made to bypass those fixture failures. + +Older-public component `64205537`, composition `d2ff81f538`, passes65 groups +including the same101 dynamic-scope checks. That older base does not contain the +fork's Gen3BatonPass owner. This public foundation therefore offers the typed +policy and native-owner query seam, without claiming that missing consumer's +selection behavior. Its complete native-owner integration remains separate +work; newer native controller bodies are not overlaid for qualification. +The native predecessor fails the new scope fixture (including missing policy +accessors and accepted persistent writes); the exact test hash is in its receipt. + +Unchanged OASC109 `bddb5181`, Ruby `a64d1f90d9`, earlier frozen `2b1dd03c76`: +trainer entry, paper/native/terrarium/native/paper, move swap/cancel, actual RAGE +PP20 to19 / enemyHP66 to58 / playerHP63 to52, dex retirement and clean teardown +pass with no loader errors. That GPU run predates the final called-move policy +inheritance adjustment. It qualifies the option ownership/render path, not +Action Card, contact timing, all terrain or stable FPS. Hosted CI is separate. + +QA uses only owned profiles/copies and the20GiB free floor. Player profiles and +private mod bytes remain untouched. Contact continuation is still pending. From 1a55943d525b40163917f84e77dfc5fe715342de Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 10:21:01 +0100 Subject: [PATCH 42/75] Project actual native send-out progress to public ball observations --- src/compat/gen1/NativeBallObservation.lua | 33 ++++++++++++ .../native_ball_flight_observations.lua | 53 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 src/compat/gen1/NativeBallObservation.lua create mode 100644 tests/engine/native_ball_flight_observations.lua diff --git a/src/compat/gen1/NativeBallObservation.lua b/src/compat/gen1/NativeBallObservation.lua new file mode 100644 index 000000000..f9308267c --- /dev/null +++ b/src/compat/gen1/NativeBallObservation.lua @@ -0,0 +1,33 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Shared scalar projection of the actual native ball, not another timeline. +local M={keys={'visible','x','y','frame','ballId','side','battler','rot','scale','alpha'}} +function M.read(screen,ball,key) + if key=='visible'then return not ball.done or ball.caught==true end + if key=='x'then return ball.x+(ball.shakeOffset or 0)end + if key=='y'then return ball.y end + if key=='frame'then return math.max(0,(ball.ballFrame or 1)-1)end + if key=='ballId'then return ball.ball end + if key=='battler'or key=='side'then + local id=1 + for i=0,3 do + local b=i==0 and screen.player or i==1 and screen.enemy or screen:battlerAt(i) + if b and ball.battler==b then id=i;break end + end + return key=='battler'and id or(id%2==0 and'player'or'enemy') + end + if key=='rot'then + -- Presentation consumers use the unwrapped send-out angle as their flight + -- coordinate. Project the actual native throw clock; never advance a second + -- animation or infer progress from a screen-space position. + if ball.sendOut=='player'and ball.phase=='throw'then + local duration=ball.send and ball.send.frames or 25 + if type(duration)=='number'and duration>0 and duration0 and 1 or view.rot/projectionTurn + T.check(progress>=previous,'the presentation driver advances during the closed-ball flight') + T.check(math.abs(progress-tick/25)<1e-10,'the presentation driver tracks the actual native throw fraction') + if tick>0 and tick<25 then T.check(view.rot>0,'a moving send-out cannot report a stationary angle')end + for _=1,4 do T.eq(view.rot,Observation.read(b,actual,'rot'));T.eq(view.scale,1)end + T.same(scalar(actual),before,'repeated presentation reads never advance native ball/mon/sound clocks') + T.same(scalar(actual),scalar(control),'native timeline is unchanged by source observation') + previous=progress + if tick<25 then actual:update();control:update()end + end + T.eq(view.rot,0,'native opening resets the presentation orientation') + T.eq(actual.phase,'release');T.eq(view.frame,2) + for _,send in ipairs{'opponent','capture'}do + local a=Ball.new(b.data,{sendOut=send=='opponent'and send or nil,battler=b.enemy}) + T.eq(Observation.read(b,a,'rot'),0,'opponent/capture do not borrow the player-send clock') + for _=1,5 do a:update();T.eq(Observation.read(b,a,'rot'),0)end + end + b.gen3Ball=nil;T.eq(view.visible,false,'retired ball observations cannot project a new flight') + Probe.close(f) +end +-- Imported timing still owns duration. A renderer's unwrapped angular range +-- follows that native duration even when a content record changes it. +for _,frames in ipairs{10,40}do + local data={constants={gen3BallAnim={timing={sendOut={frames=frames}},sounds={}}}} + local a=Ball.new(data,{sendOut='player'}) + for tick=1,frames-1 do + a:update();local fraction=Observation.read({},a,'rot')/projectionTurn + T.check(math.abs(fraction-tick/frames)<1e-10,'native imported duration owns the source flight fraction') + end +end +T.finish('Actual native send-out progress, source angular projection, unchanged clocks and ball retirement') From 9d648fa955b2a550176f6f28a22f1dd8c808eb54 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 10:42:12 +0100 Subject: [PATCH 43/75] Record native ball projection qualification and rendered-flight gap --- .../ball-flight-observations.md | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 docs/qa/native-foundation/ball-flight-observations.md diff --git a/docs/qa/native-foundation/ball-flight-observations.md b/docs/qa/native-foundation/ball-flight-observations.md new file mode 100644 index 000000000..8b64108d6 --- /dev/null +++ b/docs/qa/native-foundation/ball-flight-observations.md @@ -0,0 +1,47 @@ +# Native ball flight observations + +A player send-out ball was always exposed with rotation zero, even while the +native clock advanced its throw. Existing presentation consumers use the +unwrapped throw angle to place the ball between the trainer's hand and its +landing point, and to transition from grip size to flight size. A constant +angle therefore pins that progress at zero until the cell changes to open. + +Expose the player send-out's actual `phaseFrame / send.frames` in the existing +unwrapped angular range. Opening resets the presentation angle; opponent and +capture observations keep their existing semantics. This is a read-only scalar +projection: native x/y, timing, sound, mon reveal, catch math and queue clocks +are not changed, and the native painter remains as before. It also follows +imported send-out durations instead of deriving progress from fitted pixels. + +Current-main frozen `89bf734b9d` passes176 CPU groups including1808 new checks +across five GBA editions and alternate imported durations. Repeated observations +leave the actual native timeline equal to an independently advanced native +control. The identical fixture on native predecessor `981b6f57`, run +`839d5839f5`, reproduces288 failures out of1808 checks. Older-public composition +`d54354c682` passes67 groups including the same1808 checks; its existing native +ball/stage/capture prerequisite is retained and only the scalar hunk is applied. +No newer native simulation/controller body is overlaid. A new fork workflow +covers the observation, stage/capture identity and native Gen2 scene regressions; +hosted CI remains separate from these local results. + +The newer fork main `c548cd16` was then merged as `ebcf5f48`. Frozen +`b2c647a315` passes179 groups, including the new canonical Gen2 species/runtime +and RS native ice/continue tests, plus the ball and preceding scope checks. + +The actual unchanged private pure consumers, pinned at `bddb5181`, were then +executed against the native clocks in isolated run `0ad22d0adf`:170 checks pass +across five editions. The old observation leaves the real consumer at the hand +and grip scale through frame24, jumping to the target at25. The corrected +observation moves at frame1 and smoothly transitions to flight scale. Only +unused factory dependency declarations were stubbed in that isolated math test; +it is not complete private controller or rendered-pixel acceptance. No private +consumer bodies are included in the offered code. + +Real FireRed scene `58f56b0181` completed without loader errors, but its extra +flight instrumentation observed no ball draws. The wrapper explicitly rejects +that run as flight acceptance. Earlier `ed2737a261` failed in QA instrumentation +before the native intro, and `e32e72bdf1` also lacked ball rows; their receipts +are retained. The genuine native send-out/render admission still needs a valid +flight capture. The earlier Ruby menu/terrarium smoke is not flight acceptance. +QA uses only owned profiles/copies with the20GiB free-space floor. Player +profiles and private mod files are untouched. From c3f62979db3d48480a9b652a132ddd50b49579a2 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 11:27:55 +0100 Subject: [PATCH 44/75] Decode the shared verified FRLG send-out profile and native tutorial hand --- src/battle/BattleState.lua | 2 + src/import/Gen3BallSendOutProfile.lua | 21 ++++ src/import/RomExtractorGen3.lua | 25 +++- src/import/RomImporter.lua | 4 + tests/engine/native_ball_sendout_import.lua | 124 ++++++++++++++++++++ 5 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 src/import/Gen3BallSendOutProfile.lua create mode 100644 tests/engine/native_ball_sendout_import.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 976dd9d4b..374c7c077 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -10294,6 +10294,8 @@ function BattleState:gen3SendOut(battler) sendOut = (battler == self.player) and "player" or "opponent", battler = battler, to = { x = cx, y = cy }, + from = self.pokedudeDemo and battler == self.player + and timing.sendOut.fromPokedude or nil, -- ...AND IF IT SPARKLES, IT SPARKLES HERE. TryShinyAnimation is called -- out of the send-out, once the mon is out of the ball, which is exactly -- what this animation owns -- so the shiny beat rides it rather than diff --git a/src/import/Gen3BallSendOutProfile.lua b/src/import/Gen3BallSendOutProfile.lua new file mode 100644 index 000000000..176ea3860 --- /dev/null +++ b/src/import/Gen3BallSendOutProfile.lua @@ -0,0 +1,21 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Shared FR/LG Thumb sites from cartridge-matching pret/pokefirered ELFs +-- (ce6406d8c2eaf46d543c8a2606de0c3cb1951982). The importer authenticates the +-- cartridge; the native operand reader still checks every instruction. +local M={REVISION=1} +local supported={ + ['41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc']=true, + ['574fa542ffebb14be69902d1d36f1ec0a4afd71e']=true, +} +local sites={AT_HAND_X=0x4aaca,AT_HAND_Y=0x4aacc, + AT_DEMO_X=0x4aab0,AT_DEMO_Y=0x4aab2,AT_FRAMES=0x4b688, + AT_TARGET_DY=0x4b6ae,AT_ARC=0x4b6b2,AT_FOE_DY=0x4ab12, + AT_FOE_WAIT=0x4b876,AT_FADE=0x4b2b6,AT_RISE=0x4b510, + MOV_R0=0x2000,MOV_R2=0x2200,MOV_HAND_X=0x2500,MOV_HAND_Y=0x2400, + ADD_R0=0x3000,CMP_R0=0x2800} +function M.forSha1(hash) + if not supported[hash]then return nil end + local copy={};for key,value in pairs(sites)do copy[key]=value end + return copy +end +return M diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index c2ce0b55e..4fac3ed2a 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -54760,6 +54760,12 @@ function RomExtractorGen3:extractBallAnims() -- than the grow-in the port already had, and the screen falls back to that -- when this is absent. local S = B.SEND + -- Explicit caller/manifest profiles keep precedence. Default FR/LG imports + -- share their verified code profile rather than reading Emerald's sites. + local overrides = self.manifest and self.manifest.stageOverrides + if not config and not (overrides and overrides.BALL_ANIM and overrides.BALL_ANIM.SEND) then + S = require("src.import.Gen3BallSendOutProfile").forSha1(self.manifest and self.manifest.romSha1) or S + end local timing = {} for k, v in pairs(B.TIMING) do timing[k] = v end do @@ -54771,7 +54777,10 @@ function RomExtractorGen3:extractBallAnims() local arc = self:thumbLiteral(S.AT_ARC) local rise = self:thumbLiteral(S.AT_RISE) local send = { - from = { x = imm(S.AT_HAND_X, S.MOV_R0), y = imm(S.AT_HAND_Y, S.MOV_R0) }, + -- FR/LG keeps its normal and POKeDUDE hand coordinates in r5/r4; + -- Emerald and RS use r0. Validate the selected register as well as site. + from = { x = imm(S.AT_HAND_X, S.MOV_HAND_X or S.MOV_R0), + y = imm(S.AT_HAND_Y, S.MOV_HAND_Y or S.MOV_R0) }, frames = imm(S.AT_FRAMES, S.MOV_R0), targetOffsetY = imm(S.AT_TARGET_DY, S.ADD_R0), foeOffsetY = imm(S.AT_FOE_DY, S.ADD_R0), @@ -54784,15 +54793,23 @@ function RomExtractorGen3:extractBallAnims() and (rise - 0x100000000) or rise) or nil, arc = arc and (arc >= 0x8000 and (arc - 0x10000) or arc) or nil, } - local ok = send.from.x and send.from.y and send.frames + if S.AT_DEMO_X or S.AT_DEMO_Y then + send.fromPokedude = { + x = S.AT_DEMO_X and imm(S.AT_DEMO_X, S.MOV_HAND_X or S.MOV_R0), + y = S.AT_DEMO_Y and imm(S.AT_DEMO_Y, S.MOV_HAND_Y or S.MOV_R0), + } + end + local demoValid = not send.fromPokedude + or send.fromPokedude.x ~= nil and send.fromPokedude.y ~= nil + local ok = demoValid and send.from.x and send.from.y and send.frames and send.targetOffsetY and send.foeOffsetY and send.fadeFrames and send.arc and send.riseStep and send.foeWait > 0 and send.targetOffsetY == send.foeOffsetY if ok then timing.sendOut = send else - Logger.warn("gen3 ball animation: the send-out numbers do not read " - .. "back off 0761B4/075D14 -- the screen keeps its " + Logger.warn("gen3 ball animation: the selected cartridge send-out " + .. "instructions did not validate -- the screen keeps its " .. "grow-in rather than throwing a ball to the wrong place") end end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index bbd959cfd..438d1fa34 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1323,6 +1323,10 @@ local function markerFor(version) if version == "red" or version == "blue" or version == "yellow" then revision = "gen1-trade-art-v1:" end + -- Rebuild generated FR/LG ball timing only; saved game formats do not change. + if GameVersion.isFRLG(version)then + revision=revision..'frlg-ball-sendout-v'..require('src.import.Gen3BallSendOutProfile').REVISION..':' + end return CACHE_FORMAT .. revision .. GameVersion.info(version).sha1 end diff --git a/tests/engine/native_ball_sendout_import.lua b/tests/engine/native_ball_sendout_import.lua new file mode 100644 index 000000000..7a966bf84 --- /dev/null +++ b/tests/engine/native_ball_sendout_import.lua @@ -0,0 +1,124 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local X=require('src.import.RomExtractorGen3') +local Profiles=require('src.import.Gen3BallSendOutProfile') +local Copy=require('src.mods.Merge').deepCopy +local Writer=require('src.import.ImageWriter');local blank=Writer.blank +-- Operand-only stage qualification: image composition is a separate GPU/art test. +Writer.blank=function()error('operand-only fixture has no image writer')end +X.applyAddressMap(nil) +local emerald=Copy(X.BALL_ANIM.SEND) +local hashes={firered='41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc',leafgreen='574fa542ffebb14be69902d1d36f1ec0a4afd71e'} +local function operandFixture(B) + local u16,u32,ptr,blobs={},{},{},{} + local function zeros(n)local out={};for i=1,n do out[i]=0 end;return out end + for i=0,B.COUNT-1 do + local gfx,pal=0x3000000+i*0x1000,0x4000000+i*0x1000 + ptr[B.SHEETS+i*B.STRIDE],ptr[B.PALETTES+i*B.STRIDE]=gfx,pal + u16[B.SHEETS+i*B.STRIDE+4]=B.SHEET_BYTES + u16[B.SHEETS+i*B.STRIDE+6],u16[B.PALETTES+i*B.STRIDE+4]=i,i + blobs[gfx],blobs[pal]=zeros(B.SHEET_BYTES),zeros(B.PALETTE_BYTES) + end + blobs[B.OPEN_FRAME]=zeros(B.OPEN_BYTES) + blobs[B.PARTICLES],blobs[B.PARTICLE_PALETTE]=zeros(B.PARTICLE_BYTES),zeros(B.PALETTE_BYTES) + local S=B.SEND;local fr=S.AT_DEMO_X~=nil + u16[S.AT_HAND_X]=(S.MOV_HAND_X or S.MOV_R0)+(fr and 48 or 24) + u16[S.AT_HAND_Y]=(S.MOV_HAND_Y or S.MOV_R0)+(fr and 70 or 68) + if fr then u16[S.AT_DEMO_X]=S.MOV_HAND_X+32;u16[S.AT_DEMO_Y]=S.MOV_HAND_Y+64 end + u16[S.AT_FRAMES]=S.MOV_R0+25;u16[S.AT_TARGET_DY]=S.ADD_R0+24 + u16[S.AT_FOE_DY]=S.ADD_R0+24;u16[S.AT_FOE_WAIT]=S.CMP_R0+15;u16[S.AT_FADE]=S.MOV_R2+14 + for _,pair in ipairs{{S.AT_ARC,65506},{S.AT_RISE,4294967008}}do + u16[pair[1]]=0x4801;local at=pair[1]+4;u32[at-at%4+4]=pair[2] + end + return {u16=function(_,at)return u16[at]or 0 end,u32=function(_,at)return u32[at]or 0 end, + u8=function()return 0 end,pointer=function(_,at)return ptr[at]end, + lz77=function(_,at)return blobs[at]end},u16 +end +local function extract(B,mutation) + local rom,words=operandFixture(B) + if mutation then mutation(words,B.SEND)end + local out + local x=setmetatable({rom=rom,_constants={},beginStage=function()end,write=function(_,name,value) + T.eq(name,'constants');out=value + end},X) + x:extractBallAnims(B);return out and out.gen3BallAnim +end +for _,edition in ipairs{'firered','leafgreen','emerald'}do + X.applyAddressMap(nil) + if edition~='emerald'then + local profile=Profiles.forSha1(hashes[edition]) + T.check(profile~=nil,'FR/LG must supply its own verified send-out instruction sites') + if profile then X.applyStageOverrides({BALL_ANIM={SEND=profile}})end + end + local B=Copy(X.BALL_ANIM);local record=extract(B);local s=record.timing.sendOut + T.check(s~=nil);assert(s) + T.eq(s.frames,25);T.eq(s.arc,-30);T.eq(s.foeWait,16);T.eq(s.fadeFrames,14);T.eq(s.riseStep,-288) + T.eq(s.targetOffsetY,24);T.eq(s.foeOffsetY,24) + if edition=='emerald'then T.eq(s.from.x,24);T.eq(s.from.y,68);T.eq(s.fromPokedude,nil) + else + T.eq(s.from.x,48);T.eq(s.from.y,70) + T.eq(s.fromPokedude and s.fromPokedude.x,32);T.eq(s.fromPokedude and s.fromPokedude.y,64) + end + if edition~='emerald'then + local Probe=dofile('tests/native_action_plan/turn.lua') + local Native=require('src.battle.Gen3Battle');local centre=Native.battlerCentre + -- The fixture supplies the image-derived centre; this checks native factory + -- hand selection and its existing clock/queue, not rendered geometry. + Native.battlerCentre=function()return 72,80 end + for _,demo in ipairs{false,true}do + local f,b=Probe.open(edition);b:syncSides();b.data.constants.gen3BallAnim=record + b.pokedudeDemo=demo or nil;b.showPlayerBack=false;b.queue={} + local accepted=b:gen3SendOut(b.player);T.eq(accepted,true) + if accepted then + T.eq(b.gen3Ball.from.x,demo and 32 or 48);T.eq(b.gen3Ball.from.y,demo and 64 or 70) + T.eq(b.gen3Ball.phase,'throw');T.eq(#b.queue,1,'native send-out owns one queue wait') + T.eq(b.queue[1].wait,b.gen3Ball:estimate(),'the existing native clock owns the queue duration') + end + b.pokedudeDemo=nil -- This fixture exercises hand selection, not demo-party restoration. + Probe.close(f) + end + Native.battlerCentre=centre + end + for _,key in ipairs{'AT_HAND_X','AT_HAND_Y','AT_FRAMES','AT_TARGET_DY','AT_FOE_DY','AT_FOE_WAIT','AT_FADE','AT_ARC','AT_RISE'}do + local bad=extract(B,function(words,sites)words[sites[key]]=0 end) + T.eq(bad.timing.sendOut,nil,'changed native instruction refuses '..key) + end + local mismatch=extract(B,function(words,sites)words[sites.AT_FOE_DY]=sites.ADD_R0+25 end) + T.eq(mismatch.timing.sendOut,nil,'inconsistent native target offsets refuse send-out') + if edition~='emerald'then + for _,key in ipairs{'AT_DEMO_X','AT_DEMO_Y'}do + T.eq(extract(B,function(words,sites)words[sites[key]]=0 end).timing.sendOut,nil,'malformed tutorial hand cannot be fabricated') + end + end +end +local detached=Profiles.forSha1(hashes.firered);detached.AT_HAND_X=0 +T.neq(Profiles.forSha1(hashes.leafgreen).AT_HAND_X,0,'a caller cannot mutate a shared cartridge profile') +T.eq(Profiles.forSha1('unverified'),nil,'unknown cartridges do not borrow FR/LG instruction sites') +X.applyAddressMap(nil);T.same(X.BALL_ANIM.SEND,emerald,'switching back restores Emerald instruction sites') +Writer.blank=blank +local Importer=require('src.import.RomImporter') +-- Exercise the marker decision already owned by the importer, including older +-- native owners which do not expose the fork's cacheContract query. +local marker +if Importer.cacheContract then marker=function(ed)return Importer.cacheContract(ed).marker end +else + local seen={} + local function find(fn,depth) + if seen[fn]or depth>3 then return end;seen[fn]=true + for i=1,100 do + local name,value=debug.getupvalue(fn,i);if not name then break end + if name=='markerFor'then return value end + if type(value)=='function'then local found=find(value,depth+1);if found then return found end end + end + end + for _,fn in pairs(Importer)do if type(fn)=='function'then marker=find(fn,0);if marker then break end end end + assert(marker,'native importer marker owner unavailable') +end +for _,edition in ipairs{'firered','leafgreen'}do + T.check(marker(edition):find('frlg%-ball%-sendout%-v1:')~=nil,'older incomplete FR/LG caches must rebuild generated timing') +end +for _,edition in ipairs{'red','crystal','emerald','ruby','sapphire'}do + T.eq(marker(edition):find('frlg%-ball%-sendout%-v1:'),nil,'unaffected edition keeps its existing cache marker') +end +T.finish('Native ball send-out operand profiles, tutorial hand, fail-closed reads, restoration and edition-scoped cache upgrade') From 13f17b06b1a9250d7b015f2625d8191ad9da7a05 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 11:38:09 +0100 Subject: [PATCH 45/75] Document native send-out import qualification and remaining duplicate painter --- .../native-foundation/ball-sendout-import.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/qa/native-foundation/ball-sendout-import.md diff --git a/docs/qa/native-foundation/ball-sendout-import.md b/docs/qa/native-foundation/ball-sendout-import.md new file mode 100644 index 000000000..e8d1dfc4a --- /dev/null +++ b/docs/qa/native-foundation/ball-sendout-import.md @@ -0,0 +1,44 @@ +# Cartridge-native ball send-out import + +FireRed and LeafGreen imported ball art but no send-out timing: the default +operand profile read Emerald instruction addresses. Real scene `a221bb6033` +confirmed both native send-out requests refused despite valid battler sprite +geometry. The scene completed, but its empty ball observations were explicitly +rejected as flight acceptance. + +Use one detached, SHA-1-selected FR/LG instruction profile, checked against +matching pret/pokefirered ELFs at ce6406d8c2eaf46d543c8a2606de0c3cb1951982. +The existing native decoder still validates each instruction and matching target +offsets. Explicit caller and manifest profiles keep precedence; unknown hashes +cannot borrow this profile. Read the actual normal hand (48,70) and tutorial +hand (32,64), selecting the latter only for the actual player tutorial send-out. +The existing ball clock, queue wait, catch math and native renderer stay intact. +Only generated FR/LG import caches receive a revision marker; save formats do +not change. No private controller implementation is included. + +Local validation: + +- `native_ball_sendout_import.lua`:142 checks, including opcode refusal, + detached profile isolation, tutorial selection and edition-scoped cache + upgrades. Synthetic image centres qualify factory/queue ownership, not art. +- Frozen current-main composition `1ce0584258`:180 CPU groups pass on main + `c548cd16`. Focused run `fabe63d246`:10 groups pass. +- Actual cartridge/ELF audit and native operand import `e4c340227f`:55 checks + pass across five GBA editions. FR/LG change from missing to valid send-out; + Emerald/Ruby/Sapphire before/after timing is identical. Image writing was + disabled in this operand-only test; it is not rendered-pixel acceptance. +- Older-public composition `e2cbe1b740`:68 groups pass, including142 new checks. + It retains the previously offered cartridge/art/stage prerequisites and old + native controller bodies. The raw older branch lacks the complete LeafGreen + manifest and RS art prerequisites; full cartridge integration needs those + separately offered components. +- Real Metal FireRed run `b3d97aa123`, unchanged private consumer `bddb5181`: + both native send-outs are accepted, with69 native clock/position/draw rows, + and no loader errors. Frame captures expose a second issue: the source + renderer paints its fitted ball and its opening-effect call also paints the + complete native ball. This import change does not solve that duplicate or + constitute final visual flight/size acceptance. Earlier failed probes and + their receipts remain preserved. + +Hosted CI is separate. Tests use owned copies/profiles with a20GiB free-space +floor. Private mod files and player saves/profiles remain untouched. From cc2496d005f45f514e8f0910205601d44e805bf9 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Tue, 27 Oct 2026 07:40:30 +0100 Subject: [PATCH 46/75] Borrow native ball presentation and isolate source palette shaders --- src/battle/Gen3Battle.lua | 8 +- src/core/game3/battle/ball_open.lua | 91 +++++++++++++++++++++++ tests/engine/native_ball_presentation.lua | 64 ++++++++++++++++ tools/native-ball-palette/conf.lua | 7 ++ tools/native-ball-palette/main.lua | 48 ++++++++++++ 5 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 src/core/game3/battle/ball_open.lua create mode 100644 tests/engine/native_ball_presentation.lua create mode 100644 tools/native-ball-palette/conf.lua create mode 100644 tools/native-ball-palette/main.lua diff --git a/src/battle/Gen3Battle.lua b/src/battle/Gen3Battle.lua index dcaa62f71..c3486730f 100644 --- a/src/battle/Gen3Battle.lua +++ b/src/battle/Gen3Battle.lua @@ -2905,7 +2905,7 @@ local function ballSheet(battle, key) return (ok and img) or nil end -function Gen3Battle.drawBall(battle) +local function drawNativeBall(battle) local record, anim = Gen3Battle.ballRecord(battle) if not anim then return end local g = love.graphics @@ -3013,6 +3013,12 @@ function Gen3Battle.drawBall(battle) g.setColor(1, 1, 1, 1) end +function Gen3Battle.drawBall(battle) + local source=package.loaded['src.core.game3.battle.ball_open'] + if source then return source._nativeDraw(battle,drawNativeBall) end + return drawNativeBall(battle) +end + function Gen3Battle.draw(battle) local g = love.graphics battle:drawBattleField() diff --git a/src/core/game3/battle/ball_open.lua b/src/core/game3/battle/ball_open.lua new file mode 100644 index 000000000..97d3666c9 --- /dev/null +++ b/src/core/game3/battle/ball_open.lua @@ -0,0 +1,91 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Borrow the native ball painter/timeline; source artwork has its own shader. +local Battle=require('src.core.game3.battle') +local Coords=require('src.core.game3.battle.anim_coords') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Call=require('src.compat.gen1.SourceCall') +local Scope=require('src.render.GraphicsScope') +local M={} +local shader,users +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +local function finite(n)return type(n)=='number'and n==n and math.abs(n)1/255 end + end end + output:release() + end + check(wrong,'independent unblended negative is observable') + Ball._service:releaseOwner('engine') + check(Ball.setBlendShader(8,7,23,16),'retired program can be recreated') + check(g.getShader()~=borrowed,'only the source-owned shader is recreated') + g.setShader(prior);check(prior:hasUniform('nativePaletteBlend')==false,'caller program has no source uniforms') + g.setShader();Ball._service:release();prior:release();target:release();image:release();pixels:release() + print('NATIVE_BALL_PALETTE_GPU_OK',renderer,checks) + love.event.quit(0) +end From 515e5b4681d3f3581507d855c2258b9ce37654c5 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 11:41:16 +0100 Subject: [PATCH 47/75] Keep source opening effects separate from the native ball cell --- src/battle/Gen3Battle.lua | 39 +++++++----- src/core/game3/battle/ball_open.lua | 5 +- tests/engine/native_ball_effect_layer.lua | 73 +++++++++++++++++++++++ 3 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 tests/engine/native_ball_effect_layer.lua diff --git a/src/battle/Gen3Battle.lua b/src/battle/Gen3Battle.lua index c3486730f..ec8710f48 100644 --- a/src/battle/Gen3Battle.lua +++ b/src/battle/Gen3Battle.lua @@ -2905,7 +2905,7 @@ local function ballSheet(battle, key) return (ok and img) or nil end -local function drawNativeBall(battle) +local function drawNativeBall(battle, effectsOnly) local record, anim = Gen3Battle.ballRecord(battle) if not anim then return end local g = love.graphics @@ -2944,20 +2944,24 @@ local function drawNativeBall(battle) g.setColor(1, 1, 1, 1) end - -- the ball - local sheet = ballSheet(battle, "balls") - local bx = anim.x - size / 2 + (anim.shakeOffset or 0) - local by = anim.y - size / 2 - g.setColor(1, 1, 1, 1 - (anim.whiteout or 0)) - if sheet then - local iw, ih = sheet:getDimensions() - local f = math.max(1, math.min(frames, anim.ballFrame or 1)) - 1 - local q = love.graphics.newQuad(f * size, anim.ball * size, size, size, - iw, ih) - g.draw(sheet, q, bx, by) - else - g.setColor(0.9, 0.2, 0.2, 1 - (anim.whiteout or 0)) - g.rectangle("fill", bx + 2, by + 2, size - 4, size - 4) + -- A source renderer already draws its fitted ball cell. Its opening-effect + -- pass borrows the same native effects without painting that cell again. + if not effectsOnly then + -- the ball + local sheet = ballSheet(battle, "balls") + local bx = anim.x - size / 2 + (anim.shakeOffset or 0) + local by = anim.y - size / 2 + g.setColor(1, 1, 1, 1 - (anim.whiteout or 0)) + if sheet then + local iw, ih = sheet:getDimensions() + local f = math.max(1, math.min(frames, anim.ballFrame or 1)) - 1 + local q = love.graphics.newQuad(f * size, anim.ball * size, size, size, + iw, ih) + g.draw(sheet, q, bx, by) + else + g.setColor(0.9, 0.2, 0.2, 1 - (anim.whiteout or 0)) + g.rectangle("fill", bx + 2, by + 2, size - 4, size - 4) + end end -- ...THE SHINY SPARKLE, which is not the capture's stars. @@ -3013,6 +3017,11 @@ local function drawNativeBall(battle) g.setColor(1, 1, 1, 1) end +-- Separate paint components share the actual native timeline; neither ticks it. +function Gen3Battle.drawBallEffects(battle) + return drawNativeBall(battle, true) +end + function Gen3Battle.drawBall(battle) local source=package.loaded['src.core.game3.battle.ball_open'] if source then return source._nativeDraw(battle,drawNativeBall) end diff --git a/src/core/game3/battle/ball_open.lua b/src/core/game3/battle/ball_open.lua index 97d3666c9..3afcc2b7c 100644 --- a/src/core/game3/battle/ball_open.lua +++ b/src/core/game3/battle/ball_open.lua @@ -1,5 +1,6 @@ -- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. --- Borrow the native ball painter/timeline; source artwork has its own shader. +-- Borrow native opening effects; source renderers paint their own ball cell. +-- A base call inside the native whole-layer boundary still borrows that layer. local Battle=require('src.core.game3.battle') local Coords=require('src.core.game3.battle.anim_coords') local Boundary=require('src.compat.gen1.ScriptBoundary') @@ -65,7 +66,7 @@ end local function draw() if Boundary.active('battle.ball.draw')then return Boundary.invoke('battle.ball.draw')end local screen=Battle._screen();if not screen then return false,'no admitted native battle'end - return require('src.battle.Gen3Battle').drawBall(screen) + return require('src.battle.Gen3Battle').drawBallEffects(screen) end M.draw=draw function M._nativeDraw(screen,body) diff --git a/tests/engine/native_ball_effect_layer.lua b/tests/engine/native_ball_effect_layer.lua new file mode 100644 index 000000000..736a6c433 --- /dev/null +++ b/tests/engine/native_ball_effect_layer.lua @@ -0,0 +1,73 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Native=require('src.battle.Gen3Battle') +local Ball=require('src.core.game3.battle.ball_open') +local Battle=require('src.core.game3.battle') +local G=require('src.core.Game3') +local Assets=require('src.render.Assets') +local Timeline=require('src.battle.Gen3BallAnim') +local Copy=require('src.mods.Merge').deepCopy +local originalGame=package.loaded['src.core.Game'] +local g=love.graphics +local oldImage,oldDraw,oldRect=Assets.image,g.draw,g.rectangle +local calls={} +Assets.image=function(path)return {path=path,getDimensions=function()return 48,192 end}end +g.draw=function(img,q,x,y)calls[#calls+1]={kind='image',path=img.path,w=q.w,h=q.h,x=x,y=y}end +g.rectangle=function(...)calls[#calls+1]={kind='rectangle',...}end +local function paint(fn) + calls={};fn();return calls +end +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f=require('tools.battle-source-runtime.fixture').new(edition) + package.loaded['src.core.Game']=f.game + local b=f.fresh('TACKLE').battle;b.enter=false;f.game.stack:push(b) + local record={size=16,frames=3,particleFrames=8,balls={},timing={}, + images={balls='owned-ball',particles='owned-particles'}} + f.game.data.constants.gen3BallAnim=record + for _,kind in ipairs{'player','opponent','capture'}do + local anim=Timeline.new(f.game.data,{ball=0,battler=b.player,sendOut=kind~='capture'and kind or nil}) + b.gen3Ball=anim;b.gen3BallPlaying=true + anim.x,anim.y,anim.ballFrame,anim.shakeOffset=52,71,2,3 + anim.particles={age=2,life=12,radius=20,count=3,x=52,y=71} + anim.shinyStars={age=20,made=2} + anim.stars={age=12} + local before=Copy(anim);local frame=b.frame + local whole=paint(function()Native.drawBall(b)end) + local effects=paint(Ball.draw) + local expected,ballCount={},0 + for _,row in ipairs(whole)do + if row.path=='owned-ball'then ballCount=ballCount+1 + else expected[#expected+1]=row end + end + T.eq(ballCount,1,'native whole layer retains exactly one native ball') + T.check(#expected>3,'real native particle and star painters are exercised') + T.same(effects,expected,'source opening pass keeps all native effects without a second ball') + T.same(anim,before,'neither paint component advances or mutates the native animation') + T.eq(b.frame,frame) + local original=Ball.draw + Ball.draw=function()original();original();return true end + local wrapped=paint(function()Native.drawBall(b)end) + T.same(wrapped,whole,'repeated base calls inside the native layer borrow the complete painter once') + Ball.draw=original + local fitted=paint(function() + g.draw({path='source-fitted-ball'},{w=16,h=16},80,90) + Ball.draw() + end) + T.eq(#fitted,#effects+1,'source fitted cell plus opening effects has only one ball') + for _,row in ipairs(fitted)do T.neq(row.path,'owned-ball','source renderer never receives a duplicate native cell')end + -- Artless fallback also remains native-only; effects do not invent a ball. + anim.particles,anim.shinyStars,anim.stars=nil,nil,nil + record.images={} + local fallback=paint(function()Native.drawBall(b)end) + T.eq(#fallback,1);T.eq(fallback[1].kind,'rectangle') + T.eq(#paint(Ball.draw),0,'opening-only pass has no artless ball fallback') + record.images={balls='owned-ball',particles='owned-particles'} + end + b.gen3Ball=nil;T.eq(#paint(Ball.draw),0,'retired native ball cannot be painted') + f.game.stack:pop();T.eq(Ball.draw(),false,'no effects outside an admitted native battle') + Battle._service.release();f.close();G.release() +end +Assets.image,g.draw,g.rectangle=oldImage,oldDraw,oldRect +package.loaded['src.core.Game']=originalGame +T.finish('native complete ball painter and source opening-effect component, without duplicate cells or clocks') From f7d7d53730d3f0bd2c80b0934cbebcca544c680e Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 11:47:07 +0100 Subject: [PATCH 48/75] Document opening-effect split and preserved native painter qualification --- .../qa/native-foundation/ball-effect-layer.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 docs/qa/native-foundation/ball-effect-layer.md diff --git a/docs/qa/native-foundation/ball-effect-layer.md b/docs/qa/native-foundation/ball-effect-layer.md new file mode 100644 index 000000000..4f092b1ad --- /dev/null +++ b/docs/qa/native-foundation/ball-effect-layer.md @@ -0,0 +1,42 @@ +# Ball cells and opening effects share one native timeline + +A source renderer draws its fitted ball through its own imported cell, then +calls `ball_open.draw()` for opening effects. That call formerly borrowed the +complete native painter, adding another ball at native coordinates and size. +Real FireRed capture `b3d97aa123` shows both balls simultaneously after native +send-out admission was repaired. + +Keep the existing native painter as one implementation with two paint modes. +Ordinary native battle drawing retains the complete layer. A source opening +pass uses the effects component, including the same native particles, shiny +sparkles and capture stars, without adding another ball cell. Base calls inside +an actual native whole-layer boundary still borrow that complete layer once. +The source's fitted position/scale and the native clocks, queues and capture +math are untouched. No private renderer/controller body is included. + +Validation: + +- Focused run `4b62b07721`:11 groups pass. The new real-painter fixture passes + 235 assertions across five GBA editions and player/opponent/capture modes. + It checks effect command equality, native complete-layer preservation, + once-only wrapper borrowing, no artless duplicate, and read-only painting. +- Exact predecessor `e8d494e1`, run `2826493842`, reproduces45 failures with + the identical fixture and no native controller overlay (205/250 checks; + the old extra draw also adds data-dependent assertions). Corrected code + passes235/235. +- Current fork main `c548cd16` composition `d3dc3867ae`:181 CPU groups pass. + The subsequently fetched main `0af0b3cf` was integrated as `23fcddaa`; + frozen composition `53b81840c4` also passes181 groups. Four additional + new-main appearance/new-game/ending groups pass separately. +- Older-public composition `696b620eea`:69 groups pass, including235 new + assertions. It retains older native simulation bodies and previously + offered cartridge/art/stage prerequisites. The offered raw branch reuses + the existing ball painter boundary from the public presentation component + (unchanged preimage of #329), then applies only the component split. + +A fresh rendered FireRed/LeafGreen flight comparison is still pending and must +check both actual fitted draw rows and zero raw native ball cells. CPU command +checks alone are not complete visual flight, trainer-pose or FPS acceptance. +The earlier capture and failed QA probes remain preserved. Hosted CI is +separate. Only owned profiles/copies are used with the20GiB free-space floor; +private files and player saves/profiles are untouched. From 76aa58b453277177a0594c0333bf423d810edda2 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Tue, 27 Oct 2026 13:28:17 +0100 Subject: [PATCH 49/75] Expose native entry queue observations and presentation holds --- src/battle/BattleState.lua | 7 + src/core/game3/battle/intro_seq.lua | 156 ++++++++++++++++++++++ tests/engine/native_intro_sequence.lua | 171 +++++++++++++++++++++++++ 3 files changed, 334 insertions(+) create mode 100644 src/core/game3/battle/intro_seq.lua create mode 100644 tests/engine/native_intro_sequence.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 374c7c077..2aa60d695 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3811,6 +3811,7 @@ function BattleState:enter() self.enemySendingOut = false self:startGrowIn(self.enemy) end) + if GameVersion.isGen3()then self.queue[#self.queue]._sourceIntroKind='opponent_sendout'end self:shinyAnimAppend(self.enemy, true) queueEnemyCry() elseif self.kind == "link" then @@ -3824,6 +3825,7 @@ function BattleState:enter() self.enemySendingOut = false self:startGrowIn(self.enemy) end) + if GameVersion.isGen3()then self.queue[#self.queue]._sourceIntroKind='opponent_sendout'end self:shinyAnimAppend(self.enemy, true) queueEnemyCry() end @@ -3863,6 +3865,7 @@ function BattleState:enter() -- then the POOF plays and the mon appears with its cry -- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry) table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false }) + if GameVersion.isGen3()then self.queue[#self.queue]._sourceIntroKind='player_throw'end self:shinyAnimAppend(self.player, false) self:act(function() self.sendingOut = false @@ -3889,6 +3892,8 @@ function BattleState:enter() self:abilitySwitchIn(self.player) end }) end + local intro=GameVersion.isGen3()and package.loaded['src.core.game3.battle.intro_seq'] + if intro then intro._nativeBind(self)end Runtime.emit("battle.started", { battle = self, kind = self:battleKind(), trainerId = self.trainer and self.trainer.id, @@ -3900,6 +3905,8 @@ end -- any pop (finish, script teardown) must silence the alarm loop -- (end_of_battle.asm clears wLowHealthAlarm when a battle ends) function BattleState:exit() + local intro=package.loaded['src.core.game3.battle.intro_seq'] + if intro then intro._nativeRelease(self)end -- Teachy TV temporarily replaces the player's party exactly like -- SavePlayerParty/LoadPlayerParty in teachy_tv.c. finish() normally -- restores it before the pop, but exit is the safety net for a forced state diff --git a/src/core/game3/battle/intro_seq.lua b/src/core/game3/battle/intro_seq.lua new file mode 100644 index 000000000..5ee942519 --- /dev/null +++ b/src/core/game3/battle/intro_seq.lua @@ -0,0 +1,156 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Presentation observations over the actual native entry queue and slide. +-- There is no independent sequencer, command builder or animation clock. +local Battle=require('src.core.game3.battle') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Call=require('src.compat.gen1.SourceCall') +local M={} +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +local function copy(value) + if type(value)~='table'then return value end + local result={};for key,item in pairs(value)do result[key]=copy(item)end;return result +end +local function synchronize(screen,token) + if (screen.introSlide or 0)>0 or(screen.frlgIntro and screen.frlgIntro.hold or 0)>0 then token.index=1;return end + local index=token.rows[screen.current] + if not index then for _,row in ipairs(screen.queue or{})do + index=token.rows[row];if index then break end + end end + if index then token.index=index + elseif not screen.current and #(screen.queue or{})==0 then screen._sourceIntroSequence=nil end +end +local function current() + local screen=Battle._screen();local token=screen and screen._sourceIntroSequence + if not token then return end + local view=Battle.getState() + if token.view and token.view~=view then screen._sourceIntroSequence=nil;return end + token.view=view;synchronize(screen,token) + if screen._sourceIntroSequence~=token then return end + local owner=Call.owner(M,'update');if owner then token.users[owner]=true end + return screen,token +end +local function admitted(screen,token,top) + local live,record=current() + return live==screen and record==token and screen.game.stack:top()==(top or screen) +end +function M._nativeBind(screen) + if screen.phase~='messages'or screen.afterQueue~='menu'or not screen.introBalls then return end + local hold=screen.frlgIntro and screen.frlgIntro.hold or 0 + local slide=screen.introSlide or 0 + local token={index=1,rows={},steps={{kind='bgslide',data={frames=hold+slide, + slideFrames=slide,unlockAt=hold}}},users={}} + for _,row in ipairs(screen.queue)do + local kind=row._sourceIntroKind or(row.text~=nil and'msg'or row.wait and'wait' + or row.fn and'native_action'or row.anim and'native_animation'or'native_wait') + local data={} + if row.text~=nil then data.text=row.text end + if row.wait then data.frames=row.wait end + if kind=='opponent_sendout'then data.ids={1} end + if kind=='player_throw'then data.ids={0} end + token.steps[#token.steps+1]={kind=kind,data=data};token.rows[row]=#token.steps + end + token.canonical=copy(token.steps) + screen._sourceIntroSequence=token +end +function M._nativeRelease(screen)screen._sourceIntroSequence=nil end +function M.introText(state) + local screen=Battle._screen(state);return screen and screen.introText +end +function M.sendOutText(state,side) + local screen=Battle._screen(state);if not screen then return end + if side=='player'then return screen.player and screen:sendOutText(screen.player.name)end + if side=='enemy'then + local _,token=current();if not token then return end + for i,step in ipairs(token.canonical)do + if step.kind=='opponent_sendout'then + local previous=token.canonical[i-1] + if previous and previous.kind=='msg'then return previous.data.text end + end + end + end +end +local function update()return Boundary.invoke('battle.intro.update')end +M.update=update +function M.busy()return current()~=nil end +function M._nativeUpdate(screen,dt,body) + local live,token=current() + if live~=screen then return body(screen,dt)end + local stack,top=screen.game.stack,screen.game.stack:top() + if top~=screen then return false,'native battle is covered'end + local function native() + if screen.game.stack~=stack or not admitted(screen,token,top)then return false,'native intro admission changed'end + token.started=true + local result=pack(body(screen,dt)) + if screen._sourceIntroSequence==token then synchronize(screen,token)end + return unpack(result,1,result.n) + end + if M.update==update then return native()end + return Boundary.dispatch('battle.intro.update',native,function() + local owner=Call.owner(M,'update');if owner then token.users[owner]=true end + return Call.call(M,'update',dt) + end) +end +local function same(a,b,depth) + if type(a)~=type(b)then return false end + if type(a)~='table'then return a==b end + if(depth or 0)>4 then return false end + for key,value in pairs(a)do if not same(value,b[key],(depth or 0)+1)then return false end end + for key in pairs(b)do if a[key]==nil then return false end end + return true +end +local function replaceSteps(screen,token,value) + assert(not token.started and token.index==1,'native intro has already started') + assert(type(value)=='table'and #value==#token.canonical,'native intro steps cannot be inserted or removed') + for i,step in ipairs(token.canonical)do + local nextStep=value[i] + assert(type(nextStep)=='table'and nextStep.kind==step.kind and type(nextStep.data)=='table','native intro step identity changed') + if i>1 then assert(same(nextStep.data,step.data),'native intro queue data is read-only')end + end + local d=value[1].data;local original=token.canonical[1].data + for _,key in ipairs{'frames','slideFrames','unlockAt'}do + assert(type(d[key])=='number'and d[key]%1==0 and d[key]>=0 and d[key]<=original[key], + 'intro slide may only shorten its native presentation') + end + assert(d.frames>=1 and d.frames==d.slideFrames+d.unlockAt,'inconsistent native intro slide duration') + for key,v in pairs(d)do + assert(key=='frames'or key=='slideFrames'or key=='unlockAt' + or(key=='playerFrom'or key=='enemyFrom'or key=='enemyMonFrom')and v==0,'unsupported native intro slide option') + end + screen.introSlide=d.slideFrames + if screen.frlgIntro then screen.frlgIntro.hold=d.unlockAt end + -- Keep detached scalar observations, never source closures or queue rows. + token.steps[1].data={frames=d.frames,slideFrames=d.slideFrames,unlockAt=d.unlockAt} +end +local observed={_st=true,_steps=true,_i=true,_waiting=true,_waitingMsg=true} +setmetatable(M,{__index=function(_,key) + if not observed[key]then return end + local screen,token=current();if not token then return end + if key=='_st'then return token.view end + if key=='_steps'then return token.steps end + if key=='_i'then return token.index end + if key=='_waitingMsg'then return screen.current~=nil and screen.current.text~=nil end + if key=='_waiting'then return screen.current~=nil or screen.waitingUI==true + or screen.gen3BallPlaying==true or(screen.waitFrames or 0)>0 end +end,__newindex=function(t,key,value) + if not observed[key]then rawset(t,key,value);return end + local screen,token=current() + assert(screen and admitted(screen,token),'native intro does not own presentation') + if key=='_steps'then return replaceSteps(screen,token,value)end + -- The only writable cursor operation acknowledges an already fully shown + -- native send-out line. It cannot skip an action, a prompt or unread text. + local step=token.canonical[token.index] + local message=screen.current + assert(step and step.kind=='msg'and message and message.text==M.sendOutText(token.view,'player') + and(screen.charIndex or 0)>=(screen.total or math.huge)and not screen.scrollPx, + 'native send-out message is not complete') + if key=='_waitingMsg'or key=='_waiting'then + assert(value==false,'native intro wait may only acknowledge a completed message');return + end + assert(key=='_i'and value==token.index+1,'native intro cursor cannot skip queue steps') + screen.current=nil;synchronize(screen,token) +end}) +M._service={release=function()local screen=Battle._screen();if screen then M._nativeRelease(screen)end end, + releaseOwner=function(_,owner)local screen,token=current();if token and token.users[owner]then M._nativeRelease(screen)end end} +require('src.core.game3.asset_stream').register(M._service) +return M diff --git a/tests/engine/native_intro_sequence.lua b/tests/engine/native_intro_sequence.lua new file mode 100644 index 000000000..02f2a39dc --- /dev/null +++ b/tests/engine/native_intro_sequence.lua @@ -0,0 +1,171 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Intro=require('src.core.game3.battle.intro_seq') +local Battle=require('src.core.game3.battle') +local G=require('src.core.Game3') +local base=Intro.update;local original=package.loaded['src.core.Game'] +local function pack(...)return{n=select('#',...),...}end +local function fresh(edition,kind) + local f=require('tools.battle-source-runtime.fixture').new(edition) + package.loaded['src.core.Game']=f.game + local b=f.fresh('TACKLE').battle + if kind then b.kind=kind end + f.game.stack:push(b) + return f,b +end +local function close(f) + f.game.stack:pop();Intro._service.release();Battle._service.release();f.close();G.release() +end +local function proposal(steps) + local out={} + for i,step in ipairs(steps)do out[i]={kind=step.kind,data={}} + for key,value in pairs(step.data)do + if type(value)=='table'then local values={};for n,item in ipairs(value)do values[n]=item end;value=values end + out[i].data[key]=value + end + end + return out +end +T.eq(base(),nil);T.eq(Intro.busy(),false);T.eq(Intro.introText({}),nil);T.eq(Intro.sendOutText({},'player'),nil) +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + -- Real native enter builds every message, action, sound, shiny and throw + -- row. The source step list must not retain a row, function or battler. + local f,b=fresh(edition) + local game=f.game;local view=Battle.getState();local steps=Intro._steps + T.eq(Intro._st,view);T.eq(Intro.busy(),true);T.eq(Intro._i,1);T.eq(Intro._waiting,false) + T.eq(steps,Intro._steps);T.eq(steps[1].kind,'bgslide') + T.eq(steps[1].data.slideFrames,b.introSlide) + T.eq(steps[1].data.unlockAt,b.frlgIntro and b.frlgIntro.hold or 0) + T.eq(Intro.introText(view),b.introText);T.eq(Intro.sendOutText(view,'player'),b:sendOutText(b.player.name)) + local throw,go + for _,step in ipairs(steps)do + T.eq(step.fn,nil);T.eq(step.data.mon,nil) + if step.kind=='player_throw'then throw=step end + if step.kind=='msg'and step.data.text==Intro.sendOutText(view,'player')then go=step end + end + T.neq(throw,nil);T.eq(throw.data.ids[1],0);T.neq(go,nil) + T.eq(#steps,#b.queue+1,'only actual native entry rows are described') + local queued={};for i,row in ipairs(b.queue)do queued[i]=row end + local tooLong=proposal(steps);tooLong[1].data.slideFrames=100000 + T.raises(function()Intro._steps=tooLong end,'shorten') + local changed=proposal(steps);changed[2].kind='player_throw' + T.raises(function()Intro._steps=changed end,'identity') + local unread=proposal(steps);unread[#unread].data.injected=true + T.raises(function()Intro._steps=unread end,'read-only') + local oldFrames,oldSlide=steps[1].data.frames,steps[1].data.slideFrames + steps[1].data.frames,steps[1].data.slideFrames=100000,100000 + T.raises(function()Intro._steps=proposal(steps)end,'shorten') + steps[1].data.frames,steps[1].data.slideFrames=oldFrames,oldSlide + local fast=proposal(steps);fast[1].data={frames=1,slideFrames=1,unlockAt=0, + playerFrom=0,enemyFrom=0,enemyMonFrom=0} + Intro._steps=fast;T.eq(b.introSlide,1);T.eq(b.frlgIntro and b.frlgIntro.hold or 0,0) + for i,row in ipairs(b.queue)do T.eq(row,queued[i],'shortening never rebuilds the native queue')end + local frame=b.frame;local hp,pp=b.player.mon.hp,b.player.mon.moves[1].pp + Intro.update=function()return false end + b:update(1/60);T.eq(b.frame,frame);T.eq(b.introSlide,1);T.eq(Intro._i,1) + Intro.update=function()base();return base()end + b:update(1/60);T.eq(b.frame,frame+1,'multiple base calls borrow one native frame') + T.eq(b.introSlide,0);T.eq(b.player.mon.hp,hp);T.eq(b.player.mon.moves[1].pp,pp) + T.raises(function()Intro._steps=fast end,'already started') + T.eq(base(),nil,'source cannot advance native time outside its callback') + Intro.update=function()error('intro source before native')end + frame=b.frame;T.raises(function()b:update(1/60)end,'intro source before native');T.eq(b.frame,frame) + Intro.update=function()base();error('intro source after native')end + T.raises(function()b:update(1/60)end,'intro source after native');T.eq(b.frame,frame+1) + local tick=b.tickFx;b.tickFx=function()error('native intro failed')end + Intro.update=function()pcall(base);return true end + T.raises(function()b:update(1/60)end,'native intro failed');b.tickFx=tick + local rejected + Intro.update=function()game.stack:push({});local a,why=base();rejected=why;game.stack:pop();return a,why end + frame=b.frame;local a=b:update(1/60);T.eq(a,false);T.eq(rejected,'native intro admission changed');T.eq(b.frame,frame) + Intro.update=function()error('covered battle must not call intro wrapper')end + game.stack:push({});b:update(1/60);game.stack:pop();T.eq(b.frame,frame) + local seams=require('src.mods.ModSeams');local owner=G._callbackOwner() + Intro.update=function()T.eq(G._callbackOwner(),'intro_owner');return base()end + seams._journal[#seams._journal+1]={tbl=Intro,key='update',after=Intro.update,owner='intro_owner'} + b:update(1/60);table.remove(seams._journal);T.eq(G._callbackOwner(),owner) + Intro._service.releaseOwner(nil,'unrelated');T.eq(Intro.busy(),true) + Intro._service.releaseOwner(nil,'intro_owner');T.eq(Intro.busy(),false) + Intro.update=function()error('retired observers cannot hold the native entry')end + frame=b.frame;b:update(1/60);T.eq(b.frame,frame+1) + Intro.update=base;close(f);T.eq(Intro._steps,nil);T.eq(Intro._st,nil) + -- Native module-absent/default/wrapped entry traces have the same clock, + -- slide, queue and text typing state through the initial FRLG hold. + local traces={} + for _,mode in ipairs{'absent','default','wrapped'}do + if mode=='absent'then package.loaded['src.core.game3.battle.intro_seq']=nil + else package.loaded['src.core.game3.battle.intro_seq']=Intro end + Intro.update=mode=='wrapped'and function()base();return base()end or base + local current,screen=fresh(edition);local rows={} + for _=1,45 do screen:update(1/60);rows[#rows+1]=table.concat({screen.frame,screen.introSlide, + screen.frlgIntro and screen.frlgIntro.hold or 0,#screen.queue,screen.charIndex or -1},':')end + traces[mode]=table.concat(rows,'|');close(current) + end + T.eq(traces.default,traces.absent,edition..' default entry trace') + T.eq(traces.wrapped,traces.absent,edition..' wrapped entry trace') + package.loaded['src.core.game3.battle.intro_seq']=Intro;Intro.update=base + local entry,screen=fresh(edition,'trainer') + local enemyMessage=Intro.sendOutText(Battle.getState(),'enemy');local enemyStep + for _,step in ipairs(Intro._steps)do if step.kind=='opponent_sendout'then enemyStep=step end end + T.neq(enemyMessage,nil);T.neq(enemyStep,nil);T.eq(enemyStep.data.ids[1],1) + local values=pack(Intro._nativeUpdate(screen,1/60,function()return nil,7,nil end)) + T.eq(values.n,3);T.eq(values[1],nil);T.eq(values[2],7);T.eq(values[3],nil) + -- Public metadata is detached: mutating it cannot widen the real native + -- slide budget or turn a message into an executable action. + local live=Intro._steps;live[1].data.frames=100000;live[1].data.slideFrames=100000 + T.raises(function()Intro._steps=proposal(live)end,'already started') + local goRow,throwRow + for _,row in ipairs(screen.queue)do + if row.text==Intro.sendOutText(Battle.getState(),'player')then goRow=row end + if row._sourceIntroKind=='player_throw'then throwRow=row end + end + T.neq(goRow,nil);T.neq(throwRow,nil) + screen.introSlide=0;if screen.frlgIntro then screen.frlgIntro.hold=0 end + screen.queue={throwRow};screen:startMessage(goRow) + local cursor=Intro._i + T.raises(function()Intro._waitingMsg=false end,'not complete') + screen.charIndex=screen.total;screen.scrollPx=nil + T.raises(function()Intro._i=cursor+2 end,'cannot skip') + Intro._waitingMsg=false;Intro._waiting=false;Intro._i=cursor+1 + T.eq(screen.current,nil);T.eq(screen.queue[1],throwRow);T.eq(#screen.queue,1) + T.eq(Intro._steps[Intro._i].kind,'player_throw','acknowledgment does not consume the native throw') + local admission + Intro.update=function() + local saved=entry.game.save;entry.game.save={} + local ok,why=base();admission=why;entry.game.save=saved;return ok,why + end + local frame=screen.frame;screen:update(1/60) + T.eq(admission,'native intro admission changed');T.eq(screen.frame,frame) + T.eq(Intro._steps,nil,'a replaced cohort retires native intro observations') + Intro.update=base + close(entry) +end +-- Exercise the real clock/UI composition too. Intro holds must leave the +-- already-owned presentation task alive, and repeated pump/base calls must +-- not tick either native owner twice. +local Anim=require('src.core.game3.battle.anim') +local Ui=require('src.core.game3.battle.ui');local pump=Ui.pump +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f,b=fresh(edition);local samples=0 + Anim.tweenStage(3,function()samples=samples+1 end) + Ui.pump=function(...)pump(...);return pump(...)end + Intro.update=function()base();return base()end + local frame=b.frame;b:update(1/60);T.eq(b.frame,frame+1);T.eq(samples,1) + Intro.update=function()return false end + b:update(1/60);T.eq(b.frame,frame+1);T.eq(samples,2) + Intro.update=base;b:update(1/60);T.eq(b.frame,frame+2);T.eq(samples,3) + Ui.pump=pump;Intro.update=base;close(f);Anim._service.release() +end +-- Retained public steps cannot keep a completed native entry/session alive. +local function retired() + local f,b=fresh('firered');local steps=Intro._steps;local game=f.game + local weak=setmetatable({game,game.save,game.data,b,b._sourceIntroSequence},{__mode='v'}) + close(f);package.loaded['src.core.Game']=original + return steps,weak +end +local retained,weak=retired();require('src.render.Font').load(T.fixtures.fresh()) +for _=1,4 do collectgarbage('collect')end +for i=1,5 do T.eq(weak[i],nil,'retained entry steps release owner '..i)end +T.eq(retained[1].kind,'bgslide');Intro.update=base;package.loaded['src.core.Game']=original +T.finish('actual native intro rows, presentation shortening, shared update and observer retirement') From 47ce08bdaea9e5f2c3012d2b3a2d165b0aa66b68 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 12:02:22 +0100 Subject: [PATCH 50/75] Observe actual native send-out phase while its dynamic wait owns the intro --- src/core/game3/battle/intro_seq.lua | 13 ++++- tests/engine/native_ball_intro_phase.lua | 60 ++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 tests/engine/native_ball_intro_phase.lua diff --git a/src/core/game3/battle/intro_seq.lua b/src/core/game3/battle/intro_seq.lua index 5ee942519..70ef2d24d 100644 --- a/src/core/game3/battle/intro_seq.lua +++ b/src/core/game3/battle/intro_seq.lua @@ -13,6 +13,16 @@ local function copy(value) end local function synchronize(screen,token) if (screen.introSlide or 0)>0 or(screen.frlgIntro and screen.frlgIntro.hold or 0)>0 then token.index=1;return end + -- The native send-out inserts its own wait after the original entry was + -- bound. That dynamic row is not in token.rows; looking ahead through it + -- would expose the following message while the actual ball is still active. + local ball=screen.gen3Ball + if screen.gen3BallPlaying and ball then + local kind=ball.sendOut=='player'and ball.battler==screen.player and'player_throw' + or ball.sendOut=='opponent'and ball.battler==screen.enemy and'opponent_sendout' + local index=kind and token.sendOuts[kind] + if index then token.index=index;return end + end local index=token.rows[screen.current] if not index then for _,row in ipairs(screen.queue or{})do index=token.rows[row];if index then break end @@ -38,7 +48,7 @@ function M._nativeBind(screen) if screen.phase~='messages'or screen.afterQueue~='menu'or not screen.introBalls then return end local hold=screen.frlgIntro and screen.frlgIntro.hold or 0 local slide=screen.introSlide or 0 - local token={index=1,rows={},steps={{kind='bgslide',data={frames=hold+slide, + local token={index=1,rows={},sendOuts={},steps={{kind='bgslide',data={frames=hold+slide, slideFrames=slide,unlockAt=hold}}},users={}} for _,row in ipairs(screen.queue)do local kind=row._sourceIntroKind or(row.text~=nil and'msg'or row.wait and'wait' @@ -49,6 +59,7 @@ function M._nativeBind(screen) if kind=='opponent_sendout'then data.ids={1} end if kind=='player_throw'then data.ids={0} end token.steps[#token.steps+1]={kind=kind,data=data};token.rows[row]=#token.steps + if kind=='opponent_sendout'or kind=='player_throw'then token.sendOuts[kind]=#token.steps end end token.canonical=copy(token.steps) screen._sourceIntroSequence=token diff --git a/tests/engine/native_ball_intro_phase.lua b/tests/engine/native_ball_intro_phase.lua new file mode 100644 index 000000000..f87b85907 --- /dev/null +++ b/tests/engine/native_ball_intro_phase.lua @@ -0,0 +1,60 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Intro=require('src.core.game3.battle.intro_seq') +local Battle=require('src.core.game3.battle') +local G=require('src.core.Game3') +local Native=require('src.battle.Gen3Battle') +local Copy=require('src.mods.Merge').deepCopy +local originalGame=package.loaded['src.core.Game'] +local originalCentre=Native.battlerCentre +Native.battlerCentre=function(_,actor)return actor.side=='player'and 72 or 176,72 end +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local traces={} + for _,mode in ipairs{'absent','default','observed'}do + if mode=='absent'then package.loaded['src.core.game3.battle.intro_seq']=nil + else package.loaded['src.core.game3.battle.intro_seq']=Intro end + local f=require('tools.battle-source-runtime.fixture').new(edition) + package.loaded['src.core.Game']=f.game + f.game.data.constants.gen3BallAnim={size=16,frames=3,balls={},images={},timing={sendOut={ + from={x=24,y=68},frames=25,arc=-30,targetOffsetY=24,foeOffsetY=24, + foeWait=16,fadeFrames=14,riseStep=-288}}} + local b=f.fresh('TACKLE').battle;b.kind='trainer' + f.game.input.wasPressed=function(_,key)return key=='a'end + f.game.stack:push(b) + local observed={player=0,opponent=0};local trace={} + for tick=1,1600 do + b:update(1/60) + local a=b.gen3Ball + trace[#trace+1]={frame=b.frame,phase=b.phase,queue=#b.queue,wait=b.waitFrames, + ball=a and {side=a.sendOut,tick=a.frame,phase=a.phase,x=a.x,y=a.y}, + playerHP=b.player.mon.hp,enemyHP=b.enemy.mon.hp,pp=b.player.mon.moves[1].pp} + if mode=='observed'and a and b.gen3BallPlaying and a.sendOut then + local kind=a.sendOut=='player'and'player_throw'or'opponent_sendout' + local before=Copy(a);local count,frame,wait=#b.queue,b.frame,b.waitFrames + for _=1,3 do + local steps,index=Intro._steps,Intro._i + T.check(steps and index and steps[index].kind==kind,'live native '..a.sendOut..' ball keeps its actual intro phase') + T.eq(Intro._waiting,true) + end + T.same(a,before,'phase observation does not step the native ball') + T.eq(#b.queue,count);T.eq(b.frame,frame);T.eq(b.waitFrames,wait) + observed[a.sendOut]=observed[a.sendOut]+1 + end + if b.phase=='menu'and #b.queue==0 and not b.current then break end + assert(tick<1600,'actual native entry did not finish') + end + if mode=='observed'then + T.check(observed.player>=25,'real native player throw was observed') + T.check(observed.opponent>=16,'real native opponent release was observed') + T.eq(Intro.busy(),false,'completed native intro retires its observation') + end + traces[mode]=trace + f.game.stack:pop();Intro._service.release();Battle._service.release();f.close();G.release() + end + T.same(traces.default,traces.absent,'default phase binding leaves the entire native intro trace equal') + T.same(traces.observed,traces.absent,'repeated phase reads leave the entire native intro trace equal') +end +package.loaded['src.core.game3.battle.intro_seq']=Intro +Native.battlerCentre=originalCentre;package.loaded['src.core.Game']=originalGame +T.finish('actual native send-out phase identity through dynamic waits, without another queue or clock') From 894dd69a695fb117017b5a08dd8deb87eba67e96 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 12:04:00 +0100 Subject: [PATCH 51/75] Record refreshed fork FireRed flight and scoped duplicate-cell validation --- .../qa/native-foundation/ball-effect-layer.md | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/qa/native-foundation/ball-effect-layer.md b/docs/qa/native-foundation/ball-effect-layer.md index 4f092b1ad..a8052ee2d 100644 --- a/docs/qa/native-foundation/ball-effect-layer.md +++ b/docs/qa/native-foundation/ball-effect-layer.md @@ -34,9 +34,22 @@ Validation: the existing ball painter boundary from the public presentation component (unchanged preimage of #329), then applies only the component split. -A fresh rendered FireRed/LeafGreen flight comparison is still pending and must -check both actual fitted draw rows and zero raw native ball cells. CPU command -checks alone are not complete visual flight, trainer-pose or FPS acceptance. -The earlier capture and failed QA probes remain preserved. Hosted CI is -separate. Only owned profiles/copies are used with the20GiB free-space floor; -private files and player saves/profiles are untouched. +Fresh Metal FireRed run `368069098b` on frozen `53b81840c4` completes +native new-game and both actual send-outs without loader errors. It records69 +native clock/position/draw frames, five captured player-flight frames and412 +source opening-effect calls with zero duplicate native ball cells in those +calls. The entire69-frame native ball trace equals pre-split run `b3d97aa123`. +Images show one ball moving from hand to landing point. Actual closed artwork +is12x12 inside its16x16 cell; fitted scale grows0.296745 to0.65. Cell extent is +reported in logical canvas units, not claimed as physical opaque pixel width. + +Earlier fresh run `49a837bef7` also showed the corrected single ball and equal +clock, but its broad all-target asset counter rejected legitimate native draws. +That failed receipt is retained. The scoped probe distinguishes the actual +source opening pass from other native drawing; it does not suppress native +painting to satisfy the counter. LeafGreen comparison is pending. The focused +hosted ball workflow succeeds on `dd5a507d`; other CI and full trainer-pose, +other-view or FPS acceptance remain separate. + +Only owned profiles/copies are used with the20GiB free-space floor; private +files and player saves/profiles are untouched. From eb191c0a2ab51b9cf07ed4593c5e4bc0141377fa Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 12:06:55 +0100 Subject: [PATCH 52/75] Document ball phase observation on the older native composition --- docs/qa/native-foundation/ball-intro-phase.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/qa/native-foundation/ball-intro-phase.md diff --git a/docs/qa/native-foundation/ball-intro-phase.md b/docs/qa/native-foundation/ball-intro-phase.md new file mode 100644 index 000000000..08138e0cb --- /dev/null +++ b/docs/qa/native-foundation/ball-intro-phase.md @@ -0,0 +1,38 @@ +# Keep ball observations in the actual native entry phase + +The native send-out inserts its wait after the intro observation was bound. +That row is absent from the original observation map, so looking through the +queue reports the following message/action while the real ball still flies. +This also prevents consumers from identifying their genuine send-out window. + +While the actual native ball is playing, retain the bound send-out step only +when its send-out side and battler identity match the current native player or +opponent. The existing slide still wins before release; after the ball retires, +normal native row synchronization resumes. Keep only scalar step indices in +the token. No new rows, callbacks, timeline, private trainer animation, native +controller or permission to skip an action are added. + +`native_ball_intro_phase.lua` runs actual native trainer intros across five +GBA editions. It provides explicit synthetic sprite centres; this is phase/ +queue/clock validation, not a rendered-geometry qualification. Repeated reads +leave the full native frame/queue/wait/ball-position/HP/PP trace equal to the +module-absent control, and completion retires the observation. Focused run +`a072cb532f` passes12 groups, including3475 new checks. Exact predecessor +`dd5a507d`, run `3b0c93abd8`, reproduces1035 failures out of3475 with the identical +fixture and no native controller overlay. + +Frozen current-main composition `d05bd459f0` passes186 CPU groups including +main `0af0b3cf` appearance/new-game/ending changes. Source hashes match the +committed phase module and fixture. Older-public composition `dac22e1a00` +passes70 groups, including all3475 checks, preserving its existing native +simulation and cartridge prerequisites. The offered raw branch reuses the +previous entry observation/binding component (preimage already offered in +#329); qualification applies only the new observation hunk to that preimage. + +The accepted real FireRed flight `368069098b` predates this phase addition. +It qualifies preceding import/progress/paint fixes only. Full trainer-specific +throw poses, the native tween notification/extension contract, switches and +other views still require separate native ownership work; exposing a correct +step identity alone does not implement those features. Hosted CI is separate. +Only owned profiles/copies are used, with a20GiB free floor. Player saves and +private files remain untouched. From 2f85390ecd9f2e4539bdebb50df157af587228d2 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 12:08:41 +0100 Subject: [PATCH 53/75] Record real refreshed-fork LeafGreen ball flight and preserved QA failure --- docs/qa/native-foundation/ball-effect-layer.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/qa/native-foundation/ball-effect-layer.md b/docs/qa/native-foundation/ball-effect-layer.md index a8052ee2d..55337d9fb 100644 --- a/docs/qa/native-foundation/ball-effect-layer.md +++ b/docs/qa/native-foundation/ball-effect-layer.md @@ -47,7 +47,12 @@ Earlier fresh run `49a837bef7` also showed the corrected single ball and equal clock, but its broad all-target asset counter rejected legitimate native draws. That failed receipt is retained. The scoped probe distinguishes the actual source opening pass from other native drawing; it does not suppress native -painting to satisfy the counter. LeafGreen comparison is pending. The focused +painting to satisfy the counter. LeafGreen run `0237101bb2` also passes both +actual starts and69 clock/position/draw rows, five captures, and469 opening +passes with zero duplicate cells and no loader errors. Its scale likewise +grows0.296745 to0.65. Earlier `1d183dc241` did not enter game code: a relative +QA input path failed in host configuration. That receipt is retained and both +starter boundaries now normalize their input paths. The focused hosted ball workflow succeeds on `dd5a507d`; other CI and full trainer-pose, other-view or FPS acceptance remain separate. From 0ac80f5f4019b4508531eed558c202f5414cf830 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 12:34:45 +0100 Subject: [PATCH 54/75] Reuse the existing public animation adapter prerequisite --- src/core/game3/battle/anim.lua | 301 +++++++++++++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 src/core/game3/battle/anim.lua diff --git a/src/core/game3/battle/anim.lua b/src/core/game3/battle/anim.lua new file mode 100644 index 000000000..0276161bc --- /dev/null +++ b/src/core/game3/battle/anim.lua @@ -0,0 +1,301 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- One admitted native presentation owner. This is not a second battle VM; +-- authored command/particle APIs are advertised only as they are integrated. +local Battle=require('src.core.game3.battle') +local Coords=require('src.core.game3.battle.anim_coords') +local Clock=require('src.battle.PresentationClock') +local Scope=require('src.render.GraphicsScope') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Call=require('src.compat.gen1.SourceCall') +local Ids=require('src.compat.gen1.BattleIds') +local A={Coords=Coords,ENEMY_MON={x=176,y=40},PLAYER_MON={x=72,y=80}} +function A.stage() + return require('src.core.game3.battle.stage').current(require('src.compat.gen1.SourceCall').owner(A,'stage')) +end +function A.drawParticles(lo,hi) + return require('src.core.game3.battle.particle_pass').draw(lo,hi,require('src.compat.gen1.SourceCall').owner(A,'drawParticles')) +end +local current +local numeric={ox=true,oy=true,alpha=true,scale=true,sx=true,sy=true,rotation=true, + darken=true,flash=true,displayHp=true,displayMaxHp=true,displayLevel=true,displayExp=true} +local function retire() + if current then + local screen=current.screen[1] + if screen and current.clip and screen.gen3Anim==current.clip.player + and current.clip.serial==screen.gen3Anim.presentationSerial then + screen.gen3Anim:release();screen.gen3AnimPlaying=false + end + current.clip=nil + if current.effect then current.effect:release();current.effect=nil end + current.tasks={};current.onEnd=nil;current.users={};current.overrides={} + current.view=nil;current.screen[1]=nil + end + current=nil +end +local function context() + local screen=Battle._screen();local view=Battle.getState() + if not screen then retire();return end + if not current or current.screen[1]~=screen or current.view~=view then + retire() + current={screen=setmetatable({screen},{__mode='v'}),view=view,present={},overrides={}, + tasks={},nextTask=0,users={},ids=Ids.new(screen.data)} + end + local facade=require('src.core.Game3');local owner=facade._callbackOwner() + local host=package.loaded['src.mods.Runtime'];owner=host and host.currentMod or owner + if owner then current.users[owner]=true end + return current,screen +end +local function battler(screen,id) + if id==0 then return screen.player elseif id==1 then return screen.enemy end + return screen:battlerAt(id) +end +local function number(v)return type(v)=='number'and v==v and v~=math.huge and v~=-math.huge end +local function callback(fn,...) + if fn then return require('src.core.Game3')._withCallbackOwner(fn.owner,fn.call,...)end +end +local function owned(fn) + if fn==nil then return end + assert(type(fn)=='function','presentation callback must be a function') + local host=package.loaded['src.mods.Runtime'] + return{call=fn,owner=host and host.currentMod or require('src.core.Game3')._callbackOwner()} +end +local function read(d,id,key) + local active,screen=context();if active~=d then return nil end + local b=battler(screen,id);if not b then return nil end + local p=d.overrides[id] + if p[key]~=nil then return p[key]end + if key=='id'then return id end + if key=='side'then return Coords.sideOf(id)end + if key=='visible'then return not b.fainted and not screen:fxHidden(b)and not screen:gen3AnimMonBgHidden(b)end + if key=='invisible'or key=='battlerInvisible'then return screen:gen3AnimMonBgHidden(b)end + if key=='ox'or key=='oy'then + local x,y=screen:gen3AnimShake(b);local fx=screen.picFx and screen.picFx[b] + return(key=='ox'and x+(fx and fx.ox or 0)or y+(fx and fx.oy or 0)) + end + if key=='scale'then return screen:gen3AnimScale(b)or 1 end + if key=='sx'or key=='sy'then + local x,y=screen:gen3AnimSquash(b);return(key=='sx'and x or y)or 1 + end + if key=='rotation'then return screen:gen3AnimRotate(b)or 0 end + if key=='alpha'then local fx=screen:gen3AnimSpecialMonState(b);return fx and fx.alpha or 1 end + if key=='hFlip'then return false end + if key=='darken'or key=='flash'then return 0 end + if key=='displayHp'then return b.shownHP or b.mon.hp end + if key=='displayMaxHp'then return b.mon.stats.hp end + if key=='displayLevel'then return b.mon.level end + if key=='displayStatus'then + -- Logical status may already describe a queued future event. The native + -- HUD owns the shown ailment; false is its explicit healthy sentinel. + local status=b.shownStatus + if not status then return false end + if status=='SLP'then return 1 end + return({PSN=8,BRN=16,FRZ=32,PAR=64,TOX=128})[status]or false + end + if key=='displayExp'then return screen:expFraction(b.mon)end +end +function A.idOf(key) + if key=='attacker_side'then + local _,screen=context();local anim=screen and screen.gen3Anim + return anim and anim.attackerPosition or 0 + end + return Coords.idOf(key) +end +A.sideOf,A.isDouble,A.setDouble=Coords.sideOf,Coords.isDouble,Coords.setDouble +A.coords,A.subpriority,A.bgPriorityRank=Coords.coords,Coords.subpriority,Coords.bgPriorityRank +A.monDrawOrder,A.particleBand,A.battlerIds=Coords.monDrawOrder,Coords.particleBand,Coords.ids +function A.x(value) + local _,screen=context();local anim=screen and screen.gen3Anim + value=tonumber(value)or 0 + return anim and anim.attackerIsPlayer==false and -value or value +end +function A.present(key) + local d,screen=context();local id=A.idOf(key) + if not d or id==nil or id>=2 and not Coords.isDouble(d.view)or not battler(screen,id)then return nil end + if not d.present[id]then + d.overrides[id]={} + d.present[id]=setmetatable({},{__index=function(_,k)return read(d,id,k)end, + __newindex=function(_,k,v) + assert(context()==d,'native battle presentation retired') + assert(k~='id'and k~='side','native battler identity is read-only') + if v~=nil and(numeric[k]or type(v)=='number')then assert(number(v),'invalid presentation number')end + d.overrides[id][k]=v + end}) + end + return d.present[id] +end +function A.battlerCenter(key) + local p=A.present(key);local base=Coords.coords(nil,A.idOf(key)) + if not p or not base then return nil end + return base.x+(p.ox or 0),base.y+(p.oy or 0) +end +function A.shownBattler(key,fallback)local p=A.present(key);return p and p.shown or fallback end +function A.setShown(key,value)local p=A.present(key);if p then p.shown=value;return true end;return false end +function A.ballIdOf(key) + local d,screen=context();local id=A.idOf(key);local b=d and id and battler(screen,id) + if not b then return nil end + return require('src.battle.Gen3BallAnim').ballIndex(screen.data,b.mon.ball or'POKE_BALL',screen.data.items) +end +function A.displayHpRatio(key,fallback) + local p=A.present(key);local mon=fallback and fallback.mon + local hp=p and p.displayHp or mon and mon.hp or 0 + local maximum=math.max(1,p and p.displayMaxHp or mon and mon.maxHp or 1) + return math.max(0,math.min(1,hp/maximum)),hp,maximum +end +function A.displayExpRatio(key,fallback) + local p=A.present(key or'player') + return p and p.displayExp or 0,p and p.displayLevel or fallback and fallback.mon and fallback.mon.level or 1 +end +function A.hpTweening() + local _,screen=context();if not screen then return false end + for _,id in ipairs(Coords.ids())do + local b=battler(screen,id) + if b and b.shownHP and(b.shownHP~=b.mon.hp or b.draining or(b.drainHold or 0)>0)then return true end + end + return false +end +local function effect() + local d=context();if not d then return end + if not d.effect then d.effect=require('src.render.BattleScreenEffect').new()end + return d.effect +end +function A.setScreenEffect(opts)local e=effect();return e and e:set(opts)or false end +function A.screenEffect()local e=effect();return e and e.value end +function A.beginScreenEffect()local e=effect();return e and e:begin()or false end +function A.endScreenEffect()local e=effect();return e and e:finish()or false end +function A.busy() + local d,screen=context() + return d~=nil and(screen.gen3AnimPlaying==true or screen.gen3BallPlaying==true + or A.hpTweening()or screen.expFilling==true or next(d.tasks)~=nil)or false +end +function A.setSeqBusy(value)local d=context();if d then d.seqBusy=value==true end end +function A.seqBusy()local d=context();return d and d.seqBusy==true or false end +function A.tweenStage(frames,onStep,onComplete) + local d=context();if not d then return nil,'no admitted native battle'end + assert(number(frames)and frames>=0,'invalid presentation duration') + d.nextTask=d.nextTask+1 + local task={id=d.nextTask,frames=0,total=math.max(1,math.ceil(frames)),step=owned(onStep),done=owned(onComplete)} + d.tasks[task.id]=task + return task +end +function A._nativeStep(screen,dt) + if not Clock.active(screen)then return false,'animation update belongs to its native battle phase'end + local d,actual=context();if actual~=screen or screen.game.stack:top()~=screen then return false end + return Clock.once(screen,'source.presentation',function() + local clip=d.clip + if clip then + if clip.player~=screen.gen3Anim or clip.serial~=screen.gen3Anim.presentationSerial then + d.clip,d.onEnd=nil,nil + elseif screen.gen3AnimPlaying then + require('src.core.Game3')._withCallbackOwner(nil,screen.updateMovePresentation,screen) + end + end + -- Snapshot the IDs: a callback may schedule another tween, which starts + -- next frame, or retire the admission, which cancels the remaining work. + local ids={};for id in pairs(d.tasks)do ids[#ids+1]=id end;table.sort(ids) + for _,id in ipairs(ids)do + if context()~=d or screen.game.stack:top()~=screen then return false end + local task=d.tasks[id] + if task then + task.frames=task.frames+1 + callback(task.step,math.min(1,task.frames/task.total),task) + if context()~=d then return false end + if task.frames>=task.total then d.tasks[id]=nil;callback(task.done)end + end + end + if context()==d and screen.game.stack:top()==screen and d.onEnd and not screen.gen3AnimPlaying then + local done=d.onEnd;d.onEnd,d.clip=nil,nil;callback(done) + elseif context()==d and not screen.gen3AnimPlaying then d.clip=nil + end + return true + end) +end +function A._holdsNativeQueue(screen) + local d,actual=context();local clip=d and d.clip + return actual==screen and clip~=nil and clip.player==screen.gen3Anim + and clip.serial==screen.gen3Anim.presentationSerial and screen.gen3AnimPlaying==true +end +local publicUpdateStep='source.animation.public' +local function update(dt) + if Boundary.active('battle.animation.update')then return Boundary.invoke('battle.animation.update')end + local _,screen=context() + if not screen or not Clock.active(screen)then return false,'animation update belongs to its native battle phase'end + if screen.game.stack:top()~=screen then return false,'native battle is covered by another state'end + return Clock.once(screen,publicUpdateStep,function() + A._nativeStep(screen,Clock.delta(screen)) + if Battle._screen()~=screen or screen.game.stack:top()~=screen then return false,'native battle admission retired'end + -- Explicit source holds and the native body share the same display steps. + require('src.core.Game3')._withCallbackOwner(nil,screen.updatePresentation,screen,Clock.delta(screen)) + return true + end) +end +A.update=update +-- Native frames also notify decoration-only wrappers. If a source update +-- already called its base, do not invoke the public wrapper a second time. +-- A wrapper's base here borrows completed presentation work, never another +-- native frame or command queue. +function A._nativeAfterUpdate(screen) + if A.update==update or not Clock.active(screen)or Clock.called(screen,publicUpdateStep)then return end + local _,actual=context() + if actual~=screen or screen.game.stack:top()~=screen then return end + return Clock.once(screen,publicUpdateStep,function() + return Boundary.dispatch('battle.animation.update',function() + if Battle._screen()~=screen or screen.game.stack:top()~=screen then return false,'native battle admission retired'end + A._nativeStep(screen,Clock.delta(screen));return true + end,function()return Call.call(A,'update',Clock.delta(screen))end) + end) +end +function A.launchMove(moveId,opts) + opts=opts or{};local d,screen=context() + if not d then return false,'no admitted native battle'end + if screen.game.stack:top()~=screen then return false,'native battle is covered by another state'end + if screen.gen3AnimPlaying then return false,'native animation is already active'end + local native=d.ids:native('moves',moveId);local anim=screen.gen3Anim + if not native or not anim or not anim:has(native,opts)then return false,'no native animation record'end + if opts.miss then return false,'native miss presentation requires its authored command'end + local attacker=A.idOf(opts.attackerId or opts.attackerSide or'player') + local target=A.idOf(opts.targetId or opts.targetSide or'enemy') + if attacker==nil or target==nil or not battler(screen,attacker)or not battler(screen,target)then + return false,'animation requires admitted native battlers' + end + local done=owned(opts.onEnd) + if not require('src.core.Game3')._withCallbackOwner(nil,anim.start,anim,native,attacker%2==0,attacker,target,opts,screen)then return false end + screen.gen3AnimPlaying=true;d.onEnd=done;d.clip={player=anim,serial=anim.presentationSerial} + return true +end +function A.reset() + retire();Coords._service.release();return true +end +function A._nativeExit(screen) + if current and current.screen[1]==screen then A.reset()end +end +-- Applied around the existing native sprite draw, preserving its scanlines, +-- tints, substitutes and affine effects. Only source overrides compose here; +-- querying present() never feeds native effects back into themselves. +function A._drawBattler(screen,b,body,...) + local d,actual=context();local id=b.position or(b.isPlayer and 0 or 1) + local p=d and actual==screen and d.overrides[id] + if not p or next(p)==nil then return body(screen,b,...)end + if p.visible==false or p.invisible or p.battlerInvisible or p.blinkHidden then return end + if not(p.hFlip or(p.ox or 0)~=0 or(p.oy or 0)~=0 or(p.rotation or 0)~=0 + or(p.scale or 1)~=1 or(p.sx or 1)~=1 or(p.sy or 1)~=1 or(p.alpha or 1)~=1)then + return body(screen,b,...) + end + local args={...};local x,y,scale=args[1],args[2],args[3]or 1 + local img=screen:battlerPic(b);local w,h=img:getDimensions() + local cx,cy=x+w*scale/2,y+h*scale/2 + local ok,result=Scope.call(function() + local g=love.graphics + g.translate(cx+(p.ox or 0),cy+(p.oy or 0));g.rotate(p.rotation or 0) + g.scale((p.scale or 1)*(p.sx or 1)*(p.hFlip and -1 or 1),(p.scale or 1)*(p.sy or 1)) + g.translate(-cx,-cy) + local r,green,blue,a=g.getColor();g.setColor(r,green,blue,a*(p.alpha or 1)) + return body(screen,b,x,y,scale) + end) + if not ok then error(result,0)end + return result +end +A._service={release=A.reset,poll=function()context()end,releaseOwner=function(_,owner) + if current and current.users[owner]then A.reset()end +end} +require('src.core.game3.asset_stream').register(A._service) +return A From 1fee34a4e8dc307781ff4c3d18d0959b48229aee Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 12:37:01 +0100 Subject: [PATCH 55/75] Factor the existing older native ball tick without changing its body --- src/battle/BattleState.lua | 47 +++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 2aa60d695..09639f720 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2609,6 +2609,29 @@ function BattleState:beginMsgLine() self.shown[#self.shown + 1] = {} end +function BattleState:stepGen3Ball(anim) + anim:update() + if anim.sound then + require("src.core.Sound").playId(self.data, anim.sound) + end + -- the trainer is gone by the time his Pokemon is out of the ball + -- ...unless the trainer's own walk-off is still running (#407): the + -- cartridge frees that sprite at the end of the fifty frames, not when + -- the ball opens, and cutting it short hides most of the throw + if anim.sendOut == "player" and self.showPlayerBack + and anim.phase ~= "throw" + and not (self.backWalkOff and self.backWalkOff > 0) then + self.showPlayerBack = false + self:slidePic("back") + end + if anim:isDone() then + self.gen3BallPlaying = false + -- a caught Pokemon leaves the ball resting on screen through the text, + -- exactly as the Game Boy chain does + if not anim.caught then self.gen3Ball = nil end + end +end + function BattleState:updateQueue() if self.waitingUI then if self.game.stack:top() ~= self then return true end @@ -2641,29 +2664,7 @@ function BattleState:updateQueue() -- ...and the ball's own timeline, ticked in the same place and for the same -- reason: its frames are counted where the hold is counted, or it would -- stand still for exactly as long as it is meant to be moving. - if self.gen3BallPlaying and self.gen3Ball then - local anim = self.gen3Ball - anim:update() - if anim.sound then - require("src.core.Sound").playId(self.data, anim.sound) - end - -- the trainer is gone by the time his Pokemon is out of the ball - -- ...unless the trainer's own walk-off is still running (#407): the - -- cartridge frees that sprite at the end of the fifty frames, not when - -- the ball opens, and cutting it short hides most of the throw - if anim.sendOut == "player" and self.showPlayerBack - and anim.phase ~= "throw" - and not (self.backWalkOff and self.backWalkOff > 0) then - self.showPlayerBack = false - self:slidePic("back") - end - if anim:isDone() then - self.gen3BallPlaying = false - -- a caught Pokemon leaves the ball resting on screen through the text, - -- exactly as the Game Boy chain does - if not anim.caught then self.gen3Ball = nil end - end - end + if self.gen3BallPlaying and self.gen3Ball then self:stepGen3Ball(self.gen3Ball)end -- a queued hold (faint slide, hit blink) counts down before the next row if self.waitFrames and self.waitFrames > 0 then self.waitFrames = self.waitFrames - 1 From 40adea989c79ab9144e8e4c20b0f5ed9e4ee2df2 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 12:47:01 +0100 Subject: [PATCH 56/75] Borrow actual native opponent send-out ticks for the existing visual tween seam --- src/battle/BattleState.lua | 10 +- src/core/game3/battle/anim.lua | 27 ++- src/core/game3/battle/intro_seq.lua | 10 +- src/core/game3/battle/sendout_tween.lua | 167 ++++++++++++++++++ tests/engine/native_sendout_tween.lua | 157 ++++++++++++++++ .../engine/native_sendout_tween_admission.lua | 48 +++++ 6 files changed, 413 insertions(+), 6 deletions(-) create mode 100644 src/core/game3/battle/sendout_tween.lua create mode 100644 tests/engine/native_sendout_tween.lua create mode 100644 tests/engine/native_sendout_tween_admission.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 09639f720..de355db2b 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2664,7 +2664,10 @@ function BattleState:updateQueue() -- ...and the ball's own timeline, ticked in the same place and for the same -- reason: its frames are counted where the hold is counted, or it would -- stand still for exactly as long as it is meant to be moving. - if self.gen3BallPlaying and self.gen3Ball then self:stepGen3Ball(self.gen3Ball)end + local sendout=GameVersion.isGen3()and package.loaded['src.core.game3.battle.sendout_tween'] + local borrowed=sendout and sendout._step(self,self.gen3Ball,BattleState.stepGen3Ball) + if self.gen3BallPlaying and self.gen3Ball and not borrowed then self:stepGen3Ball(self.gen3Ball)end + if sendout and sendout._holds(self)then return true end -- a queued hold (faint slide, hit blink) counts down before the next row if self.waitFrames and self.waitFrames > 0 then self.waitFrames = self.waitFrames - 1 @@ -10332,7 +10335,10 @@ function BattleState:gen3SendOut(battler) end end self.nextInsert = (self.nextInsert or 0) + 1 - table.insert(self.queue, self.nextInsert, { wait = anim:estimate() }) + local waitRow={wait=anim:estimate()} + table.insert(self.queue,self.nextInsert,waitRow) + local source=package.loaded['src.core.game3.battle.anim'] + if source then source._nativeSendOut(self,anim,waitRow)end return true end diff --git a/src/core/game3/battle/anim.lua b/src/core/game3/battle/anim.lua index 0276161bc..4ff091328 100644 --- a/src/core/game3/battle/anim.lua +++ b/src/core/game3/battle/anim.lua @@ -16,6 +16,7 @@ function A.drawParticles(lo,hi) return require('src.core.game3.battle.particle_pass').draw(lo,hi,require('src.compat.gen1.SourceCall').owner(A,'drawParticles')) end local current +local tweenOffers=require('src.compat.gen1.CoroutineScopes').new() local numeric={ox=true,oy=true,alpha=true,scale=true,sx=true,sy=true,rotation=true, darken=true,flash=true,displayHp=true,displayMaxHp=true,displayLevel=true,displayExp=true} local function retire() @@ -164,12 +165,16 @@ function A.beginScreenEffect()local e=effect();return e and e:begin()or false en function A.endScreenEffect()local e=effect();return e and e:finish()or false end function A.busy() local d,screen=context() + local sendout=package.loaded['src.core.game3.battle.sendout_tween'] return d~=nil and(screen.gen3AnimPlaying==true or screen.gen3BallPlaying==true - or A.hpTweening()or screen.expFilling==true or next(d.tasks)~=nil)or false + or A.hpTweening()or screen.expFilling==true or next(d.tasks)~=nil + or sendout and sendout._busy(screen))or false end function A.setSeqBusy(value)local d=context();if d then d.seqBusy=value==true end end function A.seqBusy()local d=context();return d and d.seqBusy==true or false end -function A.tweenStage(frames,onStep,onComplete) +local function tweenStage(frames,onStep,onComplete) + local offer=tweenOffers:find('native.sendout.tween') + if offer then return offer.value(frames,onStep,onComplete)end local d=context();if not d then return nil,'no admitted native battle'end assert(number(frames)and frames>=0,'invalid presentation duration') d.nextTask=d.nextTask+1 @@ -177,6 +182,20 @@ function A.tweenStage(frames,onStep,onComplete) d.tasks[task.id]=task return task end +A.tweenStage=tweenStage +function A._nativeSendOut(screen,anim,row) + if A.tweenStage==tweenStage then return false end + local _,actual=context();if actual~=screen then return false end + return require('src.core.game3.battle.sendout_tween')._begin(screen,anim,row,A.tweenStage, + function()return A.tweenStage end,function(configure,frames,step,complete) + local offer=tweenOffers:push('native.sendout.tween',configure) + local values={pcall(Call.call,A,'tweenStage',frames,step,complete)} + local retired=offer.closed;tweenOffers:close(offer) + if retired then error('native send-out tween offer retired',0)end + if not values[1]then error(values[2],0)end + return values[2] + end) +end function A._nativeStep(screen,dt) if not Clock.active(screen)then return false,'animation update belongs to its native battle phase'end local d,actual=context();if actual~=screen or screen.game.stack:top()~=screen then return false end @@ -210,6 +229,8 @@ function A._nativeStep(screen,dt) end) end function A._holdsNativeQueue(screen) + local sendout=package.loaded['src.core.game3.battle.sendout_tween'] + if sendout and sendout._holds(screen)then return true end local d,actual=context();local clip=d and d.clip return actual==screen and clip~=nil and clip.player==screen.gen3Anim and clip.serial==screen.gen3Anim.presentationSerial and screen.gen3AnimPlaying==true @@ -263,6 +284,8 @@ function A.launchMove(moveId,opts) return true end function A.reset() + local sendout=package.loaded['src.core.game3.battle.sendout_tween'] + if sendout then sendout._cancel()end retire();Coords._service.release();return true end function A._nativeExit(screen) diff --git a/src/core/game3/battle/intro_seq.lua b/src/core/game3/battle/intro_seq.lua index 70ef2d24d..babc35e51 100644 --- a/src/core/game3/battle/intro_seq.lua +++ b/src/core/game3/battle/intro_seq.lua @@ -16,6 +16,9 @@ local function synchronize(screen,token) -- The native send-out inserts its own wait after the original entry was -- bound. That dynamic row is not in token.rows; looking ahead through it -- would expose the following message while the actual ball is still active. + local sendout=package.loaded['src.core.game3.battle.sendout_tween'] + local held=sendout and sendout._phase(screen,token) + if held then token.index=held;return end local ball=screen.gen3Ball if screen.gen3BallPlaying and ball then local kind=ball.sendOut=='player'and ball.battler==screen.player and'player_throw' @@ -141,8 +144,11 @@ setmetatable(M,{__index=function(_,key) if key=='_steps'then return token.steps end if key=='_i'then return token.index end if key=='_waitingMsg'then return screen.current~=nil and screen.current.text~=nil end - if key=='_waiting'then return screen.current~=nil or screen.waitingUI==true - or screen.gen3BallPlaying==true or(screen.waitFrames or 0)>0 end + if key=='_waiting'then + local sendout=package.loaded['src.core.game3.battle.sendout_tween'] + return screen.current~=nil or screen.waitingUI==true or screen.gen3BallPlaying==true + or(screen.waitFrames or 0)>0 or sendout and sendout._busy(screen)or false + end end,__newindex=function(t,key,value) if not observed[key]then rawset(t,key,value);return end local screen,token=current() diff --git a/src/core/game3/battle/sendout_tween.lua b/src/core/game3/battle/sendout_tween.lua new file mode 100644 index 000000000..0d9fb690d --- /dev/null +++ b/src/core/game3/battle/sendout_tween.lua @@ -0,0 +1,167 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- A visual lease over the real opponent send-out, never a second ball player. +local Battle=require('src.core.game3.battle') +local G=require('src.core.Game3') +local Clock=require('src.battle.PresentationClock') +local M={} +local Coroutines=require('src.compat.gen1.CoroutineScopes') +local processing=Coroutines.new() +local current +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +local function finite(v)return type(v)=='number'and v==v and math.abs(v)=c.duration then return 35 end + if tick<=c.wait then return tick*16/c.wait end + return 16+(tick-c.wait)*12/c.fade +end +local function nativeAt(c,u) + local frame=u*35 + local n=frame<=16 and frame*c.wait/16 + or c.wait+math.min(12,frame-16)*c.fade/12 + return math.min(c.duration,math.floor(n+1e-7)) +end +local function advance(c,u) + local s,a=live(c) + local phase=processing:find('native.sendout.step') + if not phase or phase.value~=c or not s or not c.active or not c.processing or not c.body + or not Clock.active(s)or s.game.stack:top()~=s then + return false,'native send-out callback is outside its visual phase' + end + assert(finite(u)and u>=0 and u<=1,'invalid native send-out progress') + local target=nativeAt(c,u) + assert(target>=c.desired,'native send-out cannot rewind') + c.desired=target + if c.nativeCompleted then return true end + while a.frame=1 and fade>=1 + and wait%1==0 and fade%1==0 and wait+fade<=4096)then return false end + if current then release(current)end + local c={refs=setmetatable({screen,screen.game.data,screen.game.save,screen.game.stack, + screen.game.mods,anim,screen.enemy,row,token,Battle.getState()},{__mode='v'}), + wait=wait,fade=fade,duration=wait+fade,index=index,desired=0,tick=0,active=true, + hook=hook,hookNow=hookNow} + current=c + local configured=false + local function configure(frames,onStep,onComplete) + assert(native(c)and Clock.active(screen),'native send-out offer retired') + if configured then return c.public end + assert(finite(frames)and frames>=0 and frames<=65536,'invalid send-out presentation duration') + assert(onStep==nil or type(onStep)=='function','invalid send-out step callback') + assert(onComplete==nil or type(onComplete)=='function','invalid send-out completion callback') + configured=true;c.total=math.max(1,math.ceil(frames));c.owner=G._callbackOwner() + c.step,c.complete=onStep,onComplete + c.public={frames=0,total=c.total} -- Detached observation; writes grant no authority. + c.displayTotal=c.total==35 and c.duration or c.total + return c.public + end + local result=pack(pcall(dispatch,configure,35,function(u)return advance(c,u)end,function() + return c.processing and c.active and true or false + end)) + if not result[1]then release(c);error(result[2],0)end + if not configured or not native(c)or hookNow()~=hook then release(c);return false end + return true +end +function M._step(screen,anim,body) + local c=current + if not c or c.refs[1]~=screen or(c.refs[6]~=anim and not(c.nativeCompleted and anim==nil))then return false end + local s,a=live(c);if s~=screen then return false end + if not c.active then + if not a or a:isDone()then release(c);return false end + body(screen,anim) + if anim:isDone()then release(c)end + return true + end + if not Clock.active(screen)or screen.game.stack:top()~=screen then return true end + Clock.once(screen,'native.sendout.tween',function() + if not live(c)then return end + c.tick=c.tick+1 + local f=c.total==35 and canonicalAt(c,math.min(c.tick,c.duration))or c.tick + local task={frames=f,total=c.total} + c.public.frames=f;c.processing,c.body=true,body + local phase=processing:push('native.sendout.step',c) + local values=pack(pcall(function() + call(c,c.step,math.min(1,f/c.total),task) + assert(not Coroutines.retired()and not phase.closed,'native send-out phase retired while suspended') + if not native(c)then return end + if c.tick>=c.displayTotal then call(c,c.complete);c.active=false end + end)) + processing:close(phase) + c.processing,c.body=false,nil + if not values[1]then c.active=false;clearCallbacks(c);error(values[2],0)end + if not c.active then clearCallbacks(c)end + end) + return true +end +local function waiting(c,s) + if not c.waitAdmitted then + for _,row in ipairs(s.queue or{})do if row==c.refs[8]then return false end end + c.waitAdmitted=true + end + return(s.waitFrames or 0)<=0 +end +function M._holds(screen) + local c=current;local s,a=live(c) + return s==screen and waiting(c,s)and(c.active or s.gen3BallPlaying and s.gen3Ball==a)or false +end +function M._phase(screen,token) + local c=current;local s=live(c) + return s==screen and c.active and c.refs[9]==token and c.index or nil +end +function M._busy(screen)local c=current;return live(c)==screen and c.active or false end +function M._cancel() + if current then current.active=false;clearCallbacks(current)end +end +M._service={release=function()if native(current)then M._cancel()else release(current)end end,poll=function()if current then live(current)end end, + releaseOwner=function(_,owner)if current and current.owner==owner then + current.active=false;clearCallbacks(current) + end end} +require('src.core.game3.asset_stream').register(M._service) +return M diff --git a/tests/engine/native_sendout_tween.lua b/tests/engine/native_sendout_tween.lua new file mode 100644 index 000000000..9d983b304 --- /dev/null +++ b/tests/engine/native_sendout_tween.lua @@ -0,0 +1,157 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local A=require('src.core.game3.battle.anim') +local Intro=require('src.core.game3.battle.intro_seq') +local Battle=require('src.core.game3.battle') +local G=require('src.core.Game3') +local Native=require('src.battle.Gen3Battle') +local Send=require('src.core.game3.battle.sendout_tween') +local Sound=require('src.core.Sound') +local originalGame=package.loaded['src.core.Game'] +local base=A.tweenStage;local centre=Native.battlerCentre;local play=Sound.playId +local function run(edition,mode,shiny) + local f=require('tools.battle-source-runtime.fixture').new(edition) + package.loaded['src.core.Game']=f.game + f.game.data.constants.gen3BallAnim={size=16,frames=3,balls={},images={},timing={sendOut={ + from={x=24,y=68},frames=25,arc=-30,targetOffsetY=24,foeOffsetY=24, + foeWait=16,fadeFrames=14,riseStep=-288}}} + local b=f.fresh('TACKLE').battle;b.kind='trainer' + b.enemy.mon.isShiny=shiny==true;b.player.mon.isShiny=false + Native.battlerCentre=function(_,actor)return actor==b.player and 72 or 176,72 end + f.game.input.wasPressed=function(_,key)return key=='a'end + local trace,sounds,steps={},{},{} + local begins,completes=0,0;local borrowed,returned + local parentUpdate=Battle.update;local parked,retired,errorSeen,weak + local seams=require('src.mods.ModSeams');local journalIndex + Sound.playId=function(data,id,...) + sounds[#sounds+1]=id;return play(data,id,...) + end + if mode=='absent'then package.loaded['src.core.game3.battle.anim']=nil + elseif mode=='default'then A.tweenStage=base + else + A.tweenStage=function(frames,onStep,onComplete) + begins=begins+1;T.eq(frames,35);borrowed=onStep + if mode=='noop'then + local task=base(frames,onStep,onComplete) + T.eq(base(frames,onStep,onComplete),task,'one native offer configures once') + return task + end + returned=base(133,function(_,task) + steps[#steps+1]=task.frames + if mode=='yield'and task.frames==1 then coroutine.yield('sendout suspended')end + if mode=='error'and task.frames==1 then error('source sendout failed')end + if mode=='nonfinite'and task.frames==1 then onStep(0/0)end + local nativeFrame=task.frames<=32 and 0 or task.frames<=57 + and math.floor((task.frames-32)*16/25)or math.min(35,16+task.frames-57) + onStep(nativeFrame/35,{frames=nativeFrame}) + if mode=='extended'and task.frames==57 then + T.eq(b.gen3Ball.phase,'release');T.eq(b.gen3Ball.frame,16,'opening landmark maps to the actual native wait') + end + if mode=='extended'and task.frames==69 then + if shiny then T.eq(b.gen3Ball.phase,'shiny');T.eq(b.gen3Ball.frame,30) + else T.eq(b.gen3Ball,nil,'native emergence finishes at the public appearance landmark')end + end + if mode=='cohort'and task.frames==45 then + local old=f.game.save + f.game.save=require('src.mods.Merge').deepCopy(old) + T.eq(onStep(1),false,'replaced native save admission cannot consume the old callback') + end + if mode=='cancel'and task.frames==45 then A.tweenStage=base end + if mode=='reset'and task.frames==45 then A.reset()end + if mode=='owner'and task.frames==45 then Send._service.releaseOwner(nil,'sendout_owner')end + end,function()completes=completes+1;onComplete()end) + returned.total=1 -- Detached source observations cannot shorten the native lease. + return returned + end + end + if mode=='owner'then journalIndex=#seams._journal+1;seams._journal[journalIndex]={tbl=A,key='tweenStage',after=A.tweenStage,owner='sendout_owner'}end + f.game.stack:push(b) + local sawReturn=false + for tick=1,2000 do + if mode=='yield'and borrowed and not retired then + Battle.update=function(...) + local args={...} + parked=coroutine.create(function()return parentUpdate(unpack(args))end) + local ok,why=coroutine.resume(parked);T.eq(ok,true);T.eq(why,'sendout suspended') + return true + end + b:update(1/60);Battle.update=parentUpdate + local ball=b.gen3Ball;local before=ball.frame + require('src.battle.PresentationClock').run(b,1/60,function() + T.eq(borrowed(1),false,'suspended source sibling cannot lend an old native step to a fresh frame') + end) + T.eq(ball.frame,before) + local ok,why=coroutine.resume(parked);T.eq(ok,false) + T.check(tostring(why):find('retired',1,true)~=nil,'borrowed native coroutine retires across its original parent') + T.eq(ball.frame,before);retired=true + elseif(mode=='error'or mode=='nonfinite')and borrowed and not errorSeen then + T.raises(function()b:update(1/60)end,mode=='error'and'source sendout failed'or'invalid native send-out progress') + A.tweenStage=base;errorSeen=true + else b:update(1/60)end + local a=b.gen3Ball + if mode=='gc'and borrowed and not weak then + weak=setmetatable({f.game,f.game.save,f.game.data,f.game.stack,b,Battle.getState()},{__mode='v'}) + end + trace[#trace+1]={frame=b.frame,phase=b.phase,queue=#b.queue,wait=b.waitFrames, + ball=a and {side=a.sendOut,tick=a.frame,phase=a.phase,x=a.x,y=a.y}, + playerHP=b.player.mon.hp,enemyHP=b.enemy.mon.hp,pp=b.player.mon.moves[1].pp} + if mode=='extended'and #steps>69 and #steps<133 and not shiny then + sawReturn=true;T.eq(b.phase,'messages');T.eq(Intro._steps[Intro._i].kind,'opponent_sendout') + T.eq(Intro._waiting,true);T.eq(A.busy(),true) + end + if mode=='cohort'and #steps==45 then break end + if b.phase=='menu'and #b.queue==0 and not b.current then break end + assert(tick<2000,'actual native send-out did not release its queue') + end + if mode~='absent'and mode~='default'then T.eq(begins,1,'only the real opponent entry offers this tween')end + if mode=='extended'then + T.eq(#steps,133);T.eq(steps[1],1);T.eq(steps[133],133);T.eq(completes,1) + if not shiny then T.eq(sawReturn,true,'visual return remains owned after native ball retirement')end + end + if mode=='cancel'or mode=='reset'or mode=='owner'then T.eq(#steps,45);T.eq(completes,0,'removed provider receives no stale completion')end + if mode=='cohort'then T.eq(#steps,45);T.eq(completes,0)end + if mode=='yield'then T.eq(retired,true);T.eq(#steps,1);T.eq(completes,0)end + if mode=='error'or mode=='nonfinite'then T.eq(errorSeen,true);T.eq(#steps,1);T.eq(completes,0)end + if borrowed then T.eq(borrowed(1),false,'retained native callback cannot run after its frame')end + if journalIndex then table.remove(seams._journal,journalIndex)end + Battle.update=parentUpdate + package.loaded['src.core.game3.battle.anim']=A;A.tweenStage=base + f.game.stack:pop();Intro._service.release();Send._service.release();A._service.release() + Battle._service.release();f.close();G.release() + return{weak=weak,borrowed=mode=='gc'and borrowed or nil,task=mode=='gc'and returned or nil,trace=trace,sounds=sounds,playerHP=b.player.mon.hp,enemyHP=b.enemy.mon.hp,pp=b.player.mon.moves[1].pp} +end +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local absent=run(edition,'absent');local normal=run(edition,'default');local noop=run(edition,'noop') + T.same(normal,absent,'default native whole intro is identical') + T.same(noop,absent,'a delegating native tween wrapper leaves every native frame and sound identical') + for _,shiny in ipairs{false,true}do + local control=shiny and run(edition,'default',true)or absent + for _,mode in ipairs{'extended','cancel','reset','owner','error','nonfinite','yield'}do + local result=run(edition,mode,shiny) + T.same(result.sounds,control.sounds,'native sound events retain order and occur once') + T.eq(result.playerHP,control.playerHP);T.eq(result.enemyHP,control.enemyHP);T.eq(result.pp,control.pp) + end + end +end +-- A real save admission replacement revokes the old source callback. This +-- retires that battle fixture; no gameplay claim is made for its old cohort. +run('firered','cohort') +local retained=run('firered','gc') +Native.battlerCentre=centre;Sound.playId=play +package.loaded['src.core.Game']=originalGame +require('src.render.Font').load(T.fixtures.fresh()) +for _=1,4 do collectgarbage('collect')end +for i=1,5 do T.eq(retained.weak[i],nil,'retained tween/callback releases native owner '..i)end +-- The existing battle runtime deliberately keeps its last completed scalar +-- snapshot. It must retain neither a native session nor mutation authority. +local completed=retained.weak[6] +if completed then + T.eq(completed.session,nil);T.eq(completed.rng,nil) + T.raises(function()completed.turn=99 end,'native battle view retired') +end +T.eq(retained.borrowed(1),false) +T.eq(retained.task.frames,133);T.eq(retained.task.total,1) +A.tweenStage=base;Native.battlerCentre=centre;Sound.playId=play +package.loaded['src.core.Game']=originalGame +T.finish('native opponent send-out visual lease, unchanged default/noop traces, return hold, shiny tail and cancellation') diff --git a/tests/engine/native_sendout_tween_admission.lua b/tests/engine/native_sendout_tween_admission.lua new file mode 100644 index 000000000..d9b3aeac4 --- /dev/null +++ b/tests/engine/native_sendout_tween_admission.lua @@ -0,0 +1,48 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- This regression uses only the existing public API, so it also runs unchanged +-- on the exact predecessor without overlaying the new bridge or a controller. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local A=require('src.core.game3.battle.anim') +local Intro=require('src.core.game3.battle.intro_seq') +local Battle=require('src.core.game3.battle') +local G=require('src.core.Game3') +local Native=require('src.battle.Gen3Battle') +local originalGame=package.loaded['src.core.Game'] +local base,centre=A.tweenStage,Native.battlerCentre +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f=require('tools.battle-source-runtime.fixture').new(edition) + package.loaded['src.core.Game']=f.game + f.game.data.constants.gen3BallAnim={size=16,frames=3,balls={},images={},timing={sendOut={ + from={x=24,y=68},frames=25,arc=-30,targetOffsetY=24,foeOffsetY=24, + foeWait=16,fadeFrames=14,riseStep=-288}}} + local b=f.fresh('TACKLE').battle;b.kind='trainer' + b.enemy.mon.isShiny=false;b.player.mon.isShiny=false + Native.battlerCentre=function(_,actor)return actor==b.player and 72 or 176,72 end + f.game.input.wasPressed=function(_,key)return key=='a'end + local offers,steps,completed=0,0,0;local last,borrowed + A.tweenStage=function(frames,step,done) + offers=offers+1;T.eq(frames,35);borrowed=step + T.eq(Intro._steps[Intro._i].kind,'opponent_sendout','offer belongs to the actual native phase') + return base(frames,function(u,task) + steps=steps+1;last=u + T.eq(task.total,35);T.check(task.frames>=0 and task.frames<=35) + step(u,task);step(u,task) -- A repeated observation cannot spend a second tick. + end,function()completed=completed+1;done()end) + end + f.game.stack:push(b) + for tick=1,1600 do + b:update(1/60) + if b.phase=='menu'and #b.queue==0 and not b.current then break end + assert(tick<1600,'native entry did not finish') + end + T.eq(offers,1,'native entry must notify the existing tween seam') + T.eq(steps,30,'the imported actual native ball owns the presentation ticks') + T.eq(completed,1);T.eq(last,1) + if borrowed then T.eq(borrowed(1),false,'retained observation has no later native authority')end + T.eq(b.player.mon.hp,400);T.eq(b.enemy.mon.hp,400);T.eq(b.player.mon.moves[1].pp,10) + A.tweenStage=base;f.game.stack:pop();Intro._service.release();A._service.release() + Battle._service.release();f.close();G.release() +end +A.tweenStage=base;Native.battlerCentre=centre;package.loaded['src.core.Game']=originalGame +T.finish('existing tween API observes real native opponent send-outs without imported controller overlays') From c8a4119928b1522951c0c25265fd28c1f51ed1a7 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 12:53:19 +0100 Subject: [PATCH 57/75] Use actual shared shiny genotype in the older native tween fixture --- tests/engine/native_sendout_tween.lua | 6 +++++- tests/engine/native_sendout_tween_admission.lua | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/engine/native_sendout_tween.lua b/tests/engine/native_sendout_tween.lua index 9d983b304..68e3d68ee 100644 --- a/tests/engine/native_sendout_tween.lua +++ b/tests/engine/native_sendout_tween.lua @@ -17,7 +17,11 @@ local function run(edition,mode,shiny) from={x=24,y=68},frames=25,arc=-30,targetOffsetY=24,foeOffsetY=24, foeWait=16,fadeFrames=14,riseStep=-288}}} local b=f.fresh('TACKLE').battle;b.kind='trainer' - b.enemy.mon.isShiny=shiny==true;b.player.mon.isShiny=false + -- Use actual shared personality/OT identity rather than a newer cache flag. + b.enemy.mon.isShiny=nil;b.enemy.mon.shiny=nil;b.enemy.mon.personality=0 + b.enemy.mon.otId=shiny and 0 or 1000;b.enemy.mon.otSecretId=nil + b.player.mon.isShiny=nil;b.player.mon.shiny=nil;b.player.mon.personality=0 + b.player.mon.otId=1000;b.player.mon.otSecretId=nil Native.battlerCentre=function(_,actor)return actor==b.player and 72 or 176,72 end f.game.input.wasPressed=function(_,key)return key=='a'end local trace,sounds,steps={},{},{} diff --git a/tests/engine/native_sendout_tween_admission.lua b/tests/engine/native_sendout_tween_admission.lua index d9b3aeac4..a55af76bf 100644 --- a/tests/engine/native_sendout_tween_admission.lua +++ b/tests/engine/native_sendout_tween_admission.lua @@ -17,7 +17,10 @@ for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do from={x=24,y=68},frames=25,arc=-30,targetOffsetY=24,foeOffsetY=24, foeWait=16,fadeFrames=14,riseStep=-288}}} local b=f.fresh('TACKLE').battle;b.kind='trainer' - b.enemy.mon.isShiny=false;b.player.mon.isShiny=false + for _,actor in ipairs{b.enemy,b.player}do + actor.mon.isShiny=nil;actor.mon.shiny=nil;actor.mon.personality=0 + actor.mon.otId=1000;actor.mon.otSecretId=nil + end Native.battlerCentre=function(_,actor)return actor==b.player and 72 or 176,72 end f.game.input.wasPressed=function(_,key)return key=='a'end local offers,steps,completed=0,0,0;local last,borrowed From 9917a0161fadde5d1acbb2000451b5a96e232b4b Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 12:57:06 +0100 Subject: [PATCH 58/75] Document preserved older native tick and opponent tween composition --- .../opponent-sendout-tween.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/qa/native-foundation/opponent-sendout-tween.md diff --git a/docs/qa/native-foundation/opponent-sendout-tween.md b/docs/qa/native-foundation/opponent-sendout-tween.md new file mode 100644 index 000000000..1fc9f4895 --- /dev/null +++ b/docs/qa/native-foundation/opponent-sendout-tween.md @@ -0,0 +1,60 @@ +# Native opponent send-out visual tween + +The existing public tween seam was never invoked by a native send-out, so a +presentation provider could not begin its opponent throw/return actor. Offer +that seam from the actual opponent ball constructor and drive it from the +same native update owner. No private actor/controller body is included. + +The35-unit presentation protocol has16 opening-delay units,12 emergence units +and7 trainer-exit units. Map those coordinates to the actual imported delay/ +fade durations; they are not advertised as cartridge frame counts. A provider +may extend the visual interval. Each mapped step consumes the existing native +ball update at most once per native tick; sounds/reveal remain in that body. +The actual native wait is admitted once, retained through a visual return and, +when necessary, the remaining native shiny tail. Default and delegating paths +keep the entire native frame/queue/wait/position/HP/PP/sound trace identical. + +Only the genuine native opponent intro is admitted. Detached task observations +grant no timing authority. Callbacks are usable only inside their current +native visual phase/coroutine ancestry; later callers and retired suspended +children cannot spend a saved step. Hook/owner removal, reset and callback +errors discard source callbacks and resume the remaining native ball without +replaying its queue row. Cohort replacement revokes the old observation. Five +native owners collect even when a task/callback is retained; the existing last +completed battle snapshot remains scalar/read-only by design. + +Validation: + +- Frozen `e7f6427315` on fork main `0af0b3cf`:187 CPU groups pass. Its actual + production hashes match the committed implementation. Final focused + `85d2e611df`:13 groups pass, including2120 native tween assertions with + normal/shiny, extension/cancel/reset/owner/error/nonfinite/suspended-child + and cohort/GC cases. The final fixture uses shared personality/OT identity + instead of a newer shiny cache flag; no native Pokemon body is replaced. +- Existing-API admission probe:350 checks pass; identical probe on exact + predecessor `613f788f`, run `7d8c512a91`, reports20 failures out of35 checks + because no offers/callbacks occur (callback-dependent checks are absent). + No new module or native controller is overlaid on that predecessor. +- Older-public composition `7f838044d0`:72 groups pass, including2120+350. + Its qualified native ball tick body is byte-identical before/after the + dispatch adaptation. The raw offered branch reuses the unchanged public + animation adapter prerequisite from #329, and factors its own older inline + ball tick with unchanged body bytes apart from indentation. It does not + copy a newer controller. Previously offered cartridge/stage/clock bindings + remain required for complete raw-branch integration. +- Real Metal Ruby `44952637b4`, unchanged private consumer `bddb5181`, uses + imported TIANA/portrait2. Both native starts and player flight pass; the + genuine opponent actor supplies26 visible flight rows and6 captured + opponent frames. There are108 native clock samples,76 visible ball rows, + and no duplicate cell in the source opening pass or loader errors. The + intro reaches the native command menu and source cleanup completes. + +Earlier GC fixture failure wrongly required collection of the intentional +completed scalar snapshot; its receipt remains. Earlier older-public failure +used a newer shiny cache flag, unsupported by that older native Pokemon body; +that receipt also remains. Shared native genotype fixes only the fixture. +The real images expose a separate missing battler hidden/scale observation +(the mon can appear before opening). That interface, switches, other portraits +and complete scene/FPS acceptance remain separate. Hosted CI is separate. +QA uses owned copies/profiles with a20GiB floor; private files and player saves +remain untouched. From 9a1d61e904fe63ff7c19a693e0cc051c519e3260 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 13:06:43 +0100 Subject: [PATCH 59/75] Project actual native ball visibility and growth into battler observations --- src/core/game3/battle/anim.lua | 9 ++- .../native_ball_battler_presentation.lua | 60 +++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 tests/engine/native_ball_battler_presentation.lua diff --git a/src/core/game3/battle/anim.lua b/src/core/game3/battle/anim.lua index 4ff091328..772d566ec 100644 --- a/src/core/game3/battle/anim.lua +++ b/src/core/game3/battle/anim.lua @@ -67,13 +67,16 @@ local function read(d,id,key) if p[key]~=nil then return p[key]end if key=='id'then return id end if key=='side'then return Coords.sideOf(id)end - if key=='visible'then return not b.fainted and not screen:fxHidden(b)and not screen:gen3AnimMonBgHidden(b)end + local ball=(key=='visible'or key=='scale'or key=='oy') + and screen.gen3BallFor and screen:gen3BallFor(b)or nil + if key=='visible'then return not b.fainted and not(ball and ball.monHidden) + and not screen:fxHidden(b)and not screen:gen3AnimMonBgHidden(b)end if key=='invisible'or key=='battlerInvisible'then return screen:gen3AnimMonBgHidden(b)end if key=='ox'or key=='oy'then local x,y=screen:gen3AnimShake(b);local fx=screen.picFx and screen.picFx[b] - return(key=='ox'and x+(fx and fx.ox or 0)or y+(fx and fx.oy or 0)) + return(key=='ox'and x+(fx and fx.ox or 0)or y+(fx and fx.oy or 0)+(ball and ball.monRise or 0)) end - if key=='scale'then return screen:gen3AnimScale(b)or 1 end + if key=='scale'then return(screen:gen3AnimScale(b)or 1)*(ball and ball.monScale or 1)end if key=='sx'or key=='sy'then local x,y=screen:gen3AnimSquash(b);return(key=='sx'and x or y)or 1 end diff --git a/tests/engine/native_ball_battler_presentation.lua b/tests/engine/native_ball_battler_presentation.lua new file mode 100644 index 000000000..b3116ba9b --- /dev/null +++ b/tests/engine/native_ball_battler_presentation.lua @@ -0,0 +1,60 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local A=require('src.core.game3.battle.anim') +local Battle=require('src.core.game3.battle') +local G=require('src.core.Game3') +local Ball=require('src.battle.Gen3BallAnim') +local Probe=dofile('tests/native_action_plan/turn.lua') +local Copy=require('src.mods.Merge').deepCopy +local original=package.loaded['src.core.Game'] +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + for _,kind in ipairs{'player','opponent','capture'}do + local f,b=Probe.open(edition) + f.game.data.constants.gen3BallAnim={size=16,frames=3,balls={},images={},timing={sendOut={ + from={x=24,y=68},frames=25,arc=-30,targetOffsetY=24,foeOffsetY=24, + foeWait=16,fadeFrames=14,riseStep=-288}}} + local actor=kind=='player'and b.player or b.enemy + local other=actor==b.player and b.enemy or b.player + local id=actor==b.player and 0 or 1;local otherId=id==0 and 1 or 0 + local opts={ball=0,battler=actor,sendOut=kind~='capture'and kind or nil,caught=false,shakes=0} + local anim=Ball.new(f.game.data,opts);local control=Ball.new(f.game.data,opts) + b.gen3Ball,b.gen3BallPlaying=anim,true + b.picFx={[actor]={ox=2,oy=3}} + local p,q=A.present(id),A.present(otherId) + local ticks=0 + while not anim:isDone()do + local frame=b.frame;local nativeFrame=anim.frame;local n=#b.queue + local before=Copy(anim) + for _=1,3 do + T.eq(p.visible,not anim.monHidden,'source visibility observes the actual ball owner') + T.eq(p.scale,(b:gen3AnimScale(actor)or 1)*anim.monScale,'ball scale composes with native move scale') + local x,y=b:gen3AnimShake(actor) + T.eq(p.ox,x+2);T.eq(p.oy,y+3+(anim.monRise or 0),'native rise remains an observation') + T.eq(q.visible,true,'the other battler keeps its own visibility') + T.eq(q.scale,b:gen3AnimScale(other)or 1,'one ball cannot shrink a different battler') + end + T.same(anim,before);T.eq(anim.frame,nativeFrame);T.eq(b.frame,frame);T.eq(#b.queue,n) + T.eq(anim.frame,control.frame);T.eq(anim.phase,control.phase) + anim:update();control:update();ticks=ticks+1;assert(ticks<1000,'native ball did not complete') + end + -- Source-owned presentation overrides keep their established precedence. + anim.monHidden=true;anim.monScale=.25;anim.monRise=-5 + p.visible=true;p.scale=2;p.oy=9 + T.eq(p.visible,true);T.eq(p.scale,2);T.eq(p.oy,9) + p.visible=nil;p.scale=nil;p.oy=nil + T.eq(p.visible,false);T.eq(p.scale,.25);T.eq(p.oy,-2) + -- Controlled existing move-scale query: ball growth must compose rather + -- than erase another native presentation multiplier. + local moveScale=b.gen3AnimScale + b.gen3AnimScale=function(_,target)return target==actor and .5 or 1 end + T.eq(p.scale,.125);T.eq(q.scale,1) + b.gen3AnimScale=moveScale + b.gen3Ball=nil;b.gen3BallPlaying=false + T.eq(p.visible,true);T.eq(p.scale,1);T.eq(p.oy,3,'retirement restores the native move-only offset') + T.eq(control:isDone(),true) + Probe.close(f);A._service.release();Battle._service.release();G.release() + end +end +package.loaded['src.core.Game']=original +T.finish('read-only native ball visibility, composited scale/rise, actor isolation and presentation override precedence') From 0cf4b40a049061a1a9ccabee706342f5b0aa3ee3 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 13:16:21 +0100 Subject: [PATCH 60/75] Keep unthrown battlers hidden during native trainer entry --- src/core/game3/battle/anim.lua | 15 +++++++++++-- .../native_ball_battler_presentation.lua | 22 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/core/game3/battle/anim.lua b/src/core/game3/battle/anim.lua index 772d566ec..25b7feaef 100644 --- a/src/core/game3/battle/anim.lua +++ b/src/core/game3/battle/anim.lua @@ -69,8 +69,19 @@ local function read(d,id,key) if key=='side'then return Coords.sideOf(id)end local ball=(key=='visible'or key=='scale'or key=='oy') and screen.gen3BallFor and screen:gen3BallFor(b)or nil - if key=='visible'then return not b.fainted and not(ball and ball.monHidden) - and not screen:fxHidden(b)and not screen:gen3AnimMonBgHidden(b)end + if key=='visible'then + -- Before a ball exists, the native entry/switch flags already own the + -- empty slot. A source capture must not expose the queued Pokemon while + -- the trainer is still arriving or the send-out text is being printed. + local hidden + if id>=2 then hidden=screen:sideArriving(id%2==0,screen.introSlide) + elseif b==screen.player then + hidden=screen.showPlayerBack or screen.sendingOut or screen.safari + or screen:demoHidesPlayer() + else hidden=screen.showEnemyTrainer or screen.enemySendingOut or screen.enemyHidden end + return not hidden and not b.fainted and not(ball and ball.monHidden) + and not screen:fxHidden(b)and not screen:gen3AnimMonBgHidden(b) + end if key=='invisible'or key=='battlerInvisible'then return screen:gen3AnimMonBgHidden(b)end if key=='ox'or key=='oy'then local x,y=screen:gen3AnimShake(b);local fx=screen.picFx and screen.picFx[b] diff --git a/tests/engine/native_ball_battler_presentation.lua b/tests/engine/native_ball_battler_presentation.lua index b3116ba9b..7d2285608 100644 --- a/tests/engine/native_ball_battler_presentation.lua +++ b/tests/engine/native_ball_battler_presentation.lua @@ -56,5 +56,27 @@ for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do Probe.close(f);A._service.release();Battle._service.release();G.release() end end +-- Native slots are also empty BEFORE their first ball has been created. +-- Exercise the same flags used by the native picture pass, including the +-- opposite actor and an explicit presentation override. +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f,b=Probe.open(edition) + local p,q=A.present(0),A.present(1) + for _,case in ipairs{{'showPlayerBack',p,q},{'sendingOut',p,q}, + {'showEnemyTrainer',q,p},{'enemySendingOut',q,p},{'enemyHidden',q,p}}do + local flag,target,other=unpack(case);local previous=b[flag] + b[flag]=true + local frame,n=b.frame,#b.queue + for _=1,3 do + T.eq(target.visible,false,'native '..flag..' keeps its unthrown Pokemon inside') + T.eq(other.visible,true,'native pre-throw hiding belongs to one actor') + end + target.visible=true;T.eq(target.visible,true,'explicit source visibility keeps precedence') + target.visible=nil;T.eq(target.visible,false) + T.eq(b.frame,frame);T.eq(#b.queue,n);T.eq(b.gen3Ball,nil,'visibility reads cannot create a ball') + b[flag]=previous;T.eq(target.visible,true,'retired native entry flag restores visibility') + end + Probe.close(f);A._service.release();Battle._service.release();G.release() +end package.loaded['src.core.Game']=original T.finish('read-only native ball visibility, composited scale/rise, actor isolation and presentation override precedence') From daa1541975d51a9805934d18bf76dbb01085174f Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 13:21:27 +0100 Subject: [PATCH 61/75] Record native battler projection and current-main qualification --- .../ball-battler-presentation.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 docs/qa/native-foundation/ball-battler-presentation.md diff --git a/docs/qa/native-foundation/ball-battler-presentation.md b/docs/qa/native-foundation/ball-battler-presentation.md new file mode 100644 index 000000000..868923220 --- /dev/null +++ b/docs/qa/native-foundation/ball-battler-presentation.md @@ -0,0 +1,33 @@ +# Native battler observations during send-out + +The source presentation view previously reported a visible, full-size Pokemon +while its actual native ball still owned an empty slot. It also omitted the +native rise offset. A fitted presentation could consequently show a Pokemon +before its throw or opening, then restart its appearance. + +Observe the existing native ball's `monHidden`, `monScale` and `monRise` for +that ball's battler only. Compose its scale/rise with existing native move +effects. Before a ball exists, observe the native trainer/send-out hiding +flags as well; the opposite slot remains independent. Additional double-battle +slots use the existing native side-arrival query. Explicit source overrides +retain their established precedence. Native draw overrides still use only +explicit overrides, so these observations cannot feed native scale back into +itself. No native controller, command queue or animation clock is changed. + +Validation so far: + +- Exact predecessor `4e807b6d`, run `7237c0a31e`: the same fixture reports + 4,075 failures out of 23,160 assertions. No new controller is overlaid. +- Older-public composition `0b63737b6a`: 73 groups pass, including all + 23,160 visibility/scale/rise assertions across five GBA editions and + player/opponent/capture balls. Existing native bodies remain in place. +- Frozen `2ad0e77282` includes fetched fork main `72c2a946` and the entry + visibility correction: 197 CPU groups pass, including all 23,160 assertions + above and the new main's font, alpha-depth, effects, checkpoint and waterfall + regressions. Native Gen2 regressions remain included. +- Real unchanged-consumer image qualification is recorded below once its + managed run finishes. This is separate from complete gameplay, all + portraits/views and stable frame-rate acceptance. + +QA uses owned profiles and preserved disposable copies with a 20 GiB free +floor. Private consumer sources, ROM originals and player saves are unchanged. From 6ef0432d448ae3611818e1f44c3ba30078f65dea Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 13:23:55 +0100 Subject: [PATCH 62/75] Record real native send-out visibility and frame evidence --- .../native-foundation/ball-battler-presentation.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/qa/native-foundation/ball-battler-presentation.md b/docs/qa/native-foundation/ball-battler-presentation.md index 868923220..fdaf9c006 100644 --- a/docs/qa/native-foundation/ball-battler-presentation.md +++ b/docs/qa/native-foundation/ball-battler-presentation.md @@ -25,9 +25,17 @@ Validation so far: visibility correction: 197 CPU groups pass, including all 23,160 assertions above and the new main's font, alpha-depth, effects, checkpoint and waterfall regressions. Native Gen2 regressions remain included. -- Real unchanged-consumer image qualification is recorded below once its - managed run finishes. This is separate from complete gameplay, all - portraits/views and stable frame-rate acceptance. +- Real Metal Ruby `611cb4757b`, unchanged consumer `bddb5181`, imported TIANA + portrait 2: 595 battler observations, including 553 before the accepted + player send-out with zero premature visible player rows. All 82 observed + ball-hidden rows hide their actual actor. Both native send-outs are admitted; + 108 native clock samples, 76 visible fitted ball rows and 11 captured throw + images are retained. The command menu and cleanup complete without loader + errors; 378 opening-effect passes contain zero duplicate native ball cells. + Images at enemy frames 32/62 and player frames 0/20 confirm empty slots + before release and actual opening emergence. Opening particles still need + fitting to the source scene; this is not complete scene, all-portrait/view + or stable frame-rate acceptance. Hosted CI remains separate. QA uses owned profiles and preserved disposable copies with a 20 GiB free floor. Private consumer sources, ROM originals and player saves are unchanged. From 968f9fccc0fc031959de92003b55ae30703e6d94 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 13:29:02 +0100 Subject: [PATCH 63/75] Fit native send-out opening effects to the existing ball projection --- src/core/game3/battle/ball_open.lua | 31 ++++++- .../engine/native_ball_opening_projection.lua | 87 +++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 tests/engine/native_ball_opening_projection.lua diff --git a/src/core/game3/battle/ball_open.lua b/src/core/game3/battle/ball_open.lua index 3afcc2b7c..12679ed25 100644 --- a/src/core/game3/battle/ball_open.lua +++ b/src/core/game3/battle/ball_open.lua @@ -66,7 +66,36 @@ end local function draw() if Boundary.active('battle.ball.draw')then return Boundary.invoke('battle.ball.draw')end local screen=Battle._screen();if not screen then return false,'no admitted native battle'end - return require('src.battle.Gen3Battle').drawBallEffects(screen) + local native=require('src.battle.Gen3Battle') + local anim=screen.gen3Ball + local api=require('src.core.game3.battle.ui').presentation + -- A send-out's fitted cell and its opening ring have the same coordinate + -- owner. Capture providers already transform their entire opening pass; + -- leave that existing protocol and the native whole-layer boundary alone. + if not(anim and anim.sendOut and api and type(api.ballPosition)=='function')then + return native.drawBallEffects(screen) + end + local stage=require('src.core.game3.battle.anim').stage() + local ball=stage and stage.ball + if not ball then return false,'native opening ball retired'end + local stack,top,view=screen.game.stack,screen.game.stack:top(),Battle.getState() + local result=pack(Scope.call(function() + local x,y,scale=Call.call(api,'ballPosition',ball) + if Battle._screen()~=screen or Battle.getState()~=view or screen.gen3Ball~=anim + or screen.game.stack~=stack or stack:top()~=top then + return false,'native opening presentation admission changed' + end + if x~=nil then + scale=scale or 1 + assert(finite(x)and finite(y)and finite(scale)and scale>=0,'invalid opening projection') + if scale==0 then return true end + local g=love.graphics + g.translate(x,y);g.scale(scale,scale);g.translate(-anim.x,-anim.y) + end + return native.drawBallEffects(screen) + end)) + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) end M.draw=draw function M._nativeDraw(screen,body) diff --git a/tests/engine/native_ball_opening_projection.lua b/tests/engine/native_ball_opening_projection.lua new file mode 100644 index 000000000..ba02bb45f --- /dev/null +++ b/tests/engine/native_ball_opening_projection.lua @@ -0,0 +1,87 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Native=require('src.battle.Gen3Battle') +local Opening=require('src.core.game3.battle.ball_open') +local Ui=require('src.core.game3.battle.ui') +local Battle=require('src.core.game3.battle') +local G=require('src.core.Game3') +local Assets=require('src.render.Assets') +local Ball=require('src.battle.Gen3BallAnim') +local Copy=require('src.mods.Merge').deepCopy +local originalGame=package.loaded['src.core.Game'] +local originalApi,originalImage=Ui.presentation,Assets.image +local g=love.graphics;local saved={} +for _,name in ipairs{'push','pop','getStackDepth','translate','scale','draw','rectangle'}do saved[name]=g[name]end +local state,stack,calls +local function fresh()state={x=0,y=0,sx=1,sy=1};stack={};calls={}end +g.push=function()stack[#stack+1]=Copy(state)end +g.pop=function()state=assert(table.remove(stack))end +g.getStackDepth=function()return #stack end +g.translate=function(x,y)state.x=state.x+x*state.sx;state.y=state.y+y*state.sy end +g.scale=function(x,y)state.sx=state.sx*x;state.sy=state.sy*(y or x)end +local function record(kind,path,x,y,w,h) + calls[#calls+1]={kind=kind,path=path,x=state.x+x*state.sx,y=state.y+y*state.sy,w=w*state.sx,h=h*state.sy} +end +g.draw=function(image,q,x,y)record('image',image.path,x,y,q.w,q.h)end +g.rectangle=function(kind,x,y,w,h)record(kind,false,x,y,w,h)end +Assets.image=function(path)return{path=path,getDimensions=function()return 48,192 end}end +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f=require('tools.battle-source-runtime.fixture').new(edition) + package.loaded['src.core.Game']=f.game + local b=f.fresh('TACKLE').battle;b.enter=false;f.game.stack:push(b) + f.game.data.constants.gen3BallAnim={size=16,frames=3,particleFrames=8,balls={},timing={}, + images={balls='owned-ball',particles='owned-particles'}} + for _,kind in ipairs{'player','opponent','capture'}do + local a=Ball.new(f.game.data,{ball=0,battler=kind=='player'and b.player or b.enemy, + sendOut=kind~='capture'and kind or nil}) + b.gen3Ball,b.gen3BallPlaying=a,true;a.x,a.y=52,71 + a.particles={age=2,life=12,radius=20,count=3,x=52,y=71} + a.shinyStars={age=20,made=2};a.stars={age=12} + local before,frame=Copy(a),b.frame + fresh();Native.drawBallEffects(b);local baseline=Copy(calls) + T.check(#baseline>3,'actual ring, shiny and capture star painters are exercised') + local requested=0 + Ui.presentation={ballPosition=function(view) + requested=requested+1;T.eq(view.battler,kind=='player'and 0 or 1) + return 120,88,.5 + end} + fresh();Opening.draw() + if kind=='capture'then + T.eq(requested,0,'capture providers retain their existing whole-pass transform') + T.same(calls,baseline) + else + T.eq(requested,1,'one current source projection per effect pass') + T.eq(#calls,#baseline,'projection neither duplicates nor drops native effects') + for i,row in ipairs(baseline)do + local fitted=calls[i] + T.eq(fitted.kind,row.kind);T.eq(fitted.path,row.path) + T.eq(fitted.x,120+(row.x-a.x)*.5);T.eq(fitted.y,88+(row.y-a.y)*.5) + T.eq(fitted.w,row.w*.5);T.eq(fitted.h,row.h*.5) + end + T.same(state,{x=0,y=0,sx=1,sy=1},'opening transform cannot leak to the HUD') + Ui.presentation.ballPosition=function()return -10000,-10000,0 end + fresh();Opening.draw();T.eq(#calls,0,'suppressed source cell suppresses its native opening too') + Ui.presentation.ballPosition=function()return nil end + fresh();Opening.draw();T.same(calls,baseline,'an absent projection preserves native geometry') + Ui.presentation.ballPosition=function()return 120,88,0/0 end + fresh();T.raises(Opening.draw,'invalid opening projection');T.eq(#calls,0);T.eq(#stack,0) + Ui.presentation.ballPosition=function()g.translate(7,9);error('source projection failed')end + fresh();T.raises(Opening.draw,'source projection failed') + T.same(state,{x=0,y=0,sx=1,sy=1});T.eq(#stack,0) + Ui.presentation.ballPosition=function()b.gen3Ball=nil;return 120,88,.5 end + fresh();local ok,why=Opening.draw();T.eq(ok,false);T.eq(why,'native opening presentation admission changed') + T.eq(#calls,0);b.gen3Ball=a + end + Ui.presentation.ballPosition=function()error('whole native ball must not borrow a source projection')end + fresh();Native.drawBall(b) + local count=0;for _,row in ipairs(calls)do if row.path=='owned-ball'then count=count+1 end end + T.eq(count,1,'native whole-layer boundary keeps its own complete painter') + T.same(a,before,'fitting cannot alter or advance the native ball');T.eq(b.frame,frame) + Ui.presentation=nil + end + b.gen3Ball=nil;f.game.stack:pop();Battle._service.release();f.close();G.release() +end +for name,fn in pairs(saved)do g[name]=fn end +Assets.image,Ui.presentation=originalImage,originalApi;package.loaded['src.core.Game']=originalGame +T.finish('native opening effects share the fitted send-out cell while capture, native painting and graphics ownership remain intact') From 3adaa863ec1e4bcf6e6d177451b08b773b960db9 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 13:30:32 +0100 Subject: [PATCH 64/75] Keep projection predecessor diagnostics complete when callbacks are absent --- tests/engine/native_ball_opening_projection.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/engine/native_ball_opening_projection.lua b/tests/engine/native_ball_opening_projection.lua index ba02bb45f..d7ba511bd 100644 --- a/tests/engine/native_ball_opening_projection.lua +++ b/tests/engine/native_ball_opening_projection.lua @@ -65,9 +65,9 @@ for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do Ui.presentation.ballPosition=function()return nil end fresh();Opening.draw();T.same(calls,baseline,'an absent projection preserves native geometry') Ui.presentation.ballPosition=function()return 120,88,0/0 end - fresh();T.raises(Opening.draw,'invalid opening projection');T.eq(#calls,0);T.eq(#stack,0) + fresh();T.raises(Opening.draw,'invalid opening projection','nonfinite source projection is rejected');T.eq(#calls,0);T.eq(#stack,0) Ui.presentation.ballPosition=function()g.translate(7,9);error('source projection failed')end - fresh();T.raises(Opening.draw,'source projection failed') + fresh();T.raises(Opening.draw,'source projection failed','source projection failure is reported') T.same(state,{x=0,y=0,sx=1,sy=1});T.eq(#stack,0) Ui.presentation.ballPosition=function()b.gen3Ball=nil;return 120,88,.5 end fresh();local ok,why=Opening.draw();T.eq(ok,false);T.eq(why,'native opening presentation admission changed') From 20c3ecabfd29c247c48ef5dda53df6663d4670dc Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 13:33:07 +0100 Subject: [PATCH 65/75] Document source-fitted native opening geometry and qualification --- .../ball-opening-projection.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/qa/native-foundation/ball-opening-projection.md diff --git a/docs/qa/native-foundation/ball-opening-projection.md b/docs/qa/native-foundation/ball-opening-projection.md new file mode 100644 index 000000000..1fa558f1b --- /dev/null +++ b/docs/qa/native-foundation/ball-opening-projection.md @@ -0,0 +1,37 @@ +# Fit native opening effects to a source send-out + +A source renderer can fit a send-out ball through its existing +`Ui.presentation.ballPosition` callback. Its cell then follows the scene, +while the borrowed native opening ring previously remained in cartridge +screen coordinates, producing oversized particles away from the opening. + +For a standalone send-out opening pass, borrow that same current callback +and map the actual native effect origin/size to its returned position/scale. +Use the existing native ring, shiny and star painters; no private renderer, +new particle timeline or native controller is introduced. Zero scale hides +the opening with its cell; an absent projection retains native geometry. +Reject nonfinite geometry or an admission changed by the callback. The +existing graphics guard restores caller state even after a provider error. + +Capture providers already transform their whole opening pass, so capture +does not borrow this projection again. Base calls inside the native whole-ball +boundary retain its complete native painter. Unmodified presentation remains +native, and no projected observation feeds back into native drawing. + +Validation: + +- Focused `8191e50543`: 16 groups pass, including 460 native effect/projection + checks across five GBA editions and player/opponent/capture balls. +- Frozen `5fe451e231` on fork main `72c2a946`: 198 CPU groups pass. The later + fixture-only diagnostic correction adds error descriptions; production + hashes and positive assertions are unchanged. +- Exact predecessor `85b7ae61`, `ed19cde0c3`: 240 failures out of 450 checks. + The ten callback-local checks are absent because the predecessor never + invokes the provider. The earlier negative run hit a missing fixture error + description after detecting wrong geometry; its receipt is retained. +- Older-public composition and real unchanged-consumer image qualification + are recorded after their managed runs finish. All views/portraits, capture + image acceptance and stable frame rates remain separate. + +Owned QA profiles/copies only, with a 20 GiB free floor. Private sources, +original ROMs and player saves/profiles are unchanged. From b35eeb087a3931684eedf351fa3024e95bd72655 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 13:35:00 +0100 Subject: [PATCH 66/75] Record older public opening-projection compatibility --- docs/qa/native-foundation/ball-opening-projection.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/qa/native-foundation/ball-opening-projection.md b/docs/qa/native-foundation/ball-opening-projection.md index 1fa558f1b..4a2a3da89 100644 --- a/docs/qa/native-foundation/ball-opening-projection.md +++ b/docs/qa/native-foundation/ball-opening-projection.md @@ -29,9 +29,11 @@ Validation: The ten callback-local checks are absent because the predecessor never invokes the provider. The earlier negative run hit a missing fixture error description after detecting wrong geometry; its receipt is retained. -- Older-public composition and real unchanged-consumer image qualification - are recorded after their managed runs finish. All views/portraits, capture - image acceptance and stable frame rates remain separate. +- Older-public composition `4402bdd757`: 74 groups pass, including all 460 + projection assertions, with its existing native controllers retained. +- Real unchanged-consumer image qualification is recorded after its managed + run finishes. All views/portraits, capture image acceptance and stable frame + rates remain separate. Owned QA profiles/copies only, with a 20 GiB free floor. Private sources, original ROMs and player saves/profiles are unchanged. From 678ee662eddf75b07524809ada614dc63ee35380 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 13:39:21 +0100 Subject: [PATCH 67/75] Record real fitted opening-particle frame and centre measurements --- .../qa/native-foundation/ball-opening-projection.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/qa/native-foundation/ball-opening-projection.md b/docs/qa/native-foundation/ball-opening-projection.md index 4a2a3da89..97192b88a 100644 --- a/docs/qa/native-foundation/ball-opening-projection.md +++ b/docs/qa/native-foundation/ball-opening-projection.md @@ -31,9 +31,16 @@ Validation: description after detecting wrong geometry; its receipt is retained. - Older-public composition `4402bdd757`: 74 groups pass, including all 460 projection assertions, with its existing native controllers retained. -- Real unchanged-consumer image qualification is recorded after its managed - run finishes. All views/portraits, capture image acceptance and stable frame - rates remain separate. +- Real Metal Ruby `3fec7460ac`, unchanged consumer `bddb5181`, imported TIANA + portrait 2: all 189 observed opening-particle draws have the current fitted + ball centre (maximum framebuffer error `0.00000763` pixels). The frame at + opponent presentation tick 62 now shows the ring at the actual ball rather + than outside the scene. Both native send-outs, 11 captured throw images, + the command menu and cleanup pass without loader errors. The 553 pre-player- + throw observations and 82 ball-hidden observations retain zero visibility + violations. There are zero duplicate cells in 378 opening passes. + All views/portraits, capture image acceptance and stable frame rates remain + separate. Hosted CI is separate too. Owned QA profiles/copies only, with a 20 GiB free floor. Private sources, original ROMs and player saves/profiles are unchanged. From 81bb187933973a58737bd15235adb3adb582b625 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 14:45:56 +0100 Subject: [PATCH 68/75] Offer native player throw and emergence presentation phases --- src/core/game3/battle/sendout_tween.lua | 90 +++++++++++++------ tests/engine/native_player_sendout_phases.lua | 87 ++++++++++++++++++ tests/engine/native_sendout_tween.lua | 1 + .../engine/native_sendout_tween_admission.lua | 1 + 4 files changed, 151 insertions(+), 28 deletions(-) create mode 100644 tests/engine/native_player_sendout_phases.lua diff --git a/src/core/game3/battle/sendout_tween.lua b/src/core/game3/battle/sendout_tween.lua index 0d9fb690d..68364d9e5 100644 --- a/src/core/game3/battle/sendout_tween.lua +++ b/src/core/game3/battle/sendout_tween.lua @@ -1,5 +1,5 @@ -- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. --- A visual lease over the real opponent send-out, never a second ball player. +-- Visual phases over the real native send-out, never a second ball player. local Battle=require('src.core.game3.battle') local G=require('src.core.Game3') local Clock=require('src.battle.PresentationClock') @@ -11,7 +11,7 @@ local unpack=table.unpack or unpack local function pack(...)return{n=select('#',...),...}end local function finite(v)return type(v)=='number'and v==v and math.abs(v)=phase.duration then return phase.units end + return phase.delay+tick*phase.span/phase.duration + end -- Public presentation units: 16 at the ball opening, 12 for emergence, -- then a 7-unit trainer exit tail. They are not cartridge frame counts. if tick>=c.duration then return 35 end @@ -46,15 +51,20 @@ local function canonicalAt(c,tick) return 16+(tick-c.wait)*12/c.fade end local function nativeAt(c,u) + local phase=c.phase + if phase.kind~='opponent'then + return phase.start+math.floor(math.max(0,math.min(phase.span,u*phase.units-phase.delay)) + *phase.duration/phase.span+1e-7) + end local frame=u*35 local n=frame<=16 and frame*c.wait/16 or c.wait+math.min(12,frame-16)*c.fade/12 return math.min(c.duration,math.floor(n+1e-7)) end -local function advance(c,u) +local function advance(c,offered,u) local s,a=live(c) local phase=processing:find('native.sendout.step') - if not phase or phase.value~=c or not s or not c.active or not c.processing or not c.body + if offered~=c.phase or offered.closed or not phase or phase.value~=c or not s or not c.active or not c.processing or not c.body or not Clock.active(s)or s.game.stack:top()~=s then return false,'native send-out callback is outside its visual phase' end @@ -74,41 +84,57 @@ end local function call(c,fn,...) if fn then return G._withCallbackOwner(c.owner,fn,...)end end -function M._begin(screen,anim,row,hook,hookNow,dispatch) - if anim.sendOut~='opponent'or anim.battler~=screen.enemy - or not screen._sourceIntroSequence or not Clock.active(screen)then return false end - local token=screen._sourceIntroSequence - local index=token.sendOuts and token.sendOuts.opponent_sendout - if not index then return false end - local send=anim.send;local wait,fade=send and send.foeWait,send and send.fadeFrames - if not(finite(wait)and finite(fade)and wait>=1 and fade>=1 - and wait%1==0 and fade%1==0 and wait+fade<=4096)then return false end - if current then release(current)end - local c={refs=setmetatable({screen,screen.game.data,screen.game.save,screen.game.stack, - screen.game.mods,anim,screen.enemy,row,token,Battle.getState()},{__mode='v'}), - wait=wait,fade=fade,duration=wait+fade,index=index,desired=0,tick=0,active=true, - hook=hook,hookNow=hookNow} - current=c +local function offer(c,phase) + c.phase,c.tick=phase,0 local configured=false + local step=function(u)return advance(c,phase,u)end local function configure(frames,onStep,onComplete) - assert(native(c)and Clock.active(screen),'native send-out offer retired') + local screen=native(c) + assert(screen and Clock.active(screen),'native send-out offer retired') if configured then return c.public end assert(finite(frames)and frames>=0 and frames<=65536,'invalid send-out presentation duration') assert(onStep==nil or type(onStep)=='function','invalid send-out step callback') assert(onComplete==nil or type(onComplete)=='function','invalid send-out completion callback') configured=true;c.total=math.max(1,math.ceil(frames));c.owner=G._callbackOwner() c.step,c.complete=onStep,onComplete - c.public={frames=0,total=c.total} -- Detached observation; writes grant no authority. - c.displayTotal=c.total==35 and c.duration or c.total + c.public={frames=0,total=c.total} + -- Delegation keeps the existing native duration. A player provider that + -- decorates its callback requests the public windup/throw presentation; + -- the native ball remains at frame zero throughout that visual windup. + c.canonical=c.total==phase.units and(phase.kind=='opponent'or onStep==step) + c.displayTotal=c.canonical and phase.duration or c.total return c.public end - local result=pack(pcall(dispatch,configure,35,function(u)return advance(c,u)end,function() - return c.processing and c.active and true or false + local result=pack(pcall(c.dispatch,configure,phase.units,step,function() + return c.phase==phase and not phase.closed and c.processing and c.active and true or false end)) if not result[1]then release(c);error(result[2],0)end - if not configured or not native(c)or hookNow()~=hook then release(c);return false end + if not configured or not native(c)or not c.hookNow or c.hookNow()~=c.hook then release(c);return false end return true end +function M._begin(screen,anim,row,hook,hookNow,dispatch) + local player=anim.sendOut=='player'and anim.battler==screen.player + local opponent=anim.sendOut=='opponent'and anim.battler==screen.enemy + if not(player or opponent)or not screen._sourceIntroSequence or not Clock.active(screen)then return false end + local token=screen._sourceIntroSequence + local index=token.sendOuts and token.sendOuts[player and'player_throw'or'opponent_sendout'] + if not index then return false end + local send=anim.send;local wait,fade=send and(player and send.frames or send.foeWait),send and send.fadeFrames + if not(finite(wait)and finite(fade)and wait>=1 and fade>=1 + and wait%1==0 and fade%1==0 and wait+fade<=4096)then return false end + if current then release(current)end + local c={refs=setmetatable({screen,screen.game.data,screen.game.save,screen.game.stack, + screen.game.mods,anim,anim.battler,row,token,Battle.getState()},{__mode='v'}), + wait=wait,fade=fade,duration=wait+fade,index=index,desired=0,tick=0,active=true, + hook=hook,hookNow=hookNow,dispatch=dispatch,actorKey=player and'player'or'enemy'} + c.phases=player and{ + {kind='player',units=57,start=0,duration=wait,delay=32,span=25}, + {kind='emergence',units=12,start=wait,duration=fade,delay=0,span=12}, + }or{{kind='opponent',units=35,start=0,duration=wait+fade}} + current=c + c.phaseIndex=1 + return offer(c,c.phases[1]) +end function M._step(screen,anim,body) local c=current if not c or c.refs[1]~=screen or(c.refs[6]~=anim and not(c.nativeCompleted and anim==nil))then return false end @@ -123,7 +149,8 @@ function M._step(screen,anim,body) Clock.once(screen,'native.sendout.tween',function() if not live(c)then return end c.tick=c.tick+1 - local f=c.total==35 and canonicalAt(c,math.min(c.tick,c.duration))or c.tick + local offered=c.phase + local f=c.canonical and canonicalAt(c,math.min(c.tick,offered.duration))or c.tick local task={frames=f,total=c.total} c.public.frames=f;c.processing,c.body=true,body local phase=processing:push('native.sendout.step',c) @@ -131,7 +158,14 @@ function M._step(screen,anim,body) call(c,c.step,math.min(1,f/c.total),task) assert(not Coroutines.retired()and not phase.closed,'native send-out phase retired while suspended') if not native(c)then return end - if c.tick>=c.displayTotal then call(c,c.complete);c.active=false end + if c.tick>=c.displayTotal then + call(c,c.complete) + offered.closed=true + if c.active and native(c)and c.phases[c.phaseIndex+1]then + c.phaseIndex=c.phaseIndex+1 + offer(c,c.phases[c.phaseIndex]) + else c.active=false end + end end)) processing:close(phase) c.processing,c.body=false,nil diff --git a/tests/engine/native_player_sendout_phases.lua b/tests/engine/native_player_sendout_phases.lua new file mode 100644 index 000000000..563e75903 --- /dev/null +++ b/tests/engine/native_player_sendout_phases.lua @@ -0,0 +1,87 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local A=require('src.core.game3.battle.anim') +local Intro=require('src.core.game3.battle.intro_seq') +local Battle=require('src.core.game3.battle') +local G=require('src.core.Game3') +local Native=require('src.battle.Gen3Battle') +local oldGame,centre=package.loaded['src.core.Game'],Native.battlerCentre +local base=A.tweenStage +local function run(edition,mode) + local f=require('tools.battle-source-runtime.fixture').new(edition) + package.loaded['src.core.Game']=f.game + f.game.data.constants.gen3BallAnim={size=16,frames=3,balls={},images={},timing={sendOut={ + from={x=24,y=68},frames=25,arc=-30,targetOffsetY=24,foeOffsetY=24, + foeWait=16,fadeFrames=14,riseStep=-288}}} + local b=f.fresh('TACKLE').battle;b.kind='trainer' + for _,actor in ipairs{b.player,b.enemy}do + actor.mon.isShiny=nil;actor.mon.shiny=nil;actor.mon.personality=0;actor.mon.otId=1000;actor.mon.otSecretId=nil + end + Native.battlerCentre=function(_,actor)return actor==b.player and 72 or 176,72 end + f.game.input.wasPressed=function(_,key)return key=='a'end + local offers,steps,complete={},{},{};local borrowed,first,failed;local trace={} + if mode=='absent'then package.loaded['src.core.game3.battle.anim']=nil + elseif mode=='default'then A.tweenStage=base + else A.tweenStage=function(frames,step,done) + if frames==35 then return base(frames,step,done)end + offers[#offers+1]=frames;borrowed=step;if frames==57 then first=step end + T.eq(Intro._steps[Intro._i].kind,'player_throw','each offer belongs to the actual player ball') + if mode=='noop'then return base(frames,step,done)end + local duration=frames==12 and 76 or frames + return base(duration,function(_,task) + steps[frames]=(steps[frames]or 0)+1 + local mapped=frames==12 and math.min(12,task.frames)or task.frames + step(mapped/frames,task);step(mapped/frames,task) + if frames==57 and task.frames<=32 then + T.eq(b.gen3Ball.frame,0,'visual windup does not spend a native flight tick') + T.eq(A.present(0).visible,false,'player remains in the actual ball during windup') + end + if frames==57 and task.frames==57 then + T.eq(b.gen3Ball.phase,'release');T.eq(b.gen3Ball.frame,25) + end + if frames==12 then + T.eq(first(1),false,'the completed throw callback cannot spend an emergence tick') + if task.frames>=12 then + T.eq(b.gen3Ball,nil,'the real native emergence completes once') + T.eq(A.busy(),true,'the visual return retains its actual queue admission') + T.eq(Intro._steps[Intro._i].kind,'player_throw') + end + end + if mode=='cancel'and frames==57 and task.frames==20 then A.tweenStage=base end + if mode=='error'and frames==57 and task.frames==10 then error('player presentation failed')end + end,function()complete[frames]=(complete[frames]or 0)+1;done()end) + end end + f.game.stack:push(b) + for tick=1,2000 do + if mode=='error'and offers[1]and not failed then + while (steps[57]or 0)<9 do b:update(1/60)end + T.raises(function()b:update(1/60)end,'player presentation failed','provider error remains visible') + A.tweenStage=base;failed=true + else b:update(1/60)end + local a=b.gen3Ball + trace[#trace+1]={frame=b.frame,phase=b.phase,queue=#b.queue,wait=b.waitFrames, + ball=a and{tick=a.frame,phase=a.phase,x=a.x,y=a.y},hp=b.player.mon.hp,pp=b.player.mon.moves[1].pp} + if b.phase=='menu'and #b.queue==0 and not b.current then break end + assert(tick<2000,'native player send-out did not complete') + end + if mode=='noop'then T.same(offers,{57,12},'the existing API receives both real player stages')end + if mode=='decorated'then + T.same(offers,{57,12});T.eq(steps[57],57);T.eq(steps[12],76) + T.eq(complete[57],1);T.eq(complete[12],1) + end + if mode=='cancel'then T.eq(steps[57],20);T.eq(complete[57],nil);T.eq(offers[2],nil)end + if mode=='error'then T.eq(failed,true);T.eq(complete[57],nil);T.eq(offers[2],nil)end + if borrowed then T.eq(borrowed(1),false,'retained callback cannot advance a later native phase')end + package.loaded['src.core.game3.battle.anim']=A;A.tweenStage=base + f.game.stack:pop();Intro._service.release();A._service.release();Battle._service.release();f.close();G.release() + return trace +end +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local absent=run(edition,'absent') + T.same(run(edition,'default'),absent,'unmodified native player trace is unchanged') + T.same(run(edition,'noop'),absent,'delegation preserves every native frame, wait, position, HP and PP') + run(edition,'decorated');run(edition,'cancel');run(edition,'error') +end +A.tweenStage=base;Native.battlerCentre=centre;package.loaded['src.core.Game']=oldGame +T.finish('native player windup/flight and emergence phases, stale-callback retirement and exact default traces') diff --git a/tests/engine/native_sendout_tween.lua b/tests/engine/native_sendout_tween.lua index 68e3d68ee..15b5210ea 100644 --- a/tests/engine/native_sendout_tween.lua +++ b/tests/engine/native_sendout_tween.lua @@ -35,6 +35,7 @@ local function run(edition,mode,shiny) elseif mode=='default'then A.tweenStage=base else A.tweenStage=function(frames,onStep,onComplete) + if frames~=35 then return base(frames,onStep,onComplete)end begins=begins+1;T.eq(frames,35);borrowed=onStep if mode=='noop'then local task=base(frames,onStep,onComplete) diff --git a/tests/engine/native_sendout_tween_admission.lua b/tests/engine/native_sendout_tween_admission.lua index a55af76bf..f94c8bb00 100644 --- a/tests/engine/native_sendout_tween_admission.lua +++ b/tests/engine/native_sendout_tween_admission.lua @@ -25,6 +25,7 @@ for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do f.game.input.wasPressed=function(_,key)return key=='a'end local offers,steps,completed=0,0,0;local last,borrowed A.tweenStage=function(frames,step,done) + if frames~=35 then return base(frames,step,done)end offers=offers+1;T.eq(frames,35);borrowed=step T.eq(Intro._steps[Intro._i].kind,'opponent_sendout','offer belongs to the actual native phase') return base(frames,function(u,task) From 69f79c3c2f1809371315cd367a17b5a7400aa5d2 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 14:59:01 +0100 Subject: [PATCH 69/75] Notify presentation providers at the actual native ball opening --- src/battle/BattleState.lua | 5 ++ src/core/game3/battle/anim.lua | 10 +++ src/core/game3/battle/ball_open_phase.lua | 51 +++++++++++++ .../engine/native_ball_open_notification.lua | 71 +++++++++++++++++++ 4 files changed, 137 insertions(+) create mode 100644 src/core/game3/battle/ball_open_phase.lua create mode 100644 tests/engine/native_ball_open_notification.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index de355db2b..03de516ed 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2610,7 +2610,12 @@ function BattleState:beginMsgLine() end function BattleState:stepGen3Ball(anim) + local before = anim.phase anim:update() + if before ~= "release" and anim.phase == "release" then + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle.anim'] + if source then source._nativeBallOpen(self, anim) end + end if anim.sound then require("src.core.Sound").playId(self.data, anim.sound) end diff --git a/src/core/game3/battle/anim.lua b/src/core/game3/battle/anim.lua index 25b7feaef..60013d5a5 100644 --- a/src/core/game3/battle/anim.lua +++ b/src/core/game3/battle/anim.lua @@ -197,6 +197,16 @@ local function tweenStage(frames,onStep,onComplete) return task end A.tweenStage=tweenStage +local function ballOpen(key,x,y) + return require('src.core.game3.battle.ball_open_phase').open(key,x,y) +end +A.ballOpen=ballOpen +function A._nativeBallOpen(screen,ball) + if A.ballOpen==ballOpen then return false end + return require('src.core.game3.battle.ball_open_phase').notify(screen,ball,function(...) + return Call.call(A,'ballOpen',...) + end) +end function A._nativeSendOut(screen,anim,row) if A.tweenStage==tweenStage then return false end local _,actual=context();if actual~=screen then return false end diff --git a/src/core/game3/battle/ball_open_phase.lua b/src/core/game3/battle/ball_open_phase.lua new file mode 100644 index 000000000..c9d1a5834 --- /dev/null +++ b/src/core/game3/battle/ball_open_phase.lua @@ -0,0 +1,51 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Notification of the actual native release, without another opening player. +local Battle=require('src.core.game3.battle') +local Clock=require('src.battle.PresentationClock') +local Coords=require('src.core.game3.battle.anim_coords') +local Coroutines=require('src.compat.gen1.CoroutineScopes') +local scopes=Coroutines.new() +local seen=setmetatable({},{__mode='k'}) +local M={} +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +local function live(c) + local r=c.refs;local s,a=r[1],r[2] + return s and a and Battle._screen()==s and Battle.getState()==r[3] + and s.gen3Ball==a and s.game.data==r[4]and s.game.save==r[5] + and s.game.stack==r[6]and s.game.mods==r[7]and s.game.stack:top()==s + and s.frame==c.frame and a.frame==c.ballFrame and a.phase=='release' + and a.phaseFrame==0 and Clock.active(s) +end +local function finite(n)return type(n)=='number'and n==n and math.abs(n) Date: Wed, 28 Oct 2026 15:00:15 +0100 Subject: [PATCH 70/75] Keep the native opening sound spent before notifying providers --- src/battle/BattleState.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 03de516ed..0b966f3f0 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2612,13 +2612,13 @@ end function BattleState:stepGen3Ball(anim) local before = anim.phase anim:update() + if anim.sound then + require("src.core.Sound").playId(self.data, anim.sound) + end if before ~= "release" and anim.phase == "release" then local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle.anim'] if source then source._nativeBallOpen(self, anim) end end - if anim.sound then - require("src.core.Sound").playId(self.data, anim.sound) - end -- the trainer is gone by the time his Pokemon is out of the ball -- ...unless the trainer's own walk-off is still running (#407): the -- cartridge frees that sprite at the end of the fifty frames, not when From 1727869ef9b9cb298c0fc0adb6e7011fe9b97f7e Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 15:10:40 +0100 Subject: [PATCH 71/75] Test native opening sound ordering before provider callbacks --- tests/engine/native_ball_open_notification.lua | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/engine/native_ball_open_notification.lua b/tests/engine/native_ball_open_notification.lua index 771c50f60..0b8241473 100644 --- a/tests/engine/native_ball_open_notification.lua +++ b/tests/engine/native_ball_open_notification.lua @@ -7,6 +7,8 @@ local Battle=require('src.core.game3.battle') local G=require('src.core.Game3') local Native=require('src.battle.Gen3Battle') local Copy=require('src.mods.Merge').deepCopy +local Sound=require('src.core.Sound') +local play=Sound.playId local base,tween,centre,oldGame=A.ballOpen,A.tweenStage,Native.battlerCentre,package.loaded['src.core.Game'] local function run(edition,mode) local f=require('tools.battle-source-runtime.fixture').new(edition) @@ -21,12 +23,18 @@ local function run(edition,mode) Native.battlerCentre=function(_,actor)return actor==b.player and 72 or 176,72 end f.game.input.wasPressed=function(_,key)return key=='a'end local openings={[0]=0,[1]=0};local trace={};local throwFrame;local handoffs=0 + local lastSound + Sound.playId=function(data,id,...) + lastSound={id=id,frame=b.frame};return play(data,id,...) + end if mode=='absent'then package.loaded['src.core.game3.battle.anim']=nil elseif mode=='observed'or mode=='handoff'then A.ballOpen=function(id,x,y) openings[id]=openings[id]+1 local a=b.gen3Ball;local before,frame,queue=Copy(a),b.frame,#b.queue T.eq(a.phase,'release');T.eq(a.phaseFrame,0) + T.eq(lastSound and lastSound.id,a.sound,'native opening sound is spent before provider notification') + T.eq(lastSound and lastSound.frame,b.frame,'opening sound and notification share the actual native tick') T.eq(a.battler,id==0 and b.player or b.enemy) T.eq(Intro._steps[Intro._i].kind,id==0 and'player_throw'or'opponent_sendout') T.eq(base(id,x+17,y-11),true,'source opening coordinates borrow an actual notification') @@ -57,7 +65,7 @@ local function run(edition,mode) if mode=='observed'or mode=='handoff'then T.eq(openings[0],1);T.eq(openings[1],1)end if mode=='handoff'then T.eq(handoffs,1);T.eq(throwFrame,nil,'the trainer can return after the native opening')end T.eq(base(0,72,80),false,'caller cannot manufacture an opening outside its native phase') - package.loaded['src.core.game3.battle.anim']=A;A.ballOpen,A.tweenStage=base,tween + package.loaded['src.core.game3.battle.anim']=A;A.ballOpen,A.tweenStage=base,tween;Sound.playId=play f.game.stack:pop();Intro._service.release();A._service.release();Battle._service.release();f.close();G.release() return trace end @@ -67,5 +75,5 @@ for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do T.same(run(edition,'observed'),absent,'repeated opening observations leave native clocks/queue/HP/PP equal') run(edition,'handoff') end -A.ballOpen,A.tweenStage=base,tween;Native.battlerCentre=centre;package.loaded['src.core.Game']=oldGame +A.ballOpen,A.tweenStage=base,tween;Sound.playId=play;Native.battlerCentre=centre;package.loaded['src.core.Game']=oldGame T.finish('actual native opening notifications, pure coordinate borrowing and player throw-to-return handoff') From ea485da79ba3b90332bc907318098f9fa61c633f Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 15:15:31 +0100 Subject: [PATCH 72/75] Document native player presentation qualification and remaining visual checks --- docs/qa/native-foundation/player-sendout-phases.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docs/qa/native-foundation/player-sendout-phases.md diff --git a/docs/qa/native-foundation/player-sendout-phases.md b/docs/qa/native-foundation/player-sendout-phases.md new file mode 100644 index 000000000..8590e6bea --- /dev/null +++ b/docs/qa/native-foundation/player-sendout-phases.md @@ -0,0 +1,13 @@ +# Native player throw and opening phases + +The native battle now offers the existing presentation callback a player windup/flight phase (57 public units: 32 windup and 25 flight) and a separate emergence phase (12 public units mapped to the actual native fade). The actual native ball, battler, queue and simulation clock remain authoritative. A source decorator may hold its visual return; default and delegating providers retain exactly the native trace. + +An opening notification borrows the actual native release transition after its opening sound has been spent. It does not manufacture a second ball or opening effect. The scoped original callback rejects another actor, nonfinite positions, replay and callbacks retained beyond their native frame/cohort. This lets an unchanged presentation retire its throw pose at the actual opening. + +Validation on the merged fork main `6ab3709e`, owned implementation `263f7585`: 244 frozen CPU groups passed. The player fixture checks 2,110 assertions across five editions, including exact default/delegating traces, decorated windup/flight/emergence, cancellation, error fallback and stale phase retirement. The opening fixture checks 345 assertions, including sound ordering, actor/frame identity, pure coordinate borrowing and throw-to-return handoff. The prior implementation fails 40 of the player fixture's first 70 assertions; its native controllers were not overlaid. + +The older public integration is checked separately over its original native controllers. This change does not qualify all switch/capture presentations or sustained field rendering performance. The current water-view investigation separately reproduced a roughly five-second frame and a missing general door metadata query in Third Person; those issues remain outside this battle change. Real unchanged-consumer player return remains a visual follow-up; CPU phase qualification is not visual acceptance. + +Only owned disposable profiles were used. No player saves or private presentation source were changed. Storage helpers enforced at least 20 GiB free (above the user's 8 GiB requirement). + +Contributions by Roxas2712 — https://github.com/Roxas2712. Original additions are offered under LICENSE-ROXAS2712.md, including specific integration/distribution permission for Cedric / UNDERdecoded with visible credit. Existing upstream/third-party licences and rights remain intact. From 1ad2732701b4755a2b304e49f1f1532fedd1b924 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 15:42:34 +0100 Subject: [PATCH 73/75] Import cartridge door descriptors and expose native cell metadata queries --- src/core/game3/doors.lua | 73 ++++++++++++++++++++ src/import/Gen3DoorMetadata.lua | 46 ++++++++++++ src/import/RomExtractorGen3.lua | 4 ++ src/import/RomImporter.lua | 3 + tests/engine/native_door_metadata.lua | 37 ++++++++++ tests/engine/native_door_metadata_import.lua | 31 +++++++++ 6 files changed, 194 insertions(+) create mode 100644 src/core/game3/doors.lua create mode 100644 src/import/Gen3DoorMetadata.lua create mode 100644 tests/engine/native_door_metadata.lua create mode 100644 tests/engine/native_door_metadata_import.lua diff --git a/src/core/game3/doors.lua b/src/core/game3/doors.lua new file mode 100644 index 000000000..7e26daff0 --- /dev/null +++ b/src/core/game3/doors.lua @@ -0,0 +1,73 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Observe the native Petalburg slide and paint its actual resident metatiles. +-- Cartridge metadata is read-only; mod-started door tasks are separate APIs. +local Native=require('src.compat.gen1.NativeSession') +local Identity=require('src.compat.gen1.MapIdentity') +local Version=require('src.core.GameVersion') +local Scope=require('src.render.GraphicsScope') +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 +local cohort=setmetatable({},{__mode='v'}) +local doorCollision +local function manifest(game) + local data=game and game.data;local constants=data and data.constants + local facts=constants and constants.gen3DoorMetadata + local edition=Version.get() + if not(type(facts)=='table'and facts.schema==1 and facts.edition==edition and Version.acceptsSha1(edition,facts.romSha1) + and type(facts.by_mid)=='table'and type(facts.doors)=='table')then + M._manifest=nil;cohort=setmetatable({},{__mode='v'});return + end + if not M._manifest or cohort[1]~=game or cohort[2]~=data or cohort[3]~=facts or cohort[4]~=game.save or cohort[5]~=game.mods then + M._manifest=copy(facts);cohort=setmetatable({game,data,facts,game.save,game.mods},{__mode='v'}) + end + return facts +end +function M.getDoorEntryAt(mapId,x,y) + local game=Native.resolve();local facts=manifest(game);if not facts then return nil,'native door metadata unavailable'end + if type(x)~='number'or type(y)~='number'or x~=x or y~=y or x%1~=0 or y%1~=0 then return nil end + local maps=require('src.core.game3.map')._context(game);if not maps then return nil end + local def=maps:definition(mapId);if not def then return nil end + local layout=maps:layout(def.id,def) + if not layout or x<0 or y<0 or x>=def.width or y>=def.height then return nil end + doorCollision=doorCollision or require('src.compat.gen1.CollisionRuntime').new() + local behavior=doorCollision:behaviorMid(def,layout:midAt(x,y),game) + local MB=require('src.compat.gen1.MetatileBehavior') + if behavior~=MB.require('WARP_DOOR')and not(facts.family=='rse'and behavior==MB.require('PETALBURG_GYM_DOOR'))then return nil end + local row=facts.by_mid[layout:midAt(x,y)] + if row then return copy(row),copy(facts.doors[row.tile])end +end +local function current(mapId,x,y) + local game,world=Native.resolve() + local slide=world and world.gymDoorSlide + if not(slide and slide.mapId==world.map.id)then return end + local canonical=Identity.bind(game.data.maps,Version.get()):legacyId(slide.mapId) + if not canonical or mapId~=nil and mapId~=canonical and mapId~=slide.mapId then return end + local P=require('src.script.Gen3Commands').PETALBURG_DOORS + for _,cell in ipairs(P.ROOMS[slide.room]or{})do + if(x==nil or x==cell[1])and(y==nil or y==cell[2])then + return{mapId=canonical,x=cell[1],y=cell[2],frame=math.max(0,math.min(4,slide.step-1))/2, + nativeFrame=math.max(0,slide.step-1),kind='sliding',timer=slide.timer,opening=true},world + end + end +end +function M.getActiveAnim(mapId,x,y)local pose=current(mapId,x,y);return pose end +function M.isBusy()return current()~=nil end +function M.draw(camX,camY) + local pose,world=current() + local renderer=world and world.map.renderer + if not(renderer and renderer.drawMapOnly and renderer.drawAbove)then return false end + camX,camY=camX or 0,camY or 0 + local ok,why=Scope.call(function() + local g=love.graphics + if g.intersectScissor then g.intersectScissor(pose.x*16-camX,pose.y*16-camY,16,32) + else g.setScissor(pose.x*16-camX,pose.y*16-camY,16,32)end + renderer:drawMapOnly(camX,camY,64,32) + renderer:drawAbove(camX,camY,64,32) + end) + if not ok then error(why,0)end + return true +end +return M diff --git a/src/import/Gen3DoorMetadata.lua b/src/import/Gen3DoorMetadata.lua new file mode 100644 index 000000000..38eab3604 --- /dev/null +++ b/src/import/Gen3DoorMetadata.lua @@ -0,0 +1,46 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Read the authenticated cartridge's door graphics descriptors. No pixel +-- copies, animation controller, sound playback or map edits live here. +local M={REVISION=1} +-- pret field_door tables: matching cartridge data symbols and FR/LG's +-- sDoorGraphics. Locations were checked against all five complete ROM hashes. +local profiles={ + firered={'41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc',0x35b5d8,32}, + leafgreen={'574fa542ffebb14be69902d1d36f1ec0a4afd71e',0x35b5b8,32}, + emerald={'f3ae088181bf583e55daf962a92bb46f4f1d07b7',0x497174,53}, + ruby={'f28b6ffc97847e94a6c21a63cacf633ee5c8df1e',0x30f9b4,34}, + sapphire={'3ccbbd45f8553c36463f13b938e833f652b793e4',0x30f944,34}, +} +local sounds={[0]='normal',[1]='sliding',[2]='arena'} +function M.read(rom,edition,sha1) + local p=profiles[edition] + if not p or p[1]~=sha1 then return nil,'door metadata profile unavailable'end + if not(rom and type(rom.data)=='string'and#rom.data>=p[2]+p[3]*12)then return nil,'door table outside cartridge'end + local frlg=edition=='firered'or edition=='leafgreen' + local out={schema=M.REVISION,edition=edition,romSha1=sha1,family=frlg and'frlg'or'rse',entries={},doors={},by_mid={}} + for i=0,p[3]-1 do + local off=p[2]+i*12 + local mid,sound,size=rom:u16(off),rom:u8(off+2),rom:u8(off+3) + local tiles,pals=rom:pointer(off+4),rom:pointer(off+8) + local validSize=frlg and(size==0 or size==1)or edition=='emerald'and(size==1 or size==2) + or(edition=='ruby'or edition=='sapphire')and size==0 + if mid>=1024 or not sounds[sound]or(frlg and sound>1)or not validSize + or not tiles or not pals or pals+8>#rom.data then return nil,'invalid native door descriptor'end + local width=size==2 and 32 or 16 + local height=frlg and(size==1 and 32 or 16)or 32 + if tiles+width*height/2>#rom.data then return nil,'door artwork outside cartridge'end + local name=('native_door_%d'):format(i) + local row={index=i,mid=mid,tile=name,sound=sounds[sound],sound_type=sound,size_type=size, + size=(width/16)..'x'..(height/16)} + out.entries[#out.entries+1]=row + if not out.by_mid[mid]then out.by_mid[mid]=row end + local slots={} + for n=0,7 do + local slot=rom:u8(pals+n);if slot>15 then return nil,'invalid door palette slot'end + slots[n+1]=slot + end + out.doors[name]={frame_width=width,frame_height=height,nativeTilesOffset=tiles,paletteSlots=slots} + end + return out +end +return M diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index 4fac3ed2a..1231edb44 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -27469,6 +27469,10 @@ function RomExtractorGen3:extractTilesets() palettesInPrimary = self:layoutValue("palettesInPrimary", 6), metatileLayers = self:layoutValue("metatileLayers", 2), } + local doorEdition=type(self.version)=='table'and self.version.id or self.version + local doorMetadata,doorWhy=require('src.import.Gen3DoorMetadata').read(self.rom,doorEdition,self.manifest.romSha1) + constants.gen3DoorMetadata=doorMetadata + if not doorMetadata then Logger.warn('Gen3 door metadata: %s',doorWhy)end self._constants = constants self:write("constants", constants) Logger.info("Gen3 tileset pairs: %d (the unit a metatile actually draws from)", diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 438d1fa34..7c8bf7faf 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1323,6 +1323,9 @@ local function markerFor(version) if version == "red" or version == "blue" or version == "yellow" then revision = "gen1-trade-art-v1:" end + if GameVersion.generation(version)==3 then + revision=revision..'gba-door-metadata-v'..require('src.import.Gen3DoorMetadata').REVISION..':' + end -- Rebuild generated FR/LG ball timing only; saved game formats do not change. if GameVersion.isFRLG(version)then revision=revision..'frlg-ball-sendout-v'..require('src.import.Gen3BallSendOutProfile').REVISION..':' diff --git a/tests/engine/native_door_metadata.lua b/tests/engine/native_door_metadata.lua new file mode 100644 index 000000000..2dee9eabe --- /dev/null +++ b/tests/engine/native_door_metadata.lua @@ -0,0 +1,37 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local G=require('src.core.Game3');local V=require('src.core.GameVersion') +local Doors=require('src.core.game3.doors');local Map=require('src.core.game3.map') +local Json=require('src.link.Json');local prior=package.loaded['src.core.Game'] +local function le(n,bytes)local out={}for i=1,bytes do out[i]=string.char(n%256);n=math.floor(n/256)end;return table.concat(out)end +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + G.release();Map.release() + local f=require('tools.map-runtime.fixture').new(edition);local g=f.game + package.loaded['src.core.Game']=g;f.world.player={};g.save={};g.mods={} + local fr=V.isFRLG();local p=g.data.map_tilesets.P + p.attributes=le(0,2)..le(0x69,2)..le(2,2) + if fr then p.sourceAttributes=le(0,4)..le(0x69,4)..le(2,4)end + local row={index=0,mid=1,tile='native_door_0',sound='sliding',sound_type=1,size_type=fr and 0 or 1} + local facts={schema=1,edition=edition,romSha1=V.info(edition).sha1,family=fr and'frlg'or'rse', + entries={row},by_mid={[1]=row},doors={native_door_0={frame_width=16,frame_height=fr and 16 or 32}}} + g.data.constants.gen3DoorMetadata=facts + local before=Json.encode({save=g.save,blocks=f.map.def.blocks,warps=f.map.def.warps,metadata=facts}) + local entry,info=Doors.getDoorEntryAt(f.mapId,2,0) + T.check(entry~=nil,'a real native door cell resolves through the canonical map identity') + T.eq(entry.sound,'sliding');T.eq(info.frame_height,fr and 16 or 32) + T.eq(Doors.getDoorEntryAt(f.map.id,2,0).mid,1,'native identity resolves the same actual cell') + entry.sound='edited';info.frame_height=999;Doors._manifest.by_mid[1].sound='edited' + T.eq(Doors.getDoorEntryAt(f.mapId,2,0).sound,'sliding','source metadata edits never change cartridge facts') + T.eq(Doors.getDoorEntryAt(f.mapId,0,0),nil,'a floor tile cannot inherit an unrelated door descriptor') + for _,xy in ipairs{{-1,0},{3,0},{4,0},{0,2},{0/0,0},{math.huge,0},{.5,0}}do T.eq(Doors.getDoorEntryAt(f.mapId,xy[1],xy[2]),nil)end + T.eq(Doors.getDoorEntryAt('UNKNOWN',2,0),nil) + T.eq(Json.encode({save=g.save,blocks=f.map.def.blocks,warps=f.map.def.warps,metadata=facts}),before,'queries leave saves, native cells, warps and imported descriptors exact') + g.data.constants.gen3DoorMetadata=nil + T.eq(Doors.getDoorEntryAt(f.mapId,2,0),nil);T.eq(Doors._manifest,nil,'a missing or retired import is not a fabricated empty manifest') + g.data.constants.gen3DoorMetadata=facts;g.stack.states={};g.stack.worldState=nil + T.eq(Doors.getDoorEntryAt(f.mapId,2,0),nil);T.eq(Doors._manifest,nil,'retired world drops the metadata projection') + f.views:release();Map.release();G.release() +end +package.loaded['src.core.Game']=prior +T.finish('native door metadata, canonical cells, detached queries and retirement') diff --git a/tests/engine/native_door_metadata_import.lua b/tests/engine/native_door_metadata_import.lua new file mode 100644 index 000000000..2ecde1b86 --- /dev/null +++ b/tests/engine/native_door_metadata_import.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 M=require('src.import.Gen3DoorMetadata') +local Rom=require('src.import.RomGba');local V=require('src.core.GameVersion') +local function le(n,bytes)local out={}for i=1,bytes do out[i]=string.char(n%256);n=math.floor(n/256)end;return table.concat(out)end +for _,p in ipairs{{'firered',0x35b5d8,32},{'leafgreen',0x35b5b8,32},{'emerald',0x497174,53},{'ruby',0x30f9b4,34},{'sapphire',0x30f944,34}}do + local edition,offset,count=p[1],p[2],p[3];local fr=edition=='firered'or edition=='leafgreen' + local rows={} + for i=0,count-1 do + local size=fr and i%2 or edition=='emerald'and(1+i%2)or 0 + rows[#rows+1]=le(i+1,2)..string.char(i%2,size)..le(0x08000040,4)..le(0x08000020,4) + end + -- Synthetic structures exercise the binary reader without ROM assets. The + -- caller supplies its already-authenticated import manifest's full digest. + local raw=string.rep('\0',offset)..table.concat(rows);local rom=Rom.new(raw) + local facts=assert(M.read(rom,edition,V.info(edition).sha1)) + T.eq(#facts.entries,count);T.eq(facts.entries[1].mid,1);T.eq(facts.by_mid[2].sound,'sliding') + local info=facts.doors[facts.by_mid[2].tile] + T.eq(info.frame_width,edition=='emerald'and 32 or 16);T.eq(info.frame_height,32) + T.eq(facts.doors[facts.by_mid[1].tile].frame_height,fr and 16 or 32) + T.eq(M.read(rom,edition,string.rep('0',40)),nil,'a different cartridge digest cannot borrow a profile') + T.eq(M.read(Rom.new(raw:sub(1,-2)),edition,V.info(edition).sha1),nil,'truncated descriptor tables are rejected as a whole') + local function damaged(at,bytes) + local data=raw:sub(1,at)..bytes..raw:sub(at+#bytes+1) + T.eq(M.read(Rom.new(data),edition,V.info(edition).sha1),nil,'invalid descriptor cannot publish a partial manifest') + end + damaged(offset+3,string.char(9));damaged(offset+2,string.char(9)) + damaged(offset+4,le(0,4));damaged(offset+8,le(0,4));damaged(0x20,string.char(16)) +end +T.eq(M.read({},'crystal',V.info('crystal').sha1),nil,'other generations do not enter the reader') +T.finish('native door descriptor shapes, profile gates, bounds and atomic rejection') From 82577f126eb9dccd481f4652cb058d1b7dcd66b3 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 15:54:39 +0100 Subject: [PATCH 74/75] Document authenticated door data and independent native query qualification --- docs/qa/native-foundation/door-metadata.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docs/qa/native-foundation/door-metadata.md diff --git a/docs/qa/native-foundation/door-metadata.md b/docs/qa/native-foundation/door-metadata.md new file mode 100644 index 000000000..5a593a2e7 --- /dev/null +++ b/docs/qa/native-foundation/door-metadata.md @@ -0,0 +1,13 @@ +# Native door descriptor queries + +Third-person world construction requested the cartridge door metadata query, which was missing and disabled the presentation renderer. The importer now reads the real descriptor tables for authenticated FireRed, LeafGreen, Emerald, Ruby and Sapphire cartridges, retaining metatile IDs, sound kind, size, tile location and palette slots. The edition-specific generated-cache marker changes; save formats do not change. + +The query resolves canonical or native map IDs through the admitted native world, reads the current metatile and semantic behavior, and returns detached descriptor and geometry metadata. Non-door cells, padded/out-of-bounds coordinates, foreign identities, missing imports and retired worlds do not create a door. The compatibility manifest is a detached per-cohort snapshot. Native map cells, warps, save data, simulation clocks and the existing Petalburg slide remain authoritative. + +Validation: 66 ROM-free binary-reader assertions, 95 native query assertions, 75 existing slide-observation assertions and the native map/weather/cache regressions pass. All 185 descriptors from the five authenticated cartridges were read successfully (32/32/53/34/34). The frozen current-main composition passes 250 CPU groups. An independent older public composition passes 78 groups using its original native controllers and the existing map/session foundations; only the owned import insertions and detached door module were added. Earlier differing import prerequisites were retained. ROM assets and private source are not published. + +This supplies descriptors and read-side queries. General door animation/task/sound coordination, broader edition/scene coverage and stable field FPS remain separate qualifications. The unchanged-consumer rain and camera matrix is being checked independently; CPU checks are not visual acceptance. + +Only disposable QA profiles were used. Private presentation files and player saves were not changed. Storage helpers enforced 20 GiB free, above the requested 8 GiB reserve. + +Contributions by Roxas2712 — https://github.com/Roxas2712. Original additions are offered under LICENSE-ROXAS2712.md, including specific integration/distribution permission for Cedric / UNDERdecoded with visible credit. Existing upstream/third-party licences and rights remain intact. From 08ff2cb84a2a6b12522277f1584b669f5d1c27ac Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 16:00:28 +0100 Subject: [PATCH 75/75] Record unchanged native clear and rain reflection matrix --- docs/qa/native-foundation/door-metadata.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/qa/native-foundation/door-metadata.md b/docs/qa/native-foundation/door-metadata.md index 5a593a2e7..a64cd0d1e 100644 --- a/docs/qa/native-foundation/door-metadata.md +++ b/docs/qa/native-foundation/door-metadata.md @@ -6,7 +6,9 @@ The query resolves canonical or native map IDs through the admitted native world Validation: 66 ROM-free binary-reader assertions, 95 native query assertions, 75 existing slide-observation assertions and the native map/weather/cache regressions pass. All 185 descriptors from the five authenticated cartridges were read successfully (32/32/53/34/34). The frozen current-main composition passes 250 CPU groups. An independent older public composition passes 78 groups using its original native controllers and the existing map/session foundations; only the owned import insertions and detached door module were added. Earlier differing import prerequisites were retained. ROM assets and private source are not published. -This supplies descriptors and read-side queries. General door animation/task/sound coordination, broader edition/scene coverage and stable field FPS remain separate qualifications. The unchanged-consumer rain and camera matrix is being checked independently; CPU checks are not visual acceptance. +The unchanged consumer now completes the native FireRed Cerulean water-bank matrix: clear/rain times Normal/Third/First, twelve on/off images, restored scene reflections, nonempty real scene targets and 9,368 observed native actor mirror draws. Native collision/elevation/metatile/warp snapshots compare equal. All six enabled images and the Third on/off pairs were inspected. The owned QA accelerates puddle fill from 45 seconds to 0.25 seconds; production fill timing is unchanged. No private runtime or renderer was mounted or edited. The native process refreshed its own disposable generated cache and contains the genuine descriptor import. + +This supplies descriptors and read-side queries. General door animation/task/sound coordination, other editions/scenes and stable field FPS remain separate qualifications. The image run still shows long frames; its captures, draw observation and accelerated fill are not FPS acceptance. Only disposable QA profiles were used. Private presentation files and player saves were not changed. Storage helpers enforced 20 GiB free, above the requested 8 GiB reserve.