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/67] 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/67] 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/67] 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/67] 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/67] 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/67] 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/67] 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/67] 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/67] 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/67] 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/67] 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/67] Add shared API 3 entry and pinned cross-engine contracts --- .github/workflows/api3-contract.yml | 56 ++++ docs/api3-portability.md | 145 ++++++++++ docs/modding.md | 17 +- src/core/Version.lua | 2 +- src/mods/Api3.lua | 199 ++++++++++++++ src/mods/Api3Catalog.lua | 57 ++++ src/mods/Api3Records.lua | 59 ++++ src/mods/CallbackRecords.lua | 79 +++++- src/mods/Loader.lua | 9 +- src/mods/Manifest.lua | 12 +- src/mods/UnifiedCallbacks.lua | 14 +- tests/api3_contract/mods/portable/main.lua | 110 ++++++++ .../api3_contract/mods/portable/manifest.json | 1 + tests/api3_contract/mods/rollback/main.lua | 6 + .../api3_contract/mods/rollback/manifest.json | 1 + tests/api3_contract/run.lua | 256 ++++++++++++++++++ tests/api3_contract/worker.lua | 10 + tests/engine/api3_compat.lua | 226 ++++++++++++++++ tests/engine/api3_entry.lua | 16 ++ tools/api3-contract-check/check.py | 82 ++++++ tools/modkit.py | 34 ++- 21 files changed, 1357 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/api3-contract.yml create mode 100644 docs/api3-portability.md create mode 100644 src/mods/Api3.lua create mode 100644 src/mods/Api3Catalog.lua create mode 100644 src/mods/Api3Records.lua create mode 100644 tests/api3_contract/mods/portable/main.lua create mode 100644 tests/api3_contract/mods/portable/manifest.json create mode 100644 tests/api3_contract/mods/rollback/main.lua create mode 100644 tests/api3_contract/mods/rollback/manifest.json create mode 100644 tests/api3_contract/run.lua create mode 100644 tests/api3_contract/worker.lua create mode 100644 tests/engine/api3_compat.lua create mode 100644 tests/engine/api3_entry.lua create mode 100644 tools/api3-contract-check/check.py diff --git a/.github/workflows/api3-contract.yml b/.github/workflows/api3-contract.yml new file mode 100644 index 000000000..fe5b47952 --- /dev/null +++ b/.github/workflows/api3-contract.yml @@ -0,0 +1,56 @@ +name: API 3 contracts +on: + pull_request: + paths: + - '.github/workflows/api3-contract.yml' + - 'src/**' + - 'tests/api3_contract/**' + - 'tests/engine/api3_*.lua' + - 'tools/api3-contract-check/**' + push: + branches: [main, dev] + paths: + - '.github/workflows/api3-contract.yml' + - 'src/**' + - 'tests/api3_contract/**' + - 'tests/engine/api3_*.lua' + - 'tools/api3-contract-check/**' +permissions: + contents: read +concurrency: + group: api3-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + compare: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + path: gen2 + sparse-checkout: | + src + data + tests + tools/api3-contract-check + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: gen1-reference + sparse-checkout: | + src + data + tests + assets/fonts + - name: install LuaJIT + run: sudo apt-get update && sudo apt-get install -y luajit + - name: identical mod contracts on both engines + run: >- + python3 gen2/tools/api3-contract-check/check.py + --gen1 gen1-reference --gen2 gen2 + --report "${{ runner.temp }}/api3-report.json" + - uses: actions/upload-artifact@v7 + with: + name: api3-contract-report + path: ${{ runner.temp }}/api3-report.json + if-no-files-found: error diff --git a/docs/api3-portability.md b/docs/api3-portability.md new file mode 100644 index 000000000..52f592613 --- /dev/null +++ b/docs/api3-portability.md @@ -0,0 +1,145 @@ +# Shared API 3 entry + +Use the same manifest and entry file on Gen1Recomp and Gen2Recomped: + +```json +{ + "id": "portable_example", + "name": "Portable Example", + "version": "1.0.0", + "api": 3, + "games": ["gen1", "gen2", "gen3"], + "entry": "main.lua" +} +``` + +```lua +local mod = ... +if mod.content.moves:has("TACKLE") then + local ok, reason = mod.content.moves:patch("TACKLE", {power = 45}) + if not ok then mod.log:warn("%s", reason) end +end +``` + +Create a portable starter with: + +```sh +python3 tools/modkit.py scaffold portable_example --api 3 +``` + +It generates the shared games list and a guarded canonical move patch. It +omits an engine-release range because Gen1 and Gen2 have independent engine +version numbers. The default scaffold remains API 2 with its existing range +and profile templates. The `--api` value is now honored instead of silently +emitting API 2 for every value. + +API 3 automatically selects unified content, callbacks and portable scripts. +No `content_format`, `callback_format` or `script_format` fields are needed. +An explicit `"native"` for one of those fields is an error under API 3; +`"unified"` is accepted. API 1/2 retain native defaults, and API 2 retains +its existing individual opt-ins. The engine advertises `Version.modApi = 3`. +A higher unknown API remains a load error. + +This is a common entry and a tested compatibility subset, not a claim that +all of Gen1Recomp's services have been ported. It builds on PRs #90–#99. +The existing unified-content limitations still apply: unsupported writes +return `nil, reason` before recording an operation, native diagnostics are +read-only, and ordinary schema errors raise an attributed load error. +Entry failure rolls back the mod's content and subscriptions. Content is +frozen after loading. + +## Public contracts + +Core `pokemon`, `moves`, `items`, `trainers` and `encounters` handles use the +existing unified adapters. Other registries retain their native record +shapes, rather than inventing canonical fields. Every content handle exposes +`has(id)`, `supports()` and `available`. API-3 passthrough reads and iteration +return detached values too; change data through the write methods. + +The API-3 Pokemon boundary uses short statuses (`SLP`, `PSN`, `BRN`, `FRZ`, +`PAR`, `TOX`), nickname-first display names and a common Special alias. +Writing `stats.special` in a writable callback sets both later Special stats; +contradictory split values are rejected before commit. API-2 unified status +names and callback behavior are unchanged. DVs/shiny presentation is read-only +in the callback boundary. Party and battle reads remain detached snapshots. + +Capture `mod.world` and `mod.battle` at entry time. World methods can be called +before Game exists and return `nil, reason`; reading the facade never boots +Game. The same world handle follows the loader's current Game/save instead of +retaining an earlier one. Battle snapshots and intents retain their existing +revision/replay validation. + +The common world calls currently enabled for generations 1–3 are: + +- `overworld`, `current`, `party`, `activeBlockAt`, `liveMaps`, `mapOverview`, + `effectiveEncounters`; +- `canReorderParty`, `reorderParty`, `availableFieldActions`, `useFieldAction`, + `canFly`, `flyTo`; +- `getFlag`/`setFlag` for private `mod:` boolean flags; +- `queueScript` for the existing bounded portable script language. + +Check `mod:supports("world", name)` before relying on a call. The other +Gen1 world method names remain callable but explicitly return +`nil, "unsupported: ..."`: warp/object/block mutations, runtime NPC operations, +`startWildBattle` and `invalidateMap` have not yet been verified against the +Gen1 API-3 contract. Their native/API-2 counterparts are unaffected. Native +symbolic flag mappings and `mod.datasets:open` are also explicitly unavailable. +Gen4/5 do not claim the shared world surface in this first entry PR. + +Private flags use `save.modData[modId].worldFlags`, independently of native +flags and other mods. A read does not create a bucket. Clearing removes the +stored key. A change emits `flag.changed` with +`name = "mod.."` and the boolean value; setting the same value +emits nothing. Invalid save stores are refused without overwriting them. + +Gen1 API-3 callback names from the pinned reference are recognized even when +this engine has no call site for them. Registration is allowed; availability +still requires a native call site in the selected version. For example, +`render.output` can be subscribed to but reports unavailable here. Unknown +names remain errors. An accepted subscription is not proof that a feature +runs. Source-specific engine handles in lifecycle/UI payloads remain native. + +For scripts, use `mod:supports("scripts", verb)` to test Gen2Recomped's current +bounded vocabulary. A `queueScript` method does not imply support for every +Gen1 script verb. See [portable world scripts](portable-world-scripts.md). + +## Reproducible contract checks + +```sh +luajit tests/engine/api3_entry.lua +luajit tests/engine/api3_compat.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The external reference must be a clean checkout at +`bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. +Use a separate checkout; do not reset a working installation. No Gen1 engine +implementation is vendored into this repository. Only public callback names +are recorded as a compatibility catalog. + +The exact same manifest and mod entry run on both engines for Red, Blue, +Yellow, Gold, Silver, Crystal, FireRed and Emerald, in both success and +intentional-rollback cases. Each engine/version/case has its own LuaJIT +process. The shared harness constructs native-shaped synthetic datasets; +the mod fixtures contain no engine imports or engine-specific branches. +The runner also generates an API-3 mod through the real scaffold CLI and +loads those same generated files on both engines for each version. This adds +eight comparisons to the sixteen normal/rollback cases: 24 comparisons and +48 isolated loader runs. It compares JSON projections of public results and +records hashes of both shared and generated fixture files. It refuses a different or modified reference. + +Coverage includes canonical content reads/writes/iteration and native merge +results, detached core and passthrough reads, callable early handles, later +Game replacement, party fields, private flags, Gen1 battle snapshot/invalid-intent boundaries, callback payloads and native +writeback, custom events, unsupported-name handling, frozen content and failed +owner cleanup. Separate Gen2 tests check API-1/2 behavior, contradictory +manifest fields, honest availability and malformed flag stores. CI pins the +same external commit and uploads the report. + +These are ROM-free loader/runtime-boundary tests, not retail-ROM gameplay +or a proof of every exposed native callback's payload. Native action details +remain covered by the prerequisite PR suites. Extend these shared fixtures +whenever another API-3 service becomes portable; do not make a failing +comparison pass by changing only one engine's mod fixture. diff --git a/docs/modding.md b/docs/modding.md index 85a7401dc..a48460651 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -18,6 +18,12 @@ Regenerate the reference straight into a wiki checkout: luajit tools/gen_registry_docs.lua ../gen1recomp.wiki ``` +## Shared API 3 entry + +The [API-3 entry and cross-engine contracts](api3-portability.md) describe +using the same manifest and mod source on Gen1Recomp and Gen2Recomped. +API 3 selects the unified adapters automatically; API 1/2 keep their defaults. + ## Feature queries (`mod:supports`) `mod:supports(surface, name)` returns `true` when the selected host exposes an @@ -62,11 +68,12 @@ Oak-speech callbacks apply to Gen 1–2 except Prism; Birch-speech events apply to Emerald. Other versions use their own intros. An event still needs its trigger to occur; custom events also need another mod to emit them. -This is additive to Mod API 2: it neither normalizes content records, IDs or -callback payloads across generations nor provides Gen1Recomp's unified API 3. -Existing registration/subscription behavior remains unchanged. When adding a -native callback or changing screen routing, update `src/mods/Capabilities.lua` -and the capability tests in the same change. +For API 1/2, the query describes the existing interfaces and does not itself +normalize records, IDs or callback payloads. API 3 selects the portable +adapters described in [Shared API 3 entry](api3-portability.md), with explicit +limits on availability. Existing API-1/2 registration/subscription behavior +remains unchanged. When adding a native callback or changing screen routing, +update `src/mods/Capabilities.lua` and the capability tests in the same change. Additional read-only world summaries: [map overview and encounter queries](world-overview.md). diff --git a/src/core/Version.lua b/src/core/Version.lua index 82cad2f61..b7ba8b6df 100644 --- a/src/core/Version.lua +++ b/src/core/Version.lua @@ -16,7 +16,7 @@ local Version = { -- binary (e.g. a LOVE version bump); an older shell -- refuses to chainload a payload whose minShell -- exceeds the shell it provides. - modApi = 2, -- mod API major (manifest `api`) + modApi = 3, -- mod API major (manifest `api`) linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL) saveFormat = 4, -- save.meta.format cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker) diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua new file mode 100644 index 000000000..f5de4a039 --- /dev/null +++ b/src/mods/Api3.lua @@ -0,0 +1,199 @@ +-- API-3 entry over the existing native adapters. Unsupported operations have +-- callable handles but never delegate to a native method with a different contract. +local Records = require("src.mods.Api3Records") +local Runtime = require("src.mods.Runtime") +local copy = require("src.mods.Merge").deepCopy +local A = {} +local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty + availableFieldActions useFieldAction canFly flyTo mapOverview warpTo toggleObject + setFlag getFlag effectiveEncounters replaceBlock spawnNpc liveMaps removeNpc npc + queueScript startWildBattle invalidateMap]] +local SUPPORTED = {} +for name in + ([[overworld current activeBlockAt party canReorderParty reorderParty + availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters + setFlag getFlag liveMaps queueScript]]):gmatch("%S+") +do + SUPPORTED[name] = true +end +local function pack(...) + return { n = select("#", ...), ... } +end +local unpack = table.unpack or unpack +local function block(gen) + return { + tileSize = gen == 3 and 16 or 32, + cellSize = 16, + coordinateBase = 0, + kind = gen == 3 and "metatile" or "block", + } +end +function A.install(api, loader, readContext, overviewContext, engineRequire) + local gen = api.generation + local supportedGeneration = gen >= 1 and gen <= 3 + -- Passthrough registries keep native shapes but expose the same handle metadata. + for name, entry in pairs(api.content) do + local handle = entry + if type(handle) == "table" and handle.supports == nil then + local available, reason = api:supports("content", name) + handle.available = available + local get, each = handle.get, handle.each + if get then + handle.get = function(self, id) + local value, why = get(self, id) + return copy(value), why + end + end + if each then + handle.each = function(self) + local iter, state, key = each(self) + return function() + local id, value = iter(state, key) + key = id + return id, copy(value) + end + end + end + handle.supports = function() + return available, reason + end + end + if type(handle) == "table" and handle.has == nil and handle.get then + handle.has = function(_, id) + return handle:get(id) ~= nil + end + end + end + local scripts = api.world -- PortableScripts owns the per-loader/session queue. + local world = {} + local nativeGame, nativeWorld + local function current() + return loader:_game(true) -- Never bootstrap Game for an observational early call. + end + local function native(game) + if game ~= nativeGame then + nativeWorld = engineRequire("src.world.WorldAPI").new(game, api.id, readContext) + nativeWorld.overviewContext = overviewContext + nativeGame = game + end + return nativeWorld + end + local function dispatch(name, ...) + if not supportedGeneration or not SUPPORTED[name] then + return nil, "unsupported: API 3 world method " .. name + end + local game = current() + if not game then + return nil, "no game" + end + if name == "setFlag" or name == "getFlag" then + if not game.save then + return nil, "no save" + end + local key, value = ... + if type(key) ~= "string" or key:sub(1, 4) ~= "mod:" then + return nil, "unsupported: API 3 native flag mapping" + end + if name == "setFlag" and type(value) ~= "boolean" then + return nil, "flag value must be boolean" + end + local id = key:sub(5) + if #id == 0 or #id > 256 or id:find("%c") then + return nil, "invalid mod flag" + end + local store = game.save.modData + if store ~= nil and type(store) ~= "table" then + return nil, "invalid mod save store" + end + local bucket = store and store[api.id] + if bucket ~= nil and type(bucket) ~= "table" then + return nil, "invalid mod save store" + end + local flags = bucket and bucket.worldFlags + if flags ~= nil and type(flags) ~= "table" then + return nil, "invalid mod flag store" + end + if name == "getFlag" then + return flags ~= nil and flags[id] == true + end + local before = flags ~= nil and flags[id] == true + game.save.modData = store or {} + bucket = bucket or {} + game.save.modData[api.id] = bucket + flags = flags or {} + bucket.worldFlags = flags + flags[id] = value or nil + if before ~= value then + Runtime.emit("flag.changed", { name = "mod." .. api.id .. "." .. id, value = value }) + end + return true + end + if name == "queueScript" then + return scripts:queueScript(...) + end + local raw = native(game) + local result = pack(raw[name](raw, ...)) + if name == "party" and type(result[1]) == "table" then + for _, mon in ipairs(result[1]) do + Records.mon(mon, game.data, gen) + end + elseif (name == "current" or name == "mapOverview") and type(result[1]) == "table" then + result[1].block = block(gen) + end + return unpack(result, 1, result.n) + end + for name in WORLD:gmatch("%S+") do + local method = name + world[method] = function(_, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local values = pack(pcall(dispatch, method, ...)) + Runtime.currentMod = owner + if not values[1] then + return nil, "world operation failed: " .. tostring(values[2]) + end + return unpack(values, 2, values.n) + end + end + api.world = world + local battle = api.battle + api.battle = { + snapshot = function() + local snapshot, why = battle:snapshot() + if not snapshot then + return nil, why + end + local game = current() + local data = game and game.data + Records.mon(snapshot.player, data, gen) + Records.mon(snapshot.enemy, data, gen) + for _, name in ipairs({ "party", "battlers" }) do + for _, mon in ipairs(snapshot[name] or {}) do + Records.mon(mon, data, gen) + end + end + return snapshot + end, + submit = function(_, ...) + return battle:submit(...) + end, + } + -- Version datasets have no native Gen2Recomped adapter yet. Keeping the + -- handle callable allows the same mod to fall back without engine imports. + api.datasets = { + open = function() + return nil, "unsupported: version datasets" + end, + } + local supports = api.supports + function api:supports(surface, name) + if surface == "world" then + if supportedGeneration and SUPPORTED[name] then + return true + end + return false, "unsupported: API 3 world method " .. tostring(name) + end + return supports(self, surface, name) + end +end +return A diff --git a/src/mods/Api3Catalog.lua b/src/mods/Api3Catalog.lua new file mode 100644 index 000000000..0549286b9 --- /dev/null +++ b/src/mods/Api3Catalog.lua @@ -0,0 +1,57 @@ +-- Public callback-name snapshot from Gen1Recomp API 3, commit +-- 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36. Recognition is not availability: +-- Capabilities still requires a native Gen2Recomped call site. +local function names(text) + local out = {} + for name in text:gmatch("%S+") do + out[name] = true + end + return out +end +local C = {} +C.hooks = names([[ +battle.accuracy battle.bottom_ui_visible battle.catch_exp battle.caught_marker_visible +battle.charge_required battle.crit battle.damage battle.enemy_action +battle.enemy_switch_or_item battle.exp_award battle.field_residual battle.low_health_alarm +battle.menu_auxiliary battle.move_grid_navigation battle.overlay battle.run +battle.status_hud_visible battle.style battle.turn_order breeding.compatibility +catch.nickname catch.party_full catch.rate core.logic_speed +core.quit_to_launcher core.update encounter.fishing encounter.roll +encounter.species encounter.table evolution.check exp.gain +fieldmove.eligibility gender.roll held_item.trigger input.gamepad +input.key input.pointer input.step input.wheel +intro.oak_speech.build item.use link.fingerprint map.palette +movement.collision movement.speed music.select music.volume +phone.contact_list player.sprite pokemon.icon pokemon.level_visible +pokemon.sprite render.compose render.hud render.letterbox +render.output render.output_enabled render.viewport render.window +render.zones save.new_game save.write screen.render_visible +script.command shiny.roll trainer.before_battle trainer.party +transition.style ui.list_menu ui.naming.grid ui.options.rows +ui.party.grid_navigation ui.party.submenu ui.pc.items ui.start_menu.items +ui.title_menu.items warp.destination world.follower.spawn world.talk +world.tod zoom.range +]]) +C.events = names([[ +apricorn.converted assets.transformed battle.ball_thrown battle.battler_switched +battle.damage_dealt battle.ended battle.exp_gained battle.fainted +battle.move_used battle.started battle.status_inflicted battle.turn_ended +battle.turn_started breeding.egg_created bug_contest.scored checkpoint.restored +clock.day_changed discord.join_requested egg.hatched flag.changed +game.ready happiness.changed intro.boot.copyright intro.boot.gamefreak +intro.boot.movie intro.boot.movie_ended intro.boot.title intro.oak_speech.answered +intro.oak_speech.finished intro.oak_speech.started intro.oak_speech.step link.battle_ended +link.connected link.desync link.ended mail.read +mail.written map.entered map.exited map.reloaded +mod.options_changed mods.loaded music.started music.stopped +phone.call_received player.warped pokemon.before_give pokemon.caught +pokemon.evolved pokemon.level_up pokemon.move_learned pokemon.received +pokerus.infected radio.channel roamer.encountered roamer.moved +save.created save.loaded save.loading save.writing +screen.popped screen.pushed script.ended script.started +sound.played trade.completed unown.unlocked world.blacked_out +world.block_replaced world.boulder_moved world.interacted world.live_maps_preparing +world.live_maps_updated world.npc_spawned world.object_toggled world.stepped +world.tod_changed world.trainer_engaged +]]) +return C diff --git a/src/mods/Api3Records.lua b/src/mods/Api3Records.lua new file mode 100644 index 000000000..22d540b20 --- /dev/null +++ b/src/mods/Api3Records.lua @@ -0,0 +1,59 @@ +-- API-3 public Pokemon values. API-2 unified records keep their existing shape. +local R = {} +local Stats = require("src.pokemon.Stats") +local bit = require("bit") +local LONG = { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local SHORT = {} +for short, long in pairs(LONG) do + SHORT[long], SHORT[short] = short, short +end +function R.status(value) + if value == false or value == "" or value == 0 then + return nil + end + return SHORT[value] or value +end +function R.nativeStatus(value, previous) + if value == nil or value == false then + return nil + end + local short = R.status(value) + assert(LONG[short], "unknown status") + if previous and LONG[SHORT[previous]] == previous then + return LONG[short] + end + return short +end +function R.stats(out, gen) + out.special = gen ~= 1 and out.specialAttack or out.special + return out +end +function R.shiny(raw, gen) + if raw.shiny ~= nil then + return raw.shiny == true + end + if gen ~= 3 then + return Stats.isShiny(raw.dvs) + end + if raw.isShiny ~= nil then + return raw.isShiny == true + end + local p = (tonumber(raw.personality) or 0) % 4294967296 + local id = (tonumber(raw.otId or raw.trainerId) or 0) % 4294967296 + local secret = tonumber(raw.otSecretId) or math.floor(id / 65536) + return bit.bxor(id % 65536, secret, math.floor(p / 65536), p % 65536) < 8 +end +function R.mon(out, data, gen) + if type(out) ~= "table" then + return out + end + out.status = R.status(out.status) + out.stats = R.stats(out.stats or {}, gen) + local raw = out.native or {} + local def = ((data or {}).pokemon or {})[raw.species or out.species] or {} + out.name = out.nickname and out.nickname ~= "" and out.nickname or out.name or def.name or out.species + out.dvs = gen ~= 3 and type(raw.dvs) == "table" and raw.dvs or nil -- raw is already a detached snapshot + out.shiny = R.shiny(raw, gen) + return out +end +return R diff --git a/src/mods/CallbackRecords.lua b/src/mods/CallbackRecords.lua index 6d702f4b1..4ce1679c5 100644 --- a/src/mods/CallbackRecords.lua +++ b/src/mods/CallbackRecords.lua @@ -1,3 +1,4 @@ +local Api3Records = require("src.mods.Api3Records") -- A call-local transaction. Only known record roles cross this boundary; -- opaque engine handles (Game, battle, save, map, RNG, UI callbacks) stay native. local Ids = require("src.mods.UnifiedIds") @@ -139,8 +140,22 @@ local function array(t, label, max) assert(count == n, label .. " must be dense") end -function R.new(loader, name, generation) +function R.new(loader, name, generation, api3) local ids = Ids.new(loader) + local portable = api3 and Api3Records or nil + local function readStatus(value) + if portable then + return portable.status(value) + end + return status[value] or value + end + local function writeStatus(value, old) + if portable then + return portable.nativeStatus(value, old) + end + assert(value == nil or value == false or short[value], "unknown status") + return short[value] + end local byRaw, byView, entries = {}, {}, {} local view, restore, commit, refresh local open = true @@ -193,6 +208,9 @@ function R.new(loader, name, generation) end out.specialAttack = out.specialAttack or out.special out.specialDefense = out.specialDefense or out.special + if portable then + portable.stats(out, generation) + end return out end local function fields(raw, kind) @@ -201,12 +219,14 @@ function R.new(loader, name, generation) species = read("pokemon", raw.species), item = read("items", raw.item), nickname = raw.nickname, - name = raw.name, + name = portable and raw.nickname and raw.nickname ~= "" and raw.nickname or raw.name, level = raw.level, hp = raw.hp, maxHp = raw.stats and raw.stats.hp, - status = status[raw.status] or raw.status, + status = readStatus(raw.status), isEgg = raw.isEgg == true, + dvs = portable and generation ~= 3 and type(raw.dvs) == "table" and copy(raw.dvs) or nil, + shiny = portable and portable.shiny(raw, generation), stats = statView(raw.stats), moves = moveViews(raw.moves), native = copy(raw), @@ -284,7 +304,7 @@ function R.new(loader, name, generation) role = nil end if k == "status" then - out[k] = status[v] or v + out[k] = readStatus(v) elseif k == "move" and type(v) ~= "table" then out[k] = read("moves", v) elseif kind == "action" and k == "id" and not raw.special then @@ -348,6 +368,43 @@ function R.new(loader, name, generation) for k in pairs(public) do assert(stats[k], "unknown stat " .. tostring(k)) end + if portable then + local common = public.special ~= baseline.special and public.special or nil + if generation == 1 then + for _, k in ipairs({ "specialAttack", "specialDefense" }) do + if public[k] ~= baseline[k] then + assert(common == nil or common == public[k], "conflicting special stats") + common = public[k] + end + end + elseif common ~= nil then + for _, k in ipairs({ "specialAttack", "specialDefense" }) do + assert(public[k] == baseline[k] or public[k] == common, "conflicting special stats") + end + end + for k, n in pairs(stats) do + if k ~= "special" or generation == 1 then + local value = public[k] + if common ~= nil and (k == "special" or k == "specialAttack" or k == "specialDefense") then + value = common + end + if value ~= baseline[k] then + value = integer(value, 1, 65535, "stats." .. k) + if generation == 1 and (k == "specialAttack" or k == "specialDefense") then + n = "special" + end + local target = n + ops[#ops + 1] = function() + old[target] = value + if generation > 1 and target == "spatk" and old.special ~= nil then + old.special = value + end + end + end + end + end + return + end local specialValue for _, k in ipairs({ "special", "specialAttack", "specialDefense" }) do if public[k] ~= baseline[k] and (generation == 1 or k ~= "specialDefense") then @@ -442,10 +499,16 @@ function R.new(loader, name, generation) end elseif kind == "mon" then for k in pairs(value) do - assert(monFields[k], "unknown Pokemon field " .. tostring(k)) + assert( + monFields[k] or (portable and (k == "dvs" or k == "shiny")), + "unknown Pokemon field " .. tostring(k) + ) end assert(equal(value.native, before.native), "native snapshot is read-only") assert(value.name == before.name, "name is read-only; set nickname") + if portable then + assert(equal(value.dvs, before.dvs) and value.shiny == before.shiny, "DVs and shiny are read-only") + end for _, key in ipairs({ "species", "item", "nickname", "level", "hp", "maxHp", "status", "isEgg" }) do local v = value[key] if not equal(v, before[key]) then @@ -459,8 +522,7 @@ function R.new(loader, name, generation) elseif key == "maxHp" then v = integer(v, 1, 65535, key) elseif key == "status" then - assert(v == nil or v == false or short[v], "unknown status") - v = short[v] + v = writeStatus(v, raw.status) elseif key == "nickname" then assert(v == nil or type(v) == "string", "nickname must be a string") elseif key == "isEgg" then @@ -590,8 +652,7 @@ function R.new(loader, name, generation) elseif kind == "action" and k == "id" and not value.special and v ~= before[k] then stage(raw, k, native("moves", v, old)) elseif k == "status" and v ~= before[k] then - assert(v == nil or v == false or short[v], "unknown status") - stage(raw, k, short[v]) + stage(raw, k, writeStatus(v, old)) elseif idRoles[k] and v ~= before[k] then stage(raw, k, native(idRoles[k], v, old)) elseif k == "applyShare" and name == "battle.exp_award" then diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 7d027fb2f..ba46218d0 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -838,7 +838,7 @@ function Loader:_api(mod) -- new game and a new load. local hostGen, hostId, hostInfo = 1, nil, nil do - local okV, GameVersion = pcall(require, "src.core.GameVersion") + local okV, GameVersion = pcall(engineRequire, "src.core.GameVersion") if okV and GameVersion then local okG, g = pcall(GameVersion.generation) if okG and tonumber(g) then hostGen = tonumber(g) end @@ -852,7 +852,7 @@ function Loader:_api(mod) local battle = BattleSnapshots.new(function() return loader:_game(true) end, loader) local engineVersion do - local okE, V = pcall(require, "src.core.Version") + local okE, V = pcall(engineRequire, "src.core.Version") engineVersion = okE and V and V.engine or nil end local api = { @@ -894,7 +894,7 @@ function Loader:_api(mod) label = hostInfo and hostInfo.label or hostId, engine = engineVersion, modApi = (function() - local okE, V = pcall(require, "src.core.Version") + local okE, V = pcall(engineRequire, "src.core.Version") return okE and V and V.modApi or nil end)(), developer = loader.dev == true, @@ -1199,6 +1199,9 @@ function Loader:_api(mod) return supports(self, surface, name) end end + if (mod.manifest.api or 1) >= 3 then + engineRequire("src.mods.Api3").install(api, loader, readContext, overviewContext, engineRequire) + end return api end diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index 3a3f6ff4a..3267b2c54 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -223,6 +223,12 @@ function Manifest.validate(raw, path) assert(api <= Version.modApi, ("requires mod API %d; this engine provides %d") :format(api, Version.modApi)) local strict = api >= 2 + -- API 3 is one portable contract, not three independent API-2 opt-ins. + for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do + assert(api < 3 or raw[field] == nil or raw[field] == "unified", + "API 3 requires unified " .. field) + end + local defaultFormat = api >= 3 and "unified" or "native" assert(raw.content_format == nil or raw.content_format == "native" or raw.content_format == "unified", "content_format must be native or unified") assert(raw.content_format ~= "unified" or api >= 2, @@ -244,7 +250,7 @@ function Manifest.validate(raw, path) end end - local scriptFormat = raw.script_format == nil and "native" or raw.script_format + local scriptFormat = raw.script_format == nil and defaultFormat or raw.script_format assert(scriptFormat == "native" or scriptFormat == "unified", "script_format must be native or unified") assert(scriptFormat ~= "unified" or api >= 2, "unified scripts require mod API 2") @@ -317,7 +323,7 @@ function Manifest.validate(raw, path) version = raw.version, entry = raw.entry, api = api, - content_format = raw.content_format or "native", + content_format = raw.content_format or defaultFormat, priority = tonumber(raw.priority) or 0, dependencies = array(raw.dependencies), optional_dependencies = array(raw.optional_dependencies), @@ -329,7 +335,7 @@ function Manifest.validate(raw, path) category = raw.category or "OTHER", game_version = raw.game_version, description = raw.description or "", - callback_format = raw.callback_format or "native", + callback_format = raw.callback_format or defaultFormat, github = github, support = support, updateCheck = updateCheck, diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index ce1659a02..5e6a6195d 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -1,3 +1,4 @@ +local Api3Catalog = require("src.mods.Api3Catalog") local Records = require("src.mods.CallbackRecords") local Ids = require("src.mods.UnifiedIds") local C = {} @@ -83,12 +84,19 @@ function C.install(loader, api, owner, generation) -- Capture pristine alias identities before Loader merges them into separate -- records. Each call still builds a fresh index, including later mods' IDs. local ids = Ids.new(loader) + local api3 = (api.manifest.api or 1) >= 3 + local extra = api3 and Api3Catalog for _, kind in ipairs({ "pokemon", "moves", "items" }) do ids:entries(kind) end local function check(catalog, name) assert( - type(name) == "string" and (catalog[name] or (catalog == C.events and custom(name))), + type(name) == "string" + and ( + catalog[name] + or (extra and (catalog == C.events and extra.events[name] or catalog == C.hooks and extra.hooks[name])) + or (catalog == C.events and custom(name)) + ), "unknown unified callback: " .. tostring(name) ) end @@ -98,7 +106,7 @@ function C.install(loader, api, owner, generation) local wrapped = callback if not custom(name) and not nativeEvents[name] then wrapped = function(payload) - local boundary = Records.new(loader, name, generation) + local boundary = Records.new(loader, name, generation, api3) local public = boundary.view(payload, name == "pokemon.before_give" and "gift" or "context") local values = pack(callback(public)) if name == "pokemon.before_give" then @@ -130,7 +138,7 @@ function C.install(loader, api, owner, generation) check(C.hooks, name) assert(type(callback) == "function", "hook callback must be a function") return loader.hooks:wrap(name, function(nextFn, ...) - local boundary = Records.new(loader, name, generation) + local boundary = Records.new(loader, name, generation, api3) local roles = positions[name] or {} local function translate(args) local out = { n = args.n } diff --git a/tests/api3_contract/mods/portable/main.lua b/tests/api3_contract/mods/portable/main.lua new file mode 100644 index 000000000..a428b942f --- /dev/null +++ b/tests/api3_contract/mods/portable/main.lua @@ -0,0 +1,110 @@ +-- This exact entry and manifest run on both engines. No engine imports, +-- format switches or engine-specific branches belong in this fixture. +local mod = ... +local world, battle = mod.world, mod.battle +assert(type(world) == "table" and type(battle) == "table", "early handles") +local function refused(value, reason) + assert(value == nil and type(reason) == "string" and #reason > 0, tostring(reason)) +end +refused(world:party()) +refused(battle:snapshot()) +local no, why = mod:supports("world", "not_a_method") +assert(no == false and type(why) == "string") +assert(mod:supports("content", "pokemon")) +assert(mod:supports("hooks", "evolution.check")) +assert(mod:supports("events", "pokemon.received")) +assert(mod:supports("world", "party")) +-- Known API-3 names are registrable even when this host cannot execute them. +mod.hooks:wrap("render.output", function(next, ctx) + return next(ctx) +end) +mod.events:on("checkpoint.restored", function() end) +local c = mod.content +assert(c.pokemon:has("MEW")) +assert(c.maps:supports() and c.maps.available) +assert(c.maps:has("ABSENT_MAP") == false) +local map = assert(c.maps:get("PROBE")) +map.width = 99 +assert(c.maps:get("PROBE").width == 2) +for id, row in c.maps:each() do + if id == "api3Probe" then + row.value = 88 + end +end +assert(c.maps:get("PROBE").width == 2) +local before = assert(c.pokemon:get("MEW")) +assert(before.id == "MEW" and before.baseStats.hp == 100) +assert(before.level1Moves[1] == "POUND") +assert(before.baseStats.special == 100) +assert(c.moves:get("POUND").effect == "NO_ADDITIONAL_EFFECT") +before.baseStats.hp = 222 +assert(c.pokemon:get("MEW").baseStats.hp == 100, "detached read") +assert(c.pokemon:patch("MEW", { baseStats = { hp = 101 } })) +assert(c.moves:patch("POUND", { power = 45 })) +assert(c.items:patch("POTION", { price = 123 })) +local found = false +for id, row in c.pokemon:each() do + if id == "MEW" then + found = true + assert(row.baseStats.hp == 101) + end +end +assert(found) +local report = { species = "MEW", hp = 101, move = "POUND", power = 45, price = 123 } +local function monCheck(mon) + assert(mon.species == "MEW" and mon.nickname == "Scout" and mon.name == "Scout") + assert(mon.status == "PSN", "short status") + assert(type(mon.shiny) == "boolean", "shiny presentation") + assert(mon.stats.special == 24 and mon.stats.hp == 40, "common stats") + assert(mon.moves[1].id == "POUND" and mon.moves[1].pp == 30 and mon.moves[1].maxPp == 35) +end +mod.events:on("pokemon.received", function(payload) + monCheck(payload.mon) + payload.mon.hp = 1 + report.event = true +end) +mod.hooks:wrap("evolution.check", function(next, game, mon, evolution, trigger) + monCheck(mon) + mon.hp, mon.status = 27, "BRN" + mon.stats.special = 31 + report.hook = true + report.special = 31 + return next(game, mon, evolution, trigger) +end) +mod.events:on("mod.portable.probe", function(payload) + report.custom = payload.value +end) +mod.events:emit("mod.portable.probe", { value = 7 }) +mod.exports.exercise = function() + local first = assert(world:party()) + monCheck(first[1]) + first[1].hp = 1 + assert(world:party()[1].hp == 30, "detached party") + report.party = true + report.status, report.shiny = first[1].status, first[1].shiny + assert(world:getFlag("mod:contract.flag") == false) + assert(world:setFlag("mod:contract.flag", true)) + assert(world:getFlag("mod:contract.flag") == true) + assert(world:setFlag("mod:contract.flag", false)) + assert(world:getFlag("mod:contract.flag") == false) + report.flags = true +end +mod.exports.snapshot = function() + local first = assert(battle:snapshot()) + assert(first.kind == "wild" and first.prompt == "menu") + monCheck(first.player) + assert(first.player.hp == 30 and first.player.maxHp == 40) + assert(type(first.revision) == "number" and first.revision > 0) + first.player.hp = 1 + assert(battle:snapshot().player.hp == 30, "detached battle snapshot") + refused(battle:submit({ id = 1, revision = first.revision, kind = "not_an_intent" })) + report.battle = + { kind = first.kind, prompt = first.prompt, species = first.player.species, status = first.player.status } +end +mod.exports.late = function() + local ok, value, reason = pcall(function() + return c.moves:patch("POUND", { power = 99 }) + end) + assert(not ok or (value == nil and type(reason) == "string"), "frozen content") +end +mod.exports.world, mod.exports.battle, mod.exports.report = world, battle, report diff --git a/tests/api3_contract/mods/portable/manifest.json b/tests/api3_contract/mods/portable/manifest.json new file mode 100644 index 000000000..d98d5f821 --- /dev/null +++ b/tests/api3_contract/mods/portable/manifest.json @@ -0,0 +1 @@ +{"id":"portable","name":"Portable API contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/rollback/main.lua b/tests/api3_contract/mods/rollback/main.lua new file mode 100644 index 000000000..daf6abe4a --- /dev/null +++ b/tests/api3_contract/mods/rollback/main.lua @@ -0,0 +1,6 @@ +local mod = ... +assert(mod.content.pokemon:patch("MEW", { baseStats = { hp = 199 } })) +mod.events:on("mod.portable.probe", function() + error("failed subscription leaked") +end) +error("intentional API 3 rollback") diff --git a/tests/api3_contract/mods/rollback/manifest.json b/tests/api3_contract/mods/rollback/manifest.json new file mode 100644 index 000000000..ab532b029 --- /dev/null +++ b/tests/api3_contract/mods/rollback/manifest.json @@ -0,0 +1 @@ +{"id":"rollback","name":"Rollback contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua","dependencies":["portable"]} diff --git a/tests/api3_contract/run.lua b/tests/api3_contract/run.lua new file mode 100644 index 000000000..0024a01d9 --- /dev/null +++ b/tests/api3_contract/run.lua @@ -0,0 +1,256 @@ +-- Shared harness; only fixture construction knows the native engine layout. +local M = {} +local function read(path) + local f = assert(io.open(path, "rb")) + local body = f:read("*a") + f:close() + return body +end +local function dataFor(backend, generation) + if backend == "gen1" and generation == 3 then + local fixture = require("tests.modkit.sdk").gen3Data() + fixture.maps.PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } } + return fixture + end + local stats = { hp = 100, attack = 100, defense = 100, speed = 100, special = 100 } + local mon = { + id = "MEW", + name = "MEW", + dex = 151, + types = { "PSYCHIC" }, + baseStats = stats, + catchRate = 45, + baseExp = 64, + growthRate = "MEDIUM_FAST", + spriteFront = "fixture.png", + spriteBack = "fixture.png", + frontSize = 5, + level1Moves = { "POUND" }, + learnset = {}, + evolutions = {}, + } + local effect = "NO_ADDITIONAL_EFFECT" + if generation > 1 then + stats.spatk, stats.spdef = 100, 100 + end + if backend == "gen1" and generation == 2 then + stats.special, stats.spatk, stats.spdef = nil, nil, nil + stats.specialAttack, stats.specialDefense = 100, 100 + mon.level1Moves, mon.learnset, mon.frontSize = nil, nil, nil + mon.levelMoves, mon.picSize = { { level = 1, move = "POUND" } }, 5 + effect = "EFFECT_NORMAL_HIT" + end + local pokemon = { MEW = mon } + if backend == "gen1" and generation == 2 then + pokemon.growthRates = + { MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0 } } + end + return { + pokemon = pokemon, + moves = { + POUND = { + id = "POUND", + name = "POUND", + type = "NORMAL", + power = 40, + accuracy = 100, + pp = 35, + effect = effect, + }, + }, + items = { POTION = { id = "POTION", name = "POTION", price = 300, tossable = true } }, + maps = { PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } } }, + trainers = {}, + encounters = {}, + audio = {}, + type_chart = { types = { NORMAL = {}, PSYCHIC = {} }, matchups = {} }, + gen2Maps = backend == "gen1" and generation == 2 and { + PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } }, + } or nil, + } +end +local function nativeMon(backend, gen) + local stats = { hp = 40, attack = 21, defense = 22, speed = 23, special = 24 } + local mon = { + species = "MEW", + nickname = "Scout", + name = "Native name", + level = 20, + hp = 30, + status = "PSN", + stats = stats, + moves = { { id = "POUND", pp = 30, ppUps = 0 } }, + } + if gen > 1 then + stats.spatk, stats.spdef = 24, 25 + end + if backend == "gen1" and gen > 1 then + stats.special, stats.spatk, stats.spdef = nil, nil, nil + stats.specialAttack, stats.specialDefense = 24, 25 + mon.status = gen == 2 and "poison" or 8 + if gen == 3 then + mon.species, mon.moves[1].id = 151, 1 + end + end + return mon +end +function M.run(root, backend, version, failing, scaffold) + local Loader = require("src.mods.Loader") + local Runtime = require("src.mods.Runtime") + local Version = require("src.core.GameVersion") + local Json = require("src.link.Json") + Version.set(version) + local generation = Version.generation(version) + local data = dataFor(backend, generation) + local files = {} + for _, id in ipairs(failing and { "portable", "rollback" } or { "portable" }) do + for _, name in ipairs({ "manifest.json", "main.lua" }) do + files["mods/" .. id .. "/" .. name] = read(root .. "/mods/" .. id .. "/" .. name) + end + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(assert(files[p]), "@" .. p) + end, + getInfo = function(p) + if files[p] then + return { type = "file", size = #files[p] } + end + for key in pairs(files) do + if key:sub(1, #p + 1) == p .. "/" then + return { type = "directory" } + end + end + end, + getDirectoryItems = function(p) + if p == "mods" then + return failing and { "portable", "rollback" } or { "portable" } + end + return {} + end, + } + local loader = Loader.new({ fs = fs, dev = false, generation = generation, version = version }) + local game + loader._game = function(_, loadedOnly) + if backend == "gen2" then + assert(loadedOnly, "API 3 must not bootstrap Game") + end + return game + end + local ok = loader:load(data) + if scaffold then + assert(ok, table.concat(loader.errors, "; ")) + assert(loader.mods.portable.manifest.api == 3, "generated manifest selects API 3") + local power = backend == "gen1" and generation == 3 and data.gen3Moves._rom[1].power + or data.moves.POUND.power + assert(power == 45, "generated entry changed the native move") + local report = { scaffold = true, power = power } + print("API3_CONTRACT " .. Json.encode(report)) + return report + end + local errorCount = #loader.errors + local exported = assert(loader.exports.portable, table.concat(loader.errors, "; ")) + assert(exported.report, table.concat(loader.errors, "; ")) + if failing then + assert(not ok and table.concat(loader.errors, "; "):find("intentional API 3 rollback", 1, true)) + else + assert(ok, table.concat(loader.errors, "; ")) + end + local nativeHp = backend == "gen1" and generation == 3 and data.gen3Pokemon._stats[151].hp + or data.pokemon.MEW.baseStats.hp + assert(nativeHp == 101, "rollback/native merge") + assert( + (backend == "gen1" and generation == 2 and data.gen2Maps or data.maps).PROBE.width == 2, + "passthrough reads remain detached" + ) + local raw = nativeMon(backend, generation) + local save = { party = { raw }, flags = {}, modData = {}, money = 0 } + game = { data = data, save = save, stack = { states = {} } } + if backend == "gen1" and generation == 3 then + game.session = save + end + exported.exercise() + Runtime.emit("pokemon.received", { mon = raw }) + assert(raw.hp == 30, "event read did not mutate native Pokemon") + loader.hooks:call("evolution.check", function(_, mon) + assert(mon == raw and mon.hp == 27, "native hook forwarding") + assert( + mon.status + == ( + backend == "gen1" and generation == 3 and 16 + or backend == "gen1" and generation == 2 and "burn" + or "BRN" + ), + "native status writeback" + ) + local attack = mon.stats.spatk or mon.stats.specialAttack or mon.stats.special + local defense = mon.stats.spdef or mon.stats.specialDefense or mon.stats.special + assert(attack == 31 and defense == 31, "common Special writes both later stats") + return "UNCHANGED" + end, game, raw, {}, {}) + assert(raw.hp == 27) + exported.late() + Runtime.emit("mod.portable.probe", { value = 8 }) + assert(exported.report.custom == 8, "custom callback reached live owner") + assert(#loader.errors == errorCount, "no callback errors or failed-owner subscription leaks") + if generation == 1 then + require("src.battle.TypeChart").load(data) + local player, enemy = nativeMon(backend, generation), nativeMon(backend, generation) + local function battler(mon) + return { + mon = mon, + species = mon.species, + name = mon.nickname, + curStats = mon.stats, + curTypes = { "PSYCHIC" }, + curMoves = mon.moves, + stages = {}, + } + end + local battle = { + isBattleState = true, + game = game, + data = data, + kind = "wild", + phase = "menu", + queue = {}, + player = battler(player), + enemy = battler(enemy), + enemyParty = { enemy }, + ruleset = {}, + turnCount = 0, + sides = { { battlers = {} }, { battlers = {} } }, + } + function battle:battleKind() + return "wild" + end + function battle:visibleText() + return {} + end + function battle:effectRecord() + return { accuracyChecked = true } + end + game.save.party, game.save.inventory = { player }, {} + game.stack.states = { battle } + exported.snapshot() + assert(player.hp == 30, "invalid intent never dispatched") + end + -- The entry-time world handle must follow a later Game instead of retaining the old save. + local replacement = nativeMon(backend, generation) + replacement.hp = 19 + local nextSave = { party = { replacement }, modData = {}, flags = {} } + game = { data = data, save = nextSave, stack = { states = {} } } + if backend == "gen1" and generation == 3 then + game.session = nextSave + end + assert(exported.world:party()[1].hp == 19, "captured facade follows current game") + assert(exported.battle:snapshot() == nil, "no battle") + local result = exported.report + assert(result.event and result.hook and result.party and result.custom == 8) + print("API3_CONTRACT " .. Json.encode(result)) + return result +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua new file mode 100644 index 000000000..b7dcb960d --- /dev/null +++ b/tests/api3_contract/worker.lua @@ -0,0 +1,10 @@ +-- Separate process per engine/version: modules and runtime buses cannot leak. +package.path = "./?.lua;./?/init.lua;" .. package.path +love = nil +local root, backend, version, mode = arg[1], arg[2], arg[3], arg[4] +assert( + root and backend and version and (mode == "normal" or mode == "rollback" or mode == "scaffold"), + "invalid worker arguments" +) +local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") +dofile(codeRoot .. "/run.lua").run(root, backend, version, mode == "rollback", mode == "scaffold") diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua new file mode 100644 index 000000000..a34fe4ae9 --- /dev/null +++ b/tests/engine/api3_compat.lua @@ -0,0 +1,226 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Manifest = require("src.mods.Manifest") +local Loader = require("src.mods.Loader") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Json = require("src.link.Json") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love } +love = nil +local function manifest(api, extra) + local raw = { id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua", api = api } + for k, v in pairs(extra or {}) do + raw[k] = v + end + return raw +end +for _, api in ipairs({ 1, 2, 3 }) do + local m = Manifest.validate(manifest(api)) + local format = api == 3 and "unified" or "native" + for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do + T.eq(m[field], format, "API " .. api .. " default " .. field) + end +end +T.eq(Manifest.validate(manifest()).api, 1, "missing API remains legacy") +for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do + T.raises(function() + Manifest.validate(manifest(3, { [field] = "native" })) + end, "API 3 requires unified", "API 3 refuses contradictory " .. field) + T.eq( + Manifest.validate(manifest(2, { [field] = "unified" }))[field], + "unified", + "API 2 keeps explicit opt-in" + ) + T.raises(function() + Manifest.validate(manifest(1, { [field] = "unified" })) + end, "mod API 2", "API 1 opt-in remains rejected") +end +T.raises(function() + Manifest.validate(manifest(4)) +end, "provides 3", "future API not silently accepted") +T.raises(function() + Manifest.validate(manifest(3.5)) +end, "positive integer", "fractional API rejected") +local function boot(api, extra, second, version) + Version.set(version or "red") + local rows = { probe = manifest(api, extra) } + if second then + rows.other = manifest(api, extra) + rows.other.id = "other" + end + local files = {} + for id, raw in pairs(rows) do + files["mods/" .. id .. "/manifest.json"] = Json.encode(raw) + files["mods/" .. id .. "/main.lua"] = "local mod = ...; mod.exports.api = mod" + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getInfo = function(p) + if files[p] then + return { type = "file" } + end + if p == "mods" or rows[p:match("^mods/(.*)$")] then + return { type = "directory" } + end + end, + getDirectoryItems = function(p) + return p == "mods" and (second and { "probe", "other" } or { "probe" }) or {} + end, + } + local loader = Loader.new({ fs = fs, dev = true }) + local game, probes = nil, 0 + loader._game = function(_, loadedOnly) + probes = probes + 1 + if api == 3 then + assert(loadedOnly, "API 3 requested Game bootstrap") + end + return game + end + T.check(loader:load({ pokemon = {}, moves = {}, items = {} }), "real loader accepts API " .. api) + local mod = assert(loader.apis.probe) + return mod, loader, function(value) + game = value + end, function() + return probes + end +end +-- Real legacy loaders keep native defaults, API-2 status vocabulary and callback catalog. +for _, api in ipairs({ 1, 2 }) do + local m = boot(api) + T.eq(m.contentFormat, "native", "legacy content native") + T.eq(m.callbackFormat, "native", "legacy callbacks native") + T.eq(m.scriptFormat, "native", "legacy scripts native") +end +do + local m, loader, attach = + boot(2, { content_format = "unified", callback_format = "unified", script_format = "unified" }) + local mon = { + species = "MEW", + nickname = "Scout", + hp = 10, + status = "PSN", + stats = { hp = 20, special = 9 }, + moves = {}, + } + attach({ + save = { party = { mon } }, + data = { pokemon = {}, moves = {}, items = {} }, + stack = { + states = {}, + }, + }) + T.eq(m.world:party()[1].status, "poison", "API-2 unified party status unchanged") + local seen + m.events:on("pokemon.received", function(p) + seen = p.mon.status + end) + Runtime.emit("pokemon.received", { mon = mon }) + T.eq(seen, "poison", "API-2 unified event status unchanged") + T.raises(function() + m.events:on("checkpoint.restored", function() end) + end, "unknown unified callback", "API-2 catalog unchanged") +end +-- Availability must be honest, and a probe cannot construct Game or attach a world. +do + local m, loader, attach, probes = boot(3, nil, true) + T.eq(probes(), 0, "entry does not resolve Game") + local world, battle = m.world, m.battle + T.eq(type(world), "table", "world facade exists before Game") + T.eq(type(world.party), "function", "early world method callable") + T.eq(type(battle.snapshot), "function", "early battle method callable") + for _, name in ipairs({ + "party", + "current", + "activeBlockAt", + "liveMaps", + "queueScript", + "getFlag", + "setFlag", + }) do + T.eq(m:supports("world", name), true, "implemented world method " .. name) + end + for _, name in ipairs({ + "warpTo", + "toggleObject", + "replaceBlock", + "spawnNpc", + "removeNpc", + "npc", + "startWildBattle", + "invalidateMap", + }) do + local available, why = m:supports("world", name) + T.eq(available, false, "no unverified native promise for " .. name) + T.check(type(why) == "string", "unavailable reason for " .. name) + local ok, reason = world[name](world, "bad") + T.eq(ok, nil, "unsupported method does not dispatch " .. name) + T.check(reason:find("unsupported:", 1, true), "explicit refusal for " .. name) + end + T.eq(probes(), 0, "capability and unsupported calls do not resolve Game") + T.eq(world:party(), nil, "no-game party refuses safely") + T.eq(battle:snapshot(), nil, "no-game battle refuses safely") + T.eq(m.datasets:open("gold"), nil, "unavailable dataset API callable") + T.eq(m:supports("hooks", "render.output"), false, "recognized output hook is not falsely available") + T.check( + pcall(function() + m.hooks:wrap("render.output", function(next, ctx) + return next(ctx) + end) + end), + "known absent hook can be registered" + ) + T.check( + pcall(function() + m.events:on("checkpoint.restored", function() end) + end), + "known absent event can be registered" + ) + T.raises(function() + m.events:on("not.catalogued", function() end) + end, "unknown unified callback", "unknown API-3 name rejected") + local save = { party = {}, flags = {}, modData = {} } + attach({ save = save, data = {}, stack = { states = {} } }) + local flags = {} + m.events:on("flag.changed", function(p) + flags[#flags + 1] = { p.name, p.value } + end) + T.eq(world:getFlag("mod:quest.finished"), false, "flag read defaults false") + T.eq(next(save.modData), nil, "flag read allocates no bucket") + T.check(world:setFlag("mod:quest.finished", true), "private flag set") + T.check(world:setFlag("mod:quest.finished", true), "setting same flag accepted") + T.eq(#flags, 1, "unchanged flag emits no duplicate event") + T.eq(loader.apis.other.world:getFlag("mod:quest.finished"), false, "other mod cannot see flag") + T.check(world:setFlag("mod:quest.finished", false), "private flag cleared") + T.eq(save.modData.probe.worldFlags["quest.finished"], nil, "clear removes saved boolean") + T.same( + flags, + { { "mod.probe.quest.finished", true }, { "mod.probe.quest.finished", false } }, + "portable flag notifications" + ) + T.eq(next(save.flags), nil, "private flags never touch native flags") + T.eq(world:setFlag("mod:bad", "yes"), nil, "non-boolean flag rejected") + T.eq(world:setFlag("NATIVE_FLAG", true), nil, "unverified native flag mapping refused") + T.eq(world:setFlag("mod:", true), nil, "empty private flag rejected") + save.modData.probe.worldFlags = false + T.eq(world:setFlag("mod:test", true), nil, "malformed store rejected") + T.eq(save.modData.probe.worldFlags, false, "malformed store never overwritten") + attach({ save = { party = {} }, data = {}, stack = { states = {} } }) + T.eq(world:getFlag("mod:quest.finished"), false, "same handle follows replacement save") + T.eq(m.world, world, "world handle identity stable") + T.eq(m.battle, battle, "battle handle identity stable") +end +for _, version in ipairs({ "platinum", "black" }) do + local m = boot(3, nil, false, version) + T.eq(m:supports("world", "party"), false, "no shared Gen1 contract claimed for " .. version) + local value, why = m.world:party() + T.eq(value, nil, "unsupported generation call refuses") + T.check(why:find("unsupported:", 1, true), "unsupported generation is explicit") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, love = saved[2], saved[3], saved[4], saved[5] +T.finish("API 3 entry compatibility") diff --git a/tests/engine/api3_entry.lua b/tests/engine/api3_entry.lua new file mode 100644 index 000000000..7eb1a2712 --- /dev/null +++ b/tests/engine/api3_entry.lua @@ -0,0 +1,16 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.run") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love } +love = nil +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + for _, failing in ipairs({ false, true }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version, failing) + T.check(ok, version .. (failing and " rollback: " or ": ") .. tostring(why)) + end +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, love = saved[2], saved[3], saved[4], saved[5] +T.finish("API 3 cross-engine contracts") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py new file mode 100644 index 000000000..48ba0eb9b --- /dev/null +++ b/tools/api3-contract-check/check.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Run the identical API-3 mod files on Gen2Recomped and pinned Gen1Recomp.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +GEN1_REF = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" +VERSIONS = ("red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald") + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--gen1", required=True, type=Path, help="clean checkout at the pinned Gen1 commit") + parser.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + parser.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + parser.add_argument("--report", type=Path) + args = parser.parse_args() + gen1, gen2 = args.gen1.resolve(), args.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1_REF: + parser.error(f"Gen1 must be pinned to {GEN1_REF}") + if git(gen1, "status", "--porcelain", "--untracked-files=normal"): + parser.error("Gen1 reference has tracked modifications; use an isolated checkout") + fixtures = gen2 / "tests" / "api3_contract" + manifest = {} + for path in sorted((fixtures / "mods").rglob("*")): + if path.is_file(): + manifest[str(path.relative_to(fixtures))] = hashlib.sha256(path.read_bytes()).hexdigest() + results = [] + scaffold_temp = tempfile.TemporaryDirectory(prefix="api3-scaffold-") + scaffold_root = Path(scaffold_temp.name) + for name, options in (("portable", ["--api", "3"]), ("legacy", [])): + subprocess.run([sys.executable, str(gen2 / "tools/modkit.py"), "scaffold", name, + "--dest", str(scaffold_root / "mods"), "--quiet", *options], + cwd=gen2, check=True, capture_output=True, text=True, timeout=60) + generated = json.loads((scaffold_root / "mods/portable/manifest.json").read_text()) + assert generated["api"] == 3 and "game_version" not in generated + assert generated["games"] == ["gen1", "gen2", "gen3"] + legacy = json.loads((scaffold_root / "mods/legacy/manifest.json").read_text()) + assert legacy["api"] == 2 and legacy["game_version"].startswith(">=") + generated_hashes = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() + for path in (scaffold_root / "mods/portable").iterdir() + if path.is_file()} + for version in VERSIONS: + for mode in ("normal", "rollback", "scaffold"): + values = {} + for backend, engine in (("gen1", gen1), ("gen2", gen2)): + source = scaffold_root if mode == "scaffold" else fixtures + command = [args.lua, str(fixtures / "worker.lua"), str(source), backend, version, mode] + run = subprocess.run(command, cwd=engine, text=True, capture_output=True, timeout=60) + reports = [line.removeprefix("API3_CONTRACT ") for line in run.stdout.splitlines() + if line.startswith("API3_CONTRACT ")] + if run.returncode or len(reports) != 1: + raise RuntimeError(f"{backend}/{version}/{mode} failed:\n{run.stdout}{run.stderr}") + values[backend] = json.loads(reports[0]) + if values["gen1"] != values["gen2"]: + raise RuntimeError(f"contract mismatch for {version}/{mode}: {values}") + results.append({"version": version, "mode": mode, "result": values["gen1"]}) + print(f"PASS {version}/{mode}: identical mod and public result") + report = {"gen1_ref": GEN1_REF, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "gen2_tracked_changes": bool(git(gen2, "status", "--porcelain", "--untracked-files=no")), + "fixture_sha256": manifest, "scaffold_sha256": generated_hashes, "comparisons": results} + if args.report: + args.report.write_text(json.dumps(report, indent=2) + "\n") + scaffold_temp.cleanup() + print(f"API 3: {len(results)} cross-engine comparisons, {len(results) * 2} isolated loader runs passed") + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, subprocess.SubprocessError, OSError, ValueError, AssertionError) as error: + print(error, file=sys.stderr) + sys.exit(1) diff --git a/tools/modkit.py b/tools/modkit.py index fb0b91499..91c23ffa4 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -323,10 +323,9 @@ def check_requires(repo, mod_dir, manifest): "id": "{{id}}", "name": "{{name}}", "version": "0.1.0", - "api": 2, - "entry": "main.lua", - "profile": "{{profile}}", - "game_version": ">={{game_version}} <{{next_major}}.0.0", + "api": {{api}}, + "entry": "main.lua",{{games_line}} + "profile": "{{profile}}",{{engine_range_line}} "category": "GAMEPLAY", "priority": 100, "dependencies": [], @@ -378,7 +377,7 @@ def check_github_field(manifest): "MK001", "error", str(err), "manifest.json")) return findings, notes -MAIN_CONTENT = """-- {{id}}: a content-profile mod (api 2). +MAIN_CONTENT = """-- {{id}}: a content-profile mod (api {{api}}). -- The 10-minute loop: edit, save, F5 in a POKEPORT_DEV=1 game, repeat. return function(mod) -- patch, not override: every field you do not name keeps its base value @@ -391,7 +390,16 @@ def check_github_field(manifest): end """ -MAIN_OVERHAUL = """-- {{id}}: an overhaul-profile mod (api 2). +MAIN_PORTABLE = """-- {{id}}: portable API {{api}} entry for Gen1Recomp and Gen2Recomped. +return function(mod) + if mod.content.moves:has("POUND") then + local ok, reason = mod.content.moves:patch("POUND", { power = 45 }) + if not ok then mod.log:warn("%s", reason) end + end +end +""" + +MAIN_OVERHAUL = """-- {{id}}: an overhaul-profile mod (api {{api}}). return function(mod) mod.options:define({ { key = "difficulty", label = "DIFFICULTY", kind = "choice", @@ -411,7 +419,7 @@ def check_github_field(manifest): end """ -MAIN_TC = """-- {{id}}: a total-conversion-profile mod (api 2). +MAIN_TC = """-- {{id}}: a total-conversion-profile mod (api {{api}}). return function(mod) -- the new game itself: spawn, names, money (field.boot, D11) -- mod.content.field:patch("boot", { @@ -442,7 +450,7 @@ def check_github_field(manifest): README_TEMPLATE = """# {{name}} -A `{{profile}}` mod for the LOVE2D Pokemon Red engine (mod api 2). +A `{{profile}}` mod for {{target_engine}} (mod api {{api}}). ## Layout @@ -489,6 +497,10 @@ def cmd_scaffold(args, repo): "{{game_version}}": engine, "{{next_major}}": str(next_major), "{{extra}}": extra, "{{github_line}}": github_line, + "{{api}}": str(args.api), + "{{games_line}}": '\n "games": ["gen1", "gen2", "gen3"],' if args.api == 3 else "", + "{{engine_range_line}}": "" if args.api == 3 else f'\n "game_version": ">={engine} <{next_major}.0.0",', + "{{target_engine}}": "Gen1Recomp and Gen2Recomped" if args.api == 3 else "the LOVE2D Pokemon Red engine", "{{experimental}}": "true" if getattr(args, "experimental", False) else "false", } @@ -504,6 +516,8 @@ def emit(rel, template): main = {"content": MAIN_CONTENT, "overhaul": MAIN_OVERHAUL, "total_conversion": MAIN_TC}[profile] + if args.api == 3: + main = MAIN_PORTABLE layout_extra = "" if profile == "total_conversion": layout_extra = "- `transforms.lua` - asset transforms over the player's cache\n" @@ -519,7 +533,7 @@ def emit(rel, template): emit("transforms.lua", TRANSFORMS_TEMPLATE) if not args.quiet: - print(f"created {dest} ({profile} profile, api 2)") + print(f"created {dest} ({profile} profile, api {args.api})") print(f"next: python3 tools/modkit.py validate {args.id}") return 0 @@ -1964,7 +1978,7 @@ def main(argv): p.add_argument("id") p.add_argument("--profile", default="content", choices=["content", "overhaul", "total_conversion"]) - p.add_argument("--api", type=int, default=2) + p.add_argument("--api", type=int, choices=(1, 2, 3), default=2) p.add_argument("--github", default="", help="optional owner/repo (enables launcher auto-update)") p.add_argument("--experimental", action="store_true", From ab7d941dcdb58cb9d8a346ffb0aa6c6b8ef8a867 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:31:07 +0200 Subject: [PATCH 13/67] Add validated API 3 world warps and cross-engine contracts --- docs/api3-portability.md | 15 +- docs/api3-warp.md | 130 +++++++ docs/modding.md | 1 + src/mods/Api3.lua | 7 +- src/mods/Api3Warp.lua | 248 +++++++++++++ src/mods/Loader.lua | 4 + src/world/OverworldController.lua | 12 +- src/world/WorldActions.lua | 2 + tests/api3_contract/mods/warp/main.lua | 23 ++ tests/api3_contract/mods/warp/manifest.json | 1 + tests/api3_contract/run.lua | 1 + tests/api3_contract/warp.lua | 387 ++++++++++++++++++++ tests/api3_contract/worker.lua | 11 +- tests/engine/api3_compat.lua | 3 +- tests/engine/api3_warp.lua | 249 +++++++++++++ tools/api3-contract-check/check.py | 2 +- 16 files changed, 1084 insertions(+), 12 deletions(-) create mode 100644 docs/api3-warp.md create mode 100644 src/mods/Api3Warp.lua create mode 100644 tests/api3_contract/mods/warp/main.lua create mode 100644 tests/api3_contract/mods/warp/manifest.json create mode 100644 tests/api3_contract/warp.lua create mode 100644 tests/engine/api3_warp.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 52f592613..a484539fa 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -76,11 +76,12 @@ The common world calls currently enabled for generations 1–3 are: - `canReorderParty`, `reorderParty`, `availableFieldActions`, `useFieldAction`, `canFly`, `flyTo`; - `getFlag`/`setFlag` for private `mod:` boolean flags; -- `queueScript` for the existing bounded portable script language. +- `queueScript` for the existing bounded portable script language; +- [`warpTo`](api3-warp.md) for validated native map transitions. Check `mod:supports("world", name)` before relying on a call. The other Gen1 world method names remain callable but explicitly return -`nil, "unsupported: ..."`: warp/object/block mutations, runtime NPC operations, +`nil, "unsupported: ..."`: object/block mutations, runtime NPC operations, `startWildBattle` and `invalidateMap` have not yet been verified against the Gen1 API-3 contract. Their native/API-2 counterparts are unaffected. Native symbolic flag mappings and `mod.datasets:open` are also explicitly unavailable. @@ -126,15 +127,19 @@ process. The shared harness constructs native-shaped synthetic datasets; the mod fixtures contain no engine imports or engine-specific branches. The runner also generates an API-3 mod through the real scaffold CLI and loads those same generated files on both engines for each version. This adds -eight comparisons to the sixteen normal/rollback cases: 24 comparisons and -48 isolated loader runs. It compares JSON projections of public results and +eight comparisons to the sixteen normal/rollback cases. Eight additional warp +cases use the same public mod on both engines and native-shaped timing seams: +32 comparisons and 64 isolated loader runs. It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge results, detached core and passthrough reads, callable early handles, later Game replacement, party fields, private flags, Gen1 battle snapshot/invalid-intent boundaries, callback payloads and native writeback, custom events, unsupported-name handling, frozen content and failed -owner cleanup. Separate Gen2 tests check API-1/2 behavior, contradictory +owner cleanup. Warp cases also compare validation errors, native dispatch +arguments, destination events, boundary coordinates, arrival options, callback +argument holes/replay, stale sessions/owners, synchronous completion and error +attribution. Separate Gen2 tests check API-1/2 behavior, contradictory manifest fields, honest availability and malformed flag stores. CI pins the same external commit and uploads the report. diff --git a/docs/api3-warp.md b/docs/api3-warp.md new file mode 100644 index 000000000..4b86542dc --- /dev/null +++ b/docs/api3-warp.md @@ -0,0 +1,130 @@ +# Portable API-3 world warps + +API-3 mods can use the same public call on Gen1Recomp and Gen2Recomped: + +```lua +local mod = ... +local world = mod.world -- Safe to retain at entry; does not create Game. + +-- Call later, from an idle overworld interaction/update. +local function goToDestination(mapId, cellX, cellY) + if not mod:supports("world", "warpTo") then return end + local accepted, reason = world:warpTo(mapId, cellX, cellY, "down", { + keepMusic = true, + onDone = function() + mod.log:info("Arrived") + end, + }) + if not accepted then mod.log:warn("Warp declined: %s", reason) end +end +``` + +`warpTo(mapId, x, y, facing, options)` returns `true` after native admission, +or `nil, reason`. Admission does not mean arrival. It uses the existing native +`OverworldState:startWarpTo` transition, map-entry events, spawn bookkeeping +and arrival-tile guard. No direct player/save relocation bypasses that path. +This API-3 adapter does not change API-1/2 facade defaults or validation. +Generations 1–3 advertise support; API 3 still refuses this world operation on +Gen4/5. Availability is a capability, not a promise that the current world is +idle or the named map exists. Reading the facade or querying support does not +bootstrap Game. + +## Admission and coordinates + +The current Game must have a save and a live, topmost overworld. Screens, +native/portable scripts, queued/parallel scripts, movement, transitions, +field/controller locks, link sessions and another pending API-3 warp decline. +Gen2Recomped reuses its existing world-action admission guard and checks its +additional native foreground owners. Idle-state details differ between the +engines; both keep their own native readiness checks. + +Map IDs are nonempty strings of at most 256 bytes, without control characters. +The map must exist in the current merged `game.data.maps`; custom merged maps +are supported. Gen3 also accepts unprefixed names when the current version's +prefixed map exists (`FR_`, `EM_`, `RU_`, `SA_`). An exact ID wins over an alias. +There is no cross-game map-name translation: the mod selects a real map in +the active dataset. + +`x` and `y` are finite, nonnegative whole numbers in zero-based **16-pixel +walk cells**. Gen1/2 dimensions are twice the map's block dimensions; Gen3 +uses the map's metatile dimensions directly. The final valid cell is +`widthInCells - 1, heightInCells - 1`. Invalid/missing map dimensions refuse +before dispatch. Coordinates are bounded, not clamped; terrain passability is +not part of this contract. + +Facing is `up`, `down`, `left` or `right`. Omitted facing follows Gen1's common +contract: `down` on generation 1, current player facing on generations 2/3, +with `down` as fallback. Explicit facing is recommended for portable scenes. + +Options must be a plain table. Only these fields are accepted: + +| Field | Meaning | +| --- | --- | +| `arrive` | `"fly"` or `"teleport"`, using native arrival presentation | +| `keepMusic` | Boolean; forwarded to native map loading | +| `onDone` | Function, called at most once after accepted native completion | +| `fade` | Boolean, generation 3 only; false uses the same native midpoint/completion without pushing a fade screen | + +Unknown options, invalid types and unsupported `fade` use decline the whole +request before native dispatch. Options are copied; later caller edits cannot +change an accepted request. Invalid admission does not set arrival effects, +change maps/coordinates/saves, raise map-entry events or call `onDone`. +Representative reasons include `no game`, `no overworld`, `no save`, +`world is busy`, `invalid map id`, `unknown map: `, `invalid coordinates`, +`coordinates out of bounds`, `invalid facing`, `invalid options`, +`invalid arrival`, `invalid callback`, `unsupported fade` and +`unknown warp option: `. These are admission failures, not exceptions. +Native failures after dispatch can already have native effects; there is no +transactional rollback of an entered transition. + +## Callback ownership + +`onDone` runs only for an accepted request whose Game, save, data, stack, +overworld, player, version, loader buses and mod lease are still current, at +the requested destination. It preserves the native callback arguments, +including nil holes. Synchronous native completion is held until the native +return confirms admission. A rejected/throwing native dispatch cannot invoke +the mod callback, and duplicate native completions are ignored. + +A replaced session/owner observed at input-step boundaries, another map entry +or leaving the destination retires the completion permanently. Returning to an +older session cannot revive a completion already retired. Loader rollback, +reload and teardown retire the facade and its pending callback; a retained +facade returns `nil, "warp facade is retired"`. Retirement does not pop screens +or undo work belonging to the native controller. + +The callback is attributed to its mod, errors enter the loader's runtime error +list once, and the previous runtime owner is restored. An error never retries +a warp or the callback. A completion may start a new request after the native +world becomes idle. The adapter adds no synthetic map-entry event; normal +native map lifecycle remains in control. + +The portable `queueScript` language is unchanged: adding this direct method +does not enable a `warp` script row. + +## Verification + +```sh +luajit tests/engine/api3_warp.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The existing Gen1 pin remains +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same manifest/entry is loaded by +both real loaders for Red, Blue, Yellow, Gold, Silver, Crystal, FireRed and +Emerald. The shared driver builds each engine's native-shaped Game/world data +and replaces only native transition timing seams. It compares validation +reasons, dispatch arguments, public destination events and callback results. +The mod has no engine imports or backend-specific branches. Together with the +existing normal, rollback and generated-starter cases, CI now performs 32 +comparisons / 64 isolated loader runs. + +The local suite additionally drives Gen2Recomped's real native +`startWarpTo` and `Transition` using synthetic map materialization, including +fade/no-fade, music options, native bookkeeping, lock release and one-shot +completion. Further cases cover host-specific locks, malformed maps, callback +reentry, native refusal/exception, lease retirement and API-2 behavior. These +are ROM-free runtime-boundary tests; no retail-ROM playthrough or rendered +arrival-effect equivalence is claimed. diff --git a/docs/modding.md b/docs/modding.md index a48460651..f29c2f6c8 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -23,6 +23,7 @@ luajit tools/gen_registry_docs.lua ../gen1recomp.wiki The [API-3 entry and cross-engine contracts](api3-portability.md) describe using the same manifest and mod source on Gen1Recomp and Gen2Recomped. API 3 selects the unified adapters automatically; API 1/2 keep their defaults. +See [API-3 world warps](api3-warp.md) for validated map transitions and callback lifetime. ## Feature queries (`mod:supports`) diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index f5de4a039..6f2de9444 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -2,6 +2,7 @@ -- callable handles but never delegate to a native method with a different contract. local Records = require("src.mods.Api3Records") local Runtime = require("src.mods.Runtime") +local Warp = require("src.mods.Api3Warp") local copy = require("src.mods.Merge").deepCopy local A = {} local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty @@ -12,7 +13,7 @@ local SUPPORTED = {} for name in ([[overworld current activeBlockAt party canReorderParty reorderParty availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters - setFlag getFlag liveMaps queueScript]]):gmatch("%S+") + setFlag getFlag liveMaps queueScript warpTo]]):gmatch("%S+") do SUPPORTED[name] = true end @@ -78,10 +79,14 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) end return nativeWorld end + local warp = supportedGeneration and Warp.new(loader, api.id, gen, current, native) local function dispatch(name, ...) if not supportedGeneration or not SUPPORTED[name] then return nil, "unsupported: API 3 world method " .. name end + if name == "warpTo" then + return warp(...) + end local game = current() if not game then return nil, "no game" diff --git a/src/mods/Api3Warp.lua b/src/mods/Api3Warp.lua new file mode 100644 index 000000000..8deba26b3 --- /dev/null +++ b/src/mods/Api3Warp.lua @@ -0,0 +1,248 @@ +-- API-3 warp admission and callback lifetime over the native overworld path. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Actions = require("src.world.WorldActions") +local W = {} +local contexts = setmetatable({}, { __mode = "k" }) +local observers = setmetatable({}, { __mode = "k" }) +local pending = setmetatable({}, { __mode = "k" }) +local dirs = { up = true, down = true, left = true, right = true } +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function integer(n) + return type(n) == "number" and n == n and n >= 0 and n < math.huge and n % 1 == 0 +end +local function retire(token) + token.done = true + token.callback = nil + if pending[token.game] == token then + pending[token.game] = nil + end +end +function W.cancelAll(loader, owner) + for candidate, context in pairs(contexts) do + if not loader or candidate == loader then + if owner then + context.owners[owner] = nil + else + contexts[candidate] = nil + end + end + end + for _, token in pairs(pending) do + if (not loader or token.loader == loader) and (not owner or token.owner == owner) then + retire(token) + end + end +end +local function inspect(loader) + for _, token in pairs(pending) do + if token.loader == loader and not token.valid() then + retire(token) + end + end +end +local function observe(loader) + local old = observers[loader] + if old and old[1] == loader.hooks then + return + end + observers[loader] = setmetatable({ loader.hooks }, { __mode = "v" }) + -- Installed at facade creation, never spliced into a dispatching hook chain. + loader.hooks:wrap("input.step", function(nextFn, ...) + if next(pending) == nil then + return nextFn(...) + end + inspect(loader) + local result = pack(pcall(nextFn, ...)) + inspect(loader) + if not result[1] then + error(result[2], 0) + end + return unpack(result, 2, result.n) + end, math.huge, "engine:api3_warp") + for _, event in ipairs({ "map.entered", "map.exited" }) do + local name = event + loader.events:on(name, function(payload) + for _, token in pairs(pending) do + if token.loader == loader then + if not token.valid() then + retire(token) + elseif name == "map.entered" then + if token.entered or payload.mapId ~= token.destination then + retire(token) + else + token.entered = true + end + elseif token.entered then + retire(token) + end + end + end + end, math.huge, "engine:api3_warp") + end +end +function W.new(loader, owner, generation, current, native) + local context = contexts[loader] + if not context then + context = { owners = {} } + contexts[loader] = context + end + local lease = {} + context.owners[owner] = lease + local function licensed() + return contexts[loader] == context and context.owners[owner] == lease + end + observe(loader) + return function(mapId, x, y, facing, options) + if not licensed() then + return nil, "warp facade is retired" + end + local game = current() + if not game then + return nil, "no game" + end + local api = native(game) + local ow = api:overworld() + if not ow then + return nil, "no overworld" + end + local ready, why = Actions.ready(game, ow) + if not ready then + return nil, why + end + inspect(loader) + if pending[game] or ow.gen3Locked or #(ow.parallelRunners or {}) > 0 or #(ow.parallelQueue or {}) > 0 then + return nil, "world is busy" + end + if type(mapId) ~= "string" or #mapId == 0 or #mapId > 256 or mapId:find("%c") then + return nil, "invalid map id" + end + local maps = game.data and game.data.maps + local destination = mapId + if generation == 3 and maps and not maps[destination] then + local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_" + if maps[prefix .. destination] then + destination = prefix .. destination + end + end + local def = maps and maps[destination] + if not def then + return nil, "unknown map: " .. mapId + end + if not integer(x) or not integer(y) then + return nil, "invalid coordinates" + end + local scale = generation == 3 and 1 or 2 + if not integer(def.width) or def.width == 0 or not integer(def.height) or def.height == 0 then + return nil, "invalid map dimensions" + end + if x >= def.width * scale or y >= def.height * scale then + return nil, "coordinates out of bounds" + end + if facing ~= nil and not dirs[facing] then + return nil, "invalid facing" + end + if options ~= nil and (type(options) ~= "table" or getmetatable(options) ~= nil) then + return nil, "invalid options" + end + local opts = {} + for key, value in pairs(options or {}) do + if key == "arrive" then + if value ~= "fly" and value ~= "teleport" then + return nil, "invalid arrival" + end + elseif key == "onDone" then + if type(value) ~= "function" then + return nil, "invalid callback" + end + elseif key == "keepMusic" or key == "fade" then + if type(value) ~= "boolean" then + return nil, "invalid options" + end + if key == "fade" and generation ~= 3 then + return nil, "unsupported fade" + end + else + return nil, "unknown warp option: " .. tostring(key) + end + opts[key] = value + end + facing = facing or (generation ~= 1 and ow.player.facing) or "down" + local hooks, events = Runtime.hooks, Runtime.events + local token = + { game = game, loader = loader, owner = owner, destination = destination, callback = opts.onDone } + local save, data, stack, player, version = game.save, game.data, game.stack, ow.player, Version.get() + function token.valid() + return not token.done + and licensed() + and current() == game + and game.save == save + and game.data == data + and game.stack == stack + and ow.player == player + and Version.get() == version + and Runtime.hooks == hooks + and Runtime.events == events + and native(game):overworld() == ow + end + local accepted, deferred + local function finish(...) + if token.done then + return + end + if accepted == nil then + if not deferred then + deferred = pack(...) + end + return + end + local valid = token.valid() and ow.map and ow.map.id == destination + local callback = valid and token.callback + retire(token) + if not accepted or not callback then + return + end + local previous = Runtime.currentMod + Runtime.currentMod = owner + local result = pack(pcall(callback, ...)) + Runtime.currentMod = previous + if not result[1] then + Runtime.reportError(owner, "world warp callback failed: " .. tostring(result[2])) + end + end + pending[game] = token + local arrival = ow.arriveWarp + if opts.arrive then + ow.arriveWarp = opts.arrive + end + local called, result, detail = pcall( + ow.startWarpTo, + ow, + destination, + x, + y, + facing, + finish, + { via = "warp", keepMusic = opts.keepMusic, fade = opts.fade } + ) + -- The native controller is a void method. Explicit false/refusal and errors + -- never count as admission; a callback fired before that result is deferred. + accepted = called and result ~= false and not (result == nil and detail ~= nil) + if not accepted then + retire(token) + ow.arriveWarp = arrival + if not called then + return nil, "world operation failed: " .. tostring(result) + end + return nil, detail or "world operation failed" + end + if deferred then + finish(unpack(deferred, 1, deferred.n)) + end + return true + end +end +return W diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index ba46218d0..17ecd49e0 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -12,6 +12,7 @@ local Version = require("src.core.Version") local Assets = require("src.render.Assets") local ModUI = require("src.ui.ModUI") local PortableScripts = require("src.mods.PortableScripts") +local Api3Warp = require("src.mods.Api3Warp") local AssetTransform = require("src.mods.AssetTransform") local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") @@ -1262,6 +1263,7 @@ end -- migration it took goes with them. The journal only exists around an -- entry chunk; a later failure (script validation) purges every registry. function Loader:_rollback(modId) + Api3Warp.cancelAll(self, modId) PortableScripts.cancelAll(self, modId, "mod rolled back") for name in pairs(self.journal or self.content) do self.content[name]:rollback(modId) @@ -1377,6 +1379,7 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + Api3Warp.cancelAll() PortableScripts.cancelAll(nil, nil, "mods reloaded") -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST -- LOAD'S MODS. EXIT GAME -> launcher -> Play runs every mod's main.lua @@ -1721,6 +1724,7 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + Api3Warp.cancelAll() PortableScripts.cancelAll(nil, nil, "mods unloaded") local undone = select(1, ModSeams.unwindAll()) pcall(function() diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 05f6dc9d0..1df5b2b0c 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -14777,7 +14777,7 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) self.arriveWarp = nil local fromId = self.map.id local fromSection = self.map.def and self.map.def.regionMapSection - Game.stack:push(Transition.new(Game, function() + local transition = Transition.new(Game, function() self:setMap(mapId, x, y, facing or "down", opts) self:noteGen2Spawn(fromId) -- FIRERED'S SIDE STAIRS (ExitStairsMovement): you land ON the stair tile @@ -14967,7 +14967,15 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) -- side, which is what the scripts already do. if self.player then self.player.inputLocked = false end if onDone then onDone() end - end)) + end) + -- API 3 exposes the Gen 3 no-fade option. Reuse the exact native midpoint + -- and completion path; ordinary/native callers keep their existing fade. + if opts and opts.fade == false then + transition.onMidpoint() + transition.onDone() + else + Game.stack:push(transition) + end end -- Re-read a map record after its data changed (WorldAPI:invalidateMap, diff --git a/src/world/WorldActions.lua b/src/world/WorldActions.lua index d6172f82c..3244aa8a6 100644 --- a/src/world/WorldActions.lua +++ b/src/world/WorldActions.lua @@ -94,6 +94,8 @@ local function ready(game, ow) end return true end +-- Shared admission guard for the API-3 warp adapter. +A.ready = ready local function guardedCall(game, fn, ...) planning[game] = true local result = { pcall(fn, game, ...) } diff --git a/tests/api3_contract/mods/warp/main.lua b/tests/api3_contract/mods/warp/main.lua new file mode 100644 index 000000000..2dd8de861 --- /dev/null +++ b/tests/api3_contract/mods/warp/main.lua @@ -0,0 +1,23 @@ +-- Identical public-only mod on both engines: the harness owns native timing. +local mod = ... +local world = mod.world +assert(mod:supports("world", "warpTo")) +local early, reason = world:warpTo("DEST", 0, 0) +assert(early == nil and reason == "no game") +local calls, args, events = 0, nil, {} +mod.events:on("map.entered", function(ev) + events[#events + 1] = { mapId = ev.mapId, fromMapId = ev.fromMapId, via = ev.via } +end) +mod.exports.request = function(...) + return world:warpTo(...) +end +mod.exports.notify = function(...) + calls = calls + 1 + args = { n = select("#", ...), first = (...), third = select(3, ...) } +end +mod.exports.results = function() + return { calls = calls, args = args, events = events } +end +mod.exports.current = function() + return world:current() +end diff --git a/tests/api3_contract/mods/warp/manifest.json b/tests/api3_contract/mods/warp/manifest.json new file mode 100644 index 000000000..6bc2f80ea --- /dev/null +++ b/tests/api3_contract/mods/warp/manifest.json @@ -0,0 +1 @@ +{"id":"warp","name":"Portable Warp Contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/run.lua b/tests/api3_contract/run.lua index 0024a01d9..c9fe68f51 100644 --- a/tests/api3_contract/run.lua +++ b/tests/api3_contract/run.lua @@ -253,4 +253,5 @@ function M.run(root, backend, version, failing, scaffold) print("API3_CONTRACT " .. Json.encode(result)) return result end +M.dataFor = dataFor return M diff --git a/tests/api3_contract/warp.lua b/tests/api3_contract/warp.lua new file mode 100644 index 000000000..1b1e5d376 --- /dev/null +++ b/tests/api3_contract/warp.lua @@ -0,0 +1,387 @@ +-- Shared driver, real production loaders/adapters, native-shaped world seams. +-- No backend distinction is passed into the mod files or public assertions. +local M = {} +local unpack = table.unpack or unpack +local function read(path) + local f = assert(io.open(path, "rb")) + local value = f:read("*a") + f:close() + return value +end +function M.fixture(root, backend, version) + local Version = require("src.core.GameVersion") + local Runtime = require("src.mods.Runtime") + Version.set(version) + local gen = Version.generation(version) + local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/warp%.lua$") + local data = dofile(codeRoot .. "/run.lua").dataFor(backend, gen) + local prefix = gen == 3 and (version == "emerald" and "EM_" or "FR_") or "" + local home, dest = prefix .. "HOME", prefix .. "DEST" + for _, id in ipairs({ home, dest }) do + data.maps[id] = { + id = id, + width = 2, + height = 3, + tileset = "FIX", + blocks = { 1 }, + midLayout = gen == 3 and { width = 2, height = 3 } or nil, + } + if data.gen2Maps then + data.gen2Maps[id] = data.maps[id] + end + end + local files = {} + for _, name in ipairs({ "manifest.json", "main.lua" }) do + files["mods/warp/" .. name] = read(root .. "/mods/warp/" .. name) + end + local fs = { + read = function(path) + return files[path] + end, + load = function(path) + return loadstring(assert(files[path]), "@" .. path) + end, + getInfo = function(path) + if files[path] then + return { type = "file", size = #files[path] } + end + if path == "mods" or path == "mods/warp" then + return { type = "directory" } + end + end, + getDirectoryItems = function(path) + return path == "mods" and { "warp" } or {} + end, + } + local loader = require("src.mods.Loader").new({ fs = fs, dev = false, generation = gen, version = version }) + local f = { loader = loader, gen = gen, home = home, dest = dest, trace = {}, busy = false } + loader._game = function(_, loadedOnly) + assert(backend ~= "gen2" or loadedOnly, "no Game bootstrap") + return f.live + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + f.exports = assert(loader.exports.warp) + local save = { party = {}, flags = {}, modData = {} } + local player = { cellX = 0, cellY = 0, facing = "left" } + local ow = { + isOverworld = true, + player = player, + map = { id = home, def = data.maps[home] }, + maps = data.maps, + runner = { + isRunning = function() + return false + end, + }, + scriptMoves = {}, + } + local stack = { states = { ow } } + function stack:top() + return self.states[#self.states] + end + local game = { data = data, save = save, session = save, stack = stack, phase = "field", overworld = ow } + f.game, f.live, f.ow, f.player = game, game, ow, player + function f:dispatch(id, x, y, facing, opts) + self.trace[#self.trace + 1] = { + mapId = id, + x = x, + y = y, + facing = facing, + arrive = opts.arrive, + keepMusic = opts.keepMusic, + fade = opts.fade, + } + self.callback = opts.onDone + self.destination = { id, x, y, facing } + self.busy, ow.transitioning = true, true + if self.synchronous then + self:arrive() + self:complete("sync", nil, "tail") + end + if self.failure == "throw" then + error("native warp probe", 0) + end + if self.failure == "refuse" then + return false, "warp refused" + end + return true + end + function ow:startWarpTo(id, x, y, facing, onDone, opts) + local arrive = self.arriveWarp + self.arriveWarp = nil + local ok, why = f:dispatch( + id, + x, + y, + facing, + { onDone = onDone, arrive = arrive, keepMusic = opts and opts.keepMusic, fade = opts and opts.fade } + ) + if not ok then + return ok, why + end + -- Native Gen 1/controller returns no value on successful admission. + end + function ow:acceptsMenuInput() + return not (f.busy or player.moving) + end + function ow:warpToMapId(id, x, y, facing, opts) + return f:dispatch(id, x, y, facing, opts or {}) + end + if backend == "gen1" and gen == 2 then + game.world = ow + end + if backend == "gen1" and gen == 3 then + local field = { running = true, _game = game, _session = save, _locks = {} } + f.field = field + package.loaded["src.core.game3.field"] = field + package.loaded["src.core.game3.runtime"] = { + getSession = function() + return game.session + end, + uiBusy = function() + return f.busy + end, + } + local map = { + current = home, + currentDef = function() + return data.maps[f.map.current] + end, + } + f.map = map + package.loaded["src.core.game3.map"] = map + package.loaded["src.core.game3.player"] = player + package.loaded["src.core.game3.scripting.space"] = {} + package.loaded["src.core.game3.battle.init"] = { + isActive = function() + return false + end, + } + package.loaded["src.core.game3.warp"] = { + isBusy = function() + return f.busy + end, + request = function(_, owner, id, x, y, facing, opts) + assert(owner == game, "native request retains Game") + return f:dispatch(id, x, y, facing, opts) + end, + } + end + function f:arrive(id) + local d = assert(self.destination) + local from = ow.map.id + Runtime.emit("map.exited", { mapId = from, toMapId = id or d[1] }) + ow.map = { id = id or d[1], def = data.maps[id or d[1]] } + player.cellX, player.cellY, player.facing = d[2], d[3], d[4] + if self.map then + self.map.current = ow.map.id + end + Runtime.emit("map.entered", { mapId = ow.map.id, fromMapId = from, via = "warp", map = ow.map }) + end + function f:complete(...) + self.busy, ow.transitioning = false, false + if self.callback then + self.callback(...) + end + end + function f:update() + local name = backend == "gen1" and "core.update" or "input.step" + return loader.hooks:call(name, function() + return "updated", nil, "tail" + end, game, 0) + end + return f +end +function M.run(root, backend, version) + local Runtime = require("src.mods.Runtime") + local Json = require("src.link.Json") + local report = { invalid = {}, busy = {}, successful = {} } + local function fixture() + return M.fixture(root, backend, version) + end + local f = fixture() + local function refused(args, expected) + local before = #f.trace + local ok, why = f.exports.request(unpack(args, 1, args.n or #args)) + assert(ok == nil and why == expected, tostring(why) .. " ~= " .. expected) + assert(#f.trace == before and f.ow.map.id == f.home and f.player.cellX == 0) + assert(f.ow.arriveWarp == nil and #f.exports.results().events == 0) + report.invalid[#report.invalid + 1] = why + end + refused({ false, 0, 0 }, "invalid map id") + refused({ "", 0, 0 }, "invalid map id") + refused({ "bad\nmap", 0, 0 }, "invalid map id") + refused({ string.rep("x", 257), 0, 0 }, "invalid map id") + refused({ "UNKNOWN", 0, 0 }, "unknown map: UNKNOWN") + for _, coordinate in ipairs({ -1, 0.5, math.huge, -math.huge, 0 / 0, "0", false }) do + refused({ f.dest, coordinate, 0 }, "invalid coordinates") + refused({ f.dest, 0, coordinate }, "invalid coordinates") + end + local scale = f.gen == 3 and 1 or 2 + refused({ f.dest, 2 * scale, 0 }, "coordinates out of bounds") + refused({ f.dest, 0, 3 * scale }, "coordinates out of bounds") + refused({ f.dest, 0, 0, "north" }, "invalid facing") + refused({ f.dest, 0, 0, "up", false }, "invalid options") + refused({ f.dest, 0, 0, "up", { arrive = "boat" } }, "invalid arrival") + refused({ f.dest, 0, 0, "up", { onDone = false } }, "invalid callback") + refused({ f.dest, 0, 0, "up", { keepMusic = 1 } }, "invalid options") + refused({ f.dest, 0, 0, "up", { fade = 1 } }, "invalid options") + refused({ f.dest, 0, 0, "up", { extra = true } }, "unknown warp option: extra") + if f.gen ~= 3 then + refused({ f.dest, 0, 0, "up", { fade = false } }, "unsupported fade") + end + for _, lock in ipairs({ "transitioning", "moving", "script", "overlay", "modscript" }) do + f = fixture() + if lock == "transitioning" then + f.ow.transitioning, f.busy = true, true + if f.field then + f.field.locked = true + end + elseif lock == "moving" then + f.player.moving = true + elseif lock == "script" then + f.ow.runner.isRunning = function() + return true + end + f.busy = true + if f.field then + package.loaded["src.core.game3.scripting.space"] = + { vm = { + isRunning = function() + return true + end, + } } + end + elseif lock == "overlay" then + f.game.stack.states[2] = {} + f.busy = true + else + f.ow._modScriptLock = {} + f.busy = true + if f.field then + f.field._modScriptLock = {} + end + end + local ok, why = f.exports.request(f.dest, 0, 0) + assert(ok == nil and type(why) == "string" and #f.trace == 0, lock .. ": " .. tostring(why)) + report.busy[#report.busy + 1] = lock + end + for _, options in ipairs({ + {}, + { arrive = "fly", keepMusic = true }, + { arrive = "teleport", keepMusic = false }, + }) do + f = fixture() + options.onDone = f.exports.notify + assert(f.exports.request(f.dest, 2 * scale - 1, 3 * scale - 1, "right", options)) + assert(f.exports.results().calls == 0 and #f.trace == 1) + local expectedArrival, expectedMusic = options.arrive, options.keepMusic + options.arrive, options.keepMusic, options.onDone = + "changed", true, function() + error("mutable options") + end + assert(f.trace[1].arrive == expectedArrival and f.trace[1].keepMusic == expectedMusic) + f:arrive() + f:complete("done", nil, "tail") + f:complete("replay") + assert(f.exports.results().calls == 1 and f.exports.results().args.n == 3) + assert(f.exports.results().args.first == "done" and f.exports.results().args.third == "tail") + local location = assert(f.exports.current()) + assert(location.mapId == f.dest and location.x == 2 * scale - 1 and location.y == 3 * scale - 1) + report.successful[#report.successful + 1] = { native = f.trace[1], result = f.exports.results() } + end + f = fixture() + assert(f.exports.request(f.dest, 0, 0)) + assert(f.trace[1].facing == (f.gen == 1 and "down" or "left")) + f:arrive() + f:complete() + report.defaultFacing = f.trace[1].facing + if f.gen == 3 then + f = fixture() + assert(f.exports.request("DEST", 1, 2, "up", { fade = false, onDone = f.exports.notify })) + assert(f.trace[1].mapId == f.dest and f.trace[1].fade == false) + f:arrive() + f:complete() + report.gen3Alias = true + end + f = fixture() + f.synchronous = true + assert(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify })) + assert(f.exports.results().calls == 1 and f.exports.results().args.first == "sync") + report.synchronous = true + for _, change in ipairs({ "game", "save", "world", "bus", "owner", "other_map" }) do + f = fixture() + assert(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify })) + f:arrive() + local restore + if change == "game" then + local old = f.live + f.live = {} + restore = function() + f.live = old + end + elseif change == "save" then + local old = f.game.save + f.game.save = {} + f.game.session = f.game.save + restore = function() + f.game.save = old + f.game.session = old + end + elseif change == "world" then + if backend == "gen1" and f.gen == 3 then + local old = package.loaded["src.core.game3.field"] + package.loaded["src.core.game3.field"] = { running = true } + restore = function() + package.loaded["src.core.game3.field"] = old + end + else + local old = f.game.stack.states + f.game.stack.states = {} + f.game.overworld = nil + f.game.world = nil + restore = function() + f.game.stack.states = old + f.game.overworld = f.ow + if f.gen == 2 then + f.game.world = f.ow + end + end + end + elseif change == "bus" then + local old = Runtime.events + Runtime.events = require("src.mods.Events").new() + restore = function() + Runtime.events = old + end + elseif change == "owner" then + f.loader:_rollback("warp") + else + f:arrive(f.home) + end + f:update() + -- A stale native completion must suppress the callback permanently. + f:complete("stale") + if restore then + restore() + end + f:complete("revived") + assert(f.exports.results().calls == 0, change .. " callback survived invalidation") + end + report.stale = true + f = fixture() + assert(f.exports.request(f.dest, 0, 0, "up", { + onDone = function() + error("authored warp failure", 0) + end, + })) + f:arrive() + f:complete() + f:complete() + assert(#f.loader.errors == 1 and f.loader.errors[1]:find("warp: world warp callback failed:", 1, true)) + assert(Runtime.currentMod == nil) + report.attribution = true + print("API3_CONTRACT " .. Json.encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index b7dcb960d..c3766dea8 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -3,8 +3,15 @@ package.path = "./?.lua;./?/init.lua;" .. package.path love = nil local root, backend, version, mode = arg[1], arg[2], arg[3], arg[4] assert( - root and backend and version and (mode == "normal" or mode == "rollback" or mode == "scaffold"), + root + and backend + and version + and (mode == "normal" or mode == "rollback" or mode == "scaffold" or mode == "warp"), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -dofile(codeRoot .. "/run.lua").run(root, backend, version, mode == "rollback", mode == "scaffold") +if mode == "warp" then + dofile(codeRoot .. "/warp.lua").run(root, backend, version) +else + dofile(codeRoot .. "/run.lua").run(root, backend, version, mode == "rollback", mode == "scaffold") +end diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua index a34fe4ae9..4e5ca3a7f 100644 --- a/tests/engine/api3_compat.lua +++ b/tests/engine/api3_compat.lua @@ -134,6 +134,7 @@ do T.eq(type(world.party), "function", "early world method callable") T.eq(type(battle.snapshot), "function", "early battle method callable") for _, name in ipairs({ + "warpTo", "party", "current", "activeBlockAt", @@ -145,7 +146,6 @@ do T.eq(m:supports("world", name), true, "implemented world method " .. name) end for _, name in ipairs({ - "warpTo", "toggleObject", "replaceBlock", "spawnNpc", @@ -217,6 +217,7 @@ end for _, version in ipairs({ "platinum", "black" }) do local m = boot(3, nil, false, version) T.eq(m:supports("world", "party"), false, "no shared Gen1 contract claimed for " .. version) + T.eq(m:supports("world", "warpTo"), false, "no shared warp promised for " .. version) local value, why = m.world:party() T.eq(value, nil, "unsupported generation call refuses") T.check(why:find("unsupported:", 1, true), "unsupported generation is explicit") diff --git a/tests/engine/api3_warp.lua b/tests/engine/api3_warp.lua new file mode 100644 index 000000000..0bc8ddbb1 --- /dev/null +++ b/tests/engine/api3_warp.lua @@ -0,0 +1,249 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.warp") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +love = nil +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared warp contract: " .. tostring(why)) +end +for _, lock in ipairs({ + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen3Decorate", + "gen3Locked", + "frameScriptThisTick", + "_modScriptLock", + "parallelRunners", + "parallelQueue", + "pendingScripts", + "scriptMoves", + "stairExit", + "spinning", + "inputLocked", + "fieldMove", + "linkSession", + "linkNet", +}) do + local f = fixture() + if lock == "linkSession" then + f.game.linkSession = {} + elseif lock == "linkNet" then + f.game.linkNet = { closed = false } + elseif lock == "stairExit" or lock == "spinning" or lock == "inputLocked" or lock == "fieldMove" then + f.player[lock] = true + elseif lock:find("Runners") or lock:find("Queue") or lock == "pendingScripts" or lock == "scriptMoves" then + f.ow[lock] = { {} } + else + f.ow[lock] = true + end + local ok, why = f.exports.request(f.dest, 0, 0) + T.eq(ok, nil, lock .. " declines admission") + T.eq(why, "world is busy", lock .. " explains busy state") + T.eq(#f.trace, 0, lock .. " never dispatches") +end +do + local f = fixture() + f.game.save = nil + T.eq(f.exports.request(f.dest, 0, 0), nil, "missing save refuses") + f.live = nil + T.eq(f.exports.request(f.dest, 0, 0), nil, "missing game refuses") + T.eq(#f.trace, 0, "missing context never dispatches") +end +for _, size in ipairs({ false, -1, 0, 0.5, math.huge, 0 / 0 }) do + local f = fixture() + f.game.data.maps[f.dest].width = size + local ok, why = f.exports.request(f.dest, 0, 0) + T.eq(ok, nil, "malformed map dimensions refuse") + T.eq(why, "invalid map dimensions", "dimensions diagnosed before native work") + T.eq(#f.trace, 0, "malformed maps never dispatch") +end +do + local f = fixture() + local opts = setmetatable({}, { + __index = function() + error("must not run") + end, + }) + local ok, why = f.exports.request(f.dest, 0, 0, "down", opts) + T.eq(ok, nil, "metatable options rejected") + T.eq(why, "invalid options", "plain options required") + T.eq(#f.trace, 0, "metatable never reaches native warp") +end +for _, change in ipairs({ "data", "stack", "player", "version" }) do + local f = fixture() + T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start " .. change) + f:arrive() + local restore + if change == "version" then + Version.set("blue") + restore = function() + Version.set("red") + end + else + local obj = change == "player" and f.ow or f.game + local previous = obj[change] + obj[change] = {} + restore = function() + obj[change] = previous + end + end + f:update() + restore() + f:complete() + T.eq(f.exports.results().calls, 0, change .. " cannot revive callback after observed change") +end +for _, failure in ipairs({ "refuse", "throw" }) do + local f = fixture() + f.failure, f.synchronous = failure, true + f.ow.arriveWarp = "original" + local ok, why = f.exports.request(f.dest, 0, 0, "up", { arrive = "fly", onDone = f.exports.notify }) + T.eq(ok, nil, "native " .. failure .. " is not admitted") + T.check(type(why) == "string", "native failure explained") + T.eq(f.exports.results().calls, 0, "synchronous callback waits for native acceptance") + T.eq(f.ow.arriveWarp, "original", "failed dispatch restores borrowed arrival field") + f:complete() + T.eq(f.exports.results().calls, 0, "failed callback cannot replay") +end +do + local f = fixture() + f.synchronous = true + local ok = f.exports.request(f.dest, 0, 0, "up", { + onDone = function(...) + T.eq(Runtime.currentMod, "warp", "callback attributed to owning mod") + f.exports.notify(...) + f.synchronous = false + T.check( + f.exports.request(f.home, 1, 1, "down", { onDone = f.exports.notify }), + "callback may start next warp" + ) + end, + }) + T.check(ok, "synchronous native arrival admitted") + T.eq(#f.trace, 2, "one reentrant request dispatches once") + T.eq(Runtime.currentMod, nil, "callback restores previous runtime owner") + f:arrive() + f:complete() + T.eq(f.exports.results().calls, 2, "old completion does not clear next request") +end +for _, operation in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start before " .. operation) + f:arrive() + if operation == "rollback" then + f.loader:_rollback("warp") + elseif operation == "reload" then + fixture() -- A new loader/session retires every previous facade. + else + Loader.teardown() + end + f:complete() + T.eq(f.exports.results().calls, 0, operation .. " retires callback") + local ok, why = f.exports.request(f.dest, 0, 0) + T.eq(ok, nil, operation .. " retires facade") + T.eq(why, "warp facade is retired", "stale facade diagnostic") +end +for _, direction in ipairs({ "up", "down", "left", "right" }) do + local f = fixture() + T.check(f.exports.request(f.home, 1, 1, direction, { onDone = f.exports.notify }), "same-map " .. direction) + T.eq(f.trace[1].facing, direction, "direction reaches native warp unchanged") + f:arrive() + f:complete() + T.eq(f.exports.results().calls, 1, "same-map arrival not mistaken for stale completion") +end +do + local f = fixture() + T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start before false arrival") + f:complete() + T.eq(f.exports.results().calls, 0, "completion on old map never invokes mod") + f:arrive() + f:complete() + T.eq(f.exports.results().calls, 0, "false completion cannot be revived by later arrival") +end +-- Exercise the actual native transition; only map materialization is synthetic. +love = require("tests.love_stub") +local OW = require("src.world.OverworldController") +local Transition = require("src.render.Transition") +local StateStack = require("src.core.StateStack") +local function bind(game) + for i = 1, 100 do + local name, old = debug.getupvalue(OW.startWarpTo, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(OW.startWarpTo, i, game) + return function() + debug.setupvalue(OW.startWarpTo, i, old) + end + end + end + error("native Game upvalue missing") +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, fade in ipairs({ true, false }) do + if fade or Version.generation(version) == 3 then + local f = fixture(version) + setmetatable(f.game.stack, { __index = StateStack }) + f.ow.startWarpTo = OW.startWarpTo + local restore = bind(f.game) + function f.ow:frlgBehaviours() + return false + end + function f.ow:noteGen2Spawn(from) + self.notedFrom = from + end + function f.ow:setMap(id, x, y, facing, opts) + f.destination = { id, x, y, facing } + T.eq(opts.via, "warp", "native transition retains warp reason") + T.eq(opts.keepMusic, true, "native transition retains music request") + f:arrive() + end + local opts = { onDone = f.exports.notify, keepMusic = true } + if f.gen == 3 then + opts.fade = fade + end + T.check(f.exports.request(f.dest, 1, 2, "right", opts), "actual native warp admitted") + if fade then + local state = f.game.stack:top() + T.eq(getmetatable(state), Transition, "ordinary warp pushes native fade") + T.eq(f.exports.results().calls, 0, "callback waits for native transition") + for _ = 1, state.frames + state.framesIn + 2 do + if f.game.stack:top() == state then + state:update(1 / 60) + end + end + else + T.eq(#f.game.stack.states, 1, "no-fade warp creates no transition screen") + end + T.eq(f.ow.map.id, f.dest, "native transition arrives at destination") + T.eq(f.ow.notedFrom, f.home, "native spawn bookkeeping retained") + T.same(f.ow.warpEntryCell, { x = 1, y = 2 }, "native arrival tile guard retained") + T.eq(f.ow.transitioning, false, "native transition releases lock") + T.eq(f.exports.results().calls, 1, "actual native callback runs exactly once") + restore() + end + end +end +-- Legacy public facade continues to ignore fade, rather than opting into API 3. +do + local f = fixture("firered") + local native = require("src.world.WorldAPI").new(f.game, "legacy") + T.check(native:warpTo(f.dest, -1, 0, "north", { fade = false }), "legacy dispatch behavior unchanged") + T.eq(f.trace[1].fade, nil, "legacy fade option remains ignored") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 portable warp") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 48ba0eb9b..52ef00c31 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold"): + for mode in ("normal", "rollback", "scaffold", "warp"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 2ea78e3db7a07532c6ab08a2c0cd0e7b7edeee5d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:56:22 +0200 Subject: [PATCH 14/67] Add API 3 object visibility and block edits with shared contracts --- docs/api3-portability.md | 15 +- docs/api3-warp.md | 4 +- docs/api3-world-edits.md | 124 ++++++++++ docs/modding.md | 3 +- src/mods/Api3.lua | 15 +- src/mods/Api3Warp.lua | 4 +- src/mods/Api3WorldEdits.lua | 154 ++++++++++++ src/script/Gen2Commands.lua | 6 + tests/api3_contract/mods/edits/main.lua | 37 +++ tests/api3_contract/mods/edits/manifest.json | 1 + tests/api3_contract/warp.lua | 11 +- tests/api3_contract/worker.lua | 12 +- tests/api3_contract/world_edits.lua | 230 ++++++++++++++++++ tests/engine/api3_compat.lua | 6 +- tests/engine/api3_world_edits.lua | 241 +++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 16 files changed, 844 insertions(+), 21 deletions(-) create mode 100644 docs/api3-world-edits.md create mode 100644 src/mods/Api3WorldEdits.lua create mode 100644 tests/api3_contract/mods/edits/main.lua create mode 100644 tests/api3_contract/mods/edits/manifest.json create mode 100644 tests/api3_contract/world_edits.lua create mode 100644 tests/engine/api3_world_edits.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index a484539fa..0c7b584ed 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -77,11 +77,13 @@ The common world calls currently enabled for generations 1–3 are: `canFly`, `flyTo`; - `getFlag`/`setFlag` for private `mod:` boolean flags; - `queueScript` for the existing bounded portable script language; -- [`warpTo`](api3-warp.md) for validated native map transitions. +- [`warpTo`](api3-warp.md) for validated native map transitions; +- [`toggleObject` and `replaceBlock`](api3-world-edits.md) for validated native + visibility and active block/metatile edits. Check `mod:supports("world", name)` before relying on a call. The other Gen1 world method names remain callable but explicitly return -`nil, "unsupported: ..."`: object/block mutations, runtime NPC operations, +`nil, "unsupported: ..."`: runtime NPC operations, `startWildBattle` and `invalidateMap` have not yet been verified against the Gen1 API-3 contract. Their native/API-2 counterparts are unaffected. Native symbolic flag mappings and `mod.datasets:open` are also explicitly unavailable. @@ -128,8 +130,9 @@ the mod fixtures contain no engine imports or engine-specific branches. The runner also generates an API-3 mod through the real scaffold CLI and loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp -cases use the same public mod on both engines and native-shaped timing seams: -32 comparisons and 64 isolated loader runs. It compares JSON projections of public results and +cases use the same public mod on both engines and native-shaped timing seams. +Eight object/block cases extend this to 40 comparisons and 80 isolated loader +runs. It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge @@ -139,7 +142,9 @@ writeback, custom events, unsupported-name handling, frozen content and failed owner cleanup. Warp cases also compare validation errors, native dispatch arguments, destination events, boundary coordinates, arrival options, callback argument holes/replay, stale sessions/owners, synchronous completion and error -attribution. Separate Gen2 tests check API-1/2 behavior, contradictory +attribution. Object/block cases compare native reference routing, visibility/flag +changes, block geometry/bounds, native reads and one event per successful edit. +Separate Gen2 tests check API-1/2 behavior, contradictory manifest fields, honest availability and malformed flag stores. CI pins the same external commit and uploads the report. diff --git a/docs/api3-warp.md b/docs/api3-warp.md index 4b86542dc..dbb19a6a8 100644 --- a/docs/api3-warp.md +++ b/docs/api3-warp.md @@ -118,8 +118,8 @@ Emerald. The shared driver builds each engine's native-shaped Game/world data and replaces only native transition timing seams. It compares validation reasons, dispatch arguments, public destination events and callback results. The mod has no engine imports or backend-specific branches. Together with the -existing normal, rollback and generated-starter cases, CI now performs 32 -comparisons / 64 isolated loader runs. +existing normal, rollback, generated-starter and object/block cases, CI now +performs 40 comparisons / 80 isolated loader runs. The local suite additionally drives Gen2Recomped's real native `startWarpTo` and `Transition` using synthetic map materialization, including diff --git a/docs/api3-world-edits.md b/docs/api3-world-edits.md new file mode 100644 index 000000000..4297c602f --- /dev/null +++ b/docs/api3-world-edits.md @@ -0,0 +1,124 @@ +# API-3 object visibility and block edits + +API-3 mods can call the same public methods on both engines: + +```lua +local mod = ... +local world = mod.world -- Retain at entry; invoke later from an idle world. + +local function openGate(mapId, objectRef, blockX, blockY, openBlock) + if not mod:supports("world", "toggleObject") then return end + local ok, why = world:toggleObject(mapId, objectRef, false) + if not ok then return nil, why end + if mod:supports("world", "replaceBlock") then + return world:replaceBlock(blockX, blockY, openBlock) + end +end +``` + +Each call returns `true` or `nil, reason`. Calls are independent, not an atomic +multi-command transaction. Support is advertised for generations 1–3; API-3 +Gen4/5 still refuse both methods. A captured facade follows the current Game +without bootstrapping one. Rollback, reload and teardown retire its mutation +lease; retained calls return `nil, "world facade is retired"`. API-1/2 behavior +and the portable script vocabulary are unchanged. + +Both methods require a save and an idle, topmost overworld. Native scripts, +portable scripts, queued/parallel scripts, menus, movement, transitions, +controller locks and link sessions refuse admission. Reentrant object/block +edits from an edit's own event/native callback return `nil, "world is busy"`; +the guard is released on success and native exceptions. + +## `toggleObject(mapId, objectRef, visible)` + +`mapId` and string references are nonempty strings of at most 256 bytes without +control characters. Numeric references must be finite nonnegative integers; +`visible` must be an actual boolean. The map must exist in the active merged +dataset. Gen3 also resolves unprefixed map names using the current version's +prefix when an exact ID is absent, as `warpTo` does. + +Reference routing and persistence deliberately retain the Gen1 API-3/native +contracts, rather than treating all generations' object numbers as identical: + +| Generation | Reference | Scope and effects | +| --- | --- | --- | +| 1 | Native visibility key, normally the object's `name`; numeric keys are also accepted | May name an inactive map or a key with no currently defined actor. Writes `save.objectToggles`. An active map refresh uses the native seamless reload, retaining player cell/facing and music. | +| 2 | 1-based position in `map.def.objects`, or its `name` | Active map only. Resolves the public slot to its native template and uses the same effect helper as native appear/disappear. Writes the object's event flag and visit-scoped visibility, synchronizing only that actor. | +| 3 | Native local object ID, `name` or `id` | Active map only. Uses native add/remove commands, including event flags, spawned-object state and flagless visit state; synchronizes only the selected actor. | + +This API does not make map/object IDs portable between cartridges: select a +reference from the active dataset. On Gen2/3, an unknown reference returns +`nil, "no such object: "`; an inactive map returns +`nil, "map is not active: "`. Missing object lists and malformed native +object/store metadata refuse before native writes. Gen1's key store keeps its +existing ability to prepare a later map's visibility. + +Flagged Gen2/3 objects use their native persistent flags. Flagless objects +retain native visit-scoped behavior. A targeted sync does not respawn, relocate +or re-derive unrelated live actors, even when several templates share a flag. +Other native visibility gates (time, story flags, script-spawn rules) still +apply; acceptance is not a promise to override every native gate. + +A successful call emits one `world.object_toggled` event with +`mapId`, `objName = objectRef` and the boolean `visible`. Repeating an accepted +value still follows the native call/event contract. Gen1's active refresh may +also produce its normal native map lifecycle events. Gen2/3 do not reload the +whole map for this operation. + +## `replaceBlock(blockX, blockY, blockId)` + +This changes only the active map. Coordinates are zero-based whole **block** +coordinates, with `0 <= x < map.width` and `0 <= y < map.height`: + +| Generation | One coordinate unit | Valid ID range | +| --- | --- | --- | +| 1/2 | 32-pixel block, 2×2 walk cells | 0–255, and the block must exist in the active tileset | +| 3 | 16-pixel metatile, one walk cell | 0–1023 | + +Coordinates/IDs must be finite integers. Gen3 range validation does not promise +that every ID has artwork in a particular tileset. The adapter checks the +current block, runtime geometry and redraw entry before mutation. Unknown +blocks, unavailable geometry/renderers, fractions, NaN/infinity, negatives and +out-of-bounds coordinates are declined without writing a patch or emitting an +event. Typical reasons are `invalid block id`, `unknown block id`, +`invalid block coordinates`, `block coordinates out of bounds`, +`block unavailable`, `unsupported block geometry` and +`block renderer unavailable`. + +Gen1/2 use the existing native world block replacement. Gen3 uses the host's +native metatile command with its default non-impassable setting, including +clearing old collision/shut-cell overrides, and redraws the runtime map. +Terrain/collision tables remain native; there is no invented shared collision +registry. `activeBlockAt` immediately sees the change. One successful operation +emits one `world.block_replaced` event with `mapId`, `bx`, `by` and `block`. + +On Gen2Recomped this is an instance patch, leaving generated map data intact. +It is not a persistent content edit: permanent layout changes belong in the +map registry. Native execution failures can occur after a native write and do +not promise transactional rollback; preflight refusals have no such effects. + +## Verification + +```sh +luajit tests/engine/api3_world_edits.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The existing Gen1 pin stays at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same new fixture manifest/source +runs under both production loaders for Red, Blue, Yellow, Gold, Silver, +Crystal, FireRed and Emerald. Engine-specific setup stays in the shared +driver, never in the mod. Native-shaped map/render/object seams let the driver +compare refusal reasons, visibility/flag effects, native block reads, geometry +and event payloads. The full checker now runs 40 comparisons / 80 isolated +processes, including all previous entry, rollback, generated-starter and warp +cases. + +Local tests additionally exercise the actual Gen2Recomped native Map, +OverworldState block replacement and object synchronization with synthetic +pooled actors, including shared flags, retained positions, flagless resets, +packed metatiles, collision clearing, malformed stores, reentrancy and retired +owners. These are ROM-free runtime-boundary tests; they do not establish +rendered retail-ROM equivalence or full scene-playthrough coverage. diff --git a/docs/modding.md b/docs/modding.md index f29c2f6c8..153ac03e7 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -23,7 +23,8 @@ luajit tools/gen_registry_docs.lua ../gen1recomp.wiki The [API-3 entry and cross-engine contracts](api3-portability.md) describe using the same manifest and mod source on Gen1Recomp and Gen2Recomped. API 3 selects the unified adapters automatically; API 1/2 keep their defaults. -See [API-3 world warps](api3-warp.md) for validated map transitions and callback lifetime. +See [API-3 world warps](api3-warp.md) for validated map transitions and callback lifetime, +and [API-3 object/block edits](api3-world-edits.md) for native visibility and terrain changes. ## Feature queries (`mod:supports`) diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index 6f2de9444..29d7f0139 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -3,6 +3,7 @@ local Records = require("src.mods.Api3Records") local Runtime = require("src.mods.Runtime") local Warp = require("src.mods.Api3Warp") +local Edits = require("src.mods.Api3WorldEdits") local copy = require("src.mods.Merge").deepCopy local A = {} local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty @@ -13,7 +14,7 @@ local SUPPORTED = {} for name in ([[overworld current activeBlockAt party canReorderParty reorderParty availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters - setFlag getFlag liveMaps queueScript warpTo]]):gmatch("%S+") + setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock]]):gmatch("%S+") do SUPPORTED[name] = true end @@ -79,7 +80,10 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) end return nativeWorld end - local warp = supportedGeneration and Warp.new(loader, api.id, gen, current, native) + local warp, licensed + if supportedGeneration then + warp, licensed = Warp.new(loader, api.id, gen, current, native) + end local function dispatch(name, ...) if not supportedGeneration or not SUPPORTED[name] then return nil, "unsupported: API 3 world method " .. name @@ -87,10 +91,17 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) if name == "warpTo" then return warp(...) end + local edit = name == "toggleObject" or name == "replaceBlock" + if edit and not licensed() then + return nil, "world facade is retired" + end local game = current() if not game then return nil, "no game" end + if edit then + return Edits.call(game, native(game), gen, engineRequire, name, ...) + end if name == "setFlag" or name == "getFlag" then if not game.save then return nil, "no save" diff --git a/src/mods/Api3Warp.lua b/src/mods/Api3Warp.lua index 8deba26b3..400465d87 100644 --- a/src/mods/Api3Warp.lua +++ b/src/mods/Api3Warp.lua @@ -96,7 +96,7 @@ function W.new(loader, owner, generation, current, native) return contexts[loader] == context and context.owners[owner] == lease end observe(loader) - return function(mapId, x, y, facing, options) + local function warp(mapId, x, y, facing, options) if not licensed() then return nil, "warp facade is retired" end @@ -244,5 +244,7 @@ function W.new(loader, owner, generation, current, native) end return true end + -- Synchronous API-3 edits share this owner lease and loader lifecycle. + return warp, licensed end return W diff --git a/src/mods/Api3WorldEdits.lua b/src/mods/Api3WorldEdits.lua new file mode 100644 index 000000000..522adcb04 --- /dev/null +++ b/src/mods/Api3WorldEdits.lua @@ -0,0 +1,154 @@ +-- Validated API-3 object/block edits over this host's native runtime paths. +local Actions = require("src.world.WorldActions") +local Reads = require("src.mods.WorldReads") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local E = {} +local running = setmetatable({}, { __mode = "k" }) +local function integer(value, maximum) + return type(value) == "number" + and value == value + and value >= 0 + and value < math.huge + and value % 1 == 0 + and (not maximum or value <= maximum) +end +local function identifier(value) + return type(value) == "string" and #value > 0 and #value <= 256 and not value:find("%c") +end +local function store(value) + return value == nil or type(value) == "table" +end +local function toggle(game, ow, api, gen, req, mapId, ref, visible) + if not identifier(mapId) or not (identifier(ref) or integer(ref)) or type(visible) ~= "boolean" then + return nil, "invalid object toggle" + end + local maps = game.data and game.data.maps + local id = mapId + if gen == 3 and maps and not maps[id] then + local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_" + if maps[prefix .. id] then + id = prefix .. id + end + end + local def = maps and maps[id] + if not def then + return nil, "unknown map: " .. mapId + end + local save = game.save + if not store(save.objectToggles) or not store(save.objectToggles and save.objectToggles[id]) then + return nil, "invalid object toggle store" + end + if not store(save.flags) then + return nil, "invalid flag store" + end + if gen == 1 then + -- Gen 1 permits off-map keys and resolves their visibility on map load. + return api:toggleObject(id, ref, visible) + end + if ow.map.id ~= id then + return nil, "map is not active: " .. mapId + end + local objects = ow.map.def and ow.map.def.objects + if type(objects) ~= "table" then + return nil, "map has no objects" + end + local object + for i, candidate in ipairs(objects) do + local numeric = gen == 2 and i or tonumber(candidate.localId or candidate.index) + if numeric == ref or candidate.name == ref or gen == 3 and candidate.id == ref then + object = candidate + break + end + end + if not object then + return nil, "no such object: " .. tostring(ref) + end + if type(ow.syncObjectVisibility) ~= "function" then + return nil, "object runtime unavailable" + end + local ctx = { game = game, save = save, overworld = ow, mapId = id } + if gen == 2 then + if not store(save.g2ObjectToggles) then + return nil, "invalid object toggle store" + end + req("src.script.Gen2Commands").setObjectVisibility(ctx, object, id, visible) + else + local index = object.index + if not integer(index, 254) or index == 0 then + return nil, "invalid object definition" + end + if + not store(save.gen3Spawned) + or not store(save.gen3Spawned and save.gen3Spawned[id]) + or not store(save.gen3SessionObjects) + then + return nil, "invalid object toggle store" + end + req("src.script.Gen3Commands") + local commands = req("src.script.Commands") + local command = visible and commands.g3_show_object or commands.g3_hide_object + command(ctx, index) + end + Runtime.emit("world.object_toggled", { mapId = id, objName = ref, visible = visible }) + return true +end +local function replace(game, ow, api, gen, req, bx, by, block) + if not integer(block, gen == 3 and 1023 or 255) then + return nil, "invalid block id" + end + local present, metadata = Reads.activeBlockAt(ow.map, ow.map.id, bx, by) + if present == nil then + return nil, metadata + end + local cells = gen == 3 and 1 or 2 + if ow.map.blockCells ~= cells then + return nil, "unsupported block geometry" + end + if gen ~= 3 and not (ow.map.tileset and ow.map.tileset.blocks and ow.map.tileset.blocks[block + 1]) then + return nil, "unknown block id" + end + local renderer = ow.map.renderer + if not renderer or type(renderer.rebuild) ~= "function" then + return nil, "block renderer unavailable" + end + if gen ~= 3 then + return api:replaceBlock(bx, by, block) + end + -- Like Gen1's Gen3 Field.setMetatile default, use the native non-impassable + -- metatile command; it replaces the old collision override as well. + req("src.script.Gen3Commands") + req("src.script.Commands").g3_set_metatile( + { game = game, save = game.save, overworld = ow }, + bx, + by, + block, + 0 + ) + renderer:rebuild() + ow.map.blocksDirty = nil + Runtime.emit("world.block_replaced", { mapId = ow.map.id, bx = bx, by = by, block = block }) + return true +end +function E.call(game, api, generation, req, name, ...) + local ow = api:overworld() + if not ow then + return nil, "no overworld" + end + local ok, reason = Actions.ready(game, ow) + if not ok then + return nil, reason + end + if running[game] or ow.gen3Locked or #(ow.parallelRunners or {}) > 0 or #(ow.parallelQueue or {}) > 0 then + return nil, "world is busy" + end + running[game] = true + local operation = name == "toggleObject" and toggle or replace + local called, value, why = pcall(operation, game, ow, api, generation, req, ...) + running[game] = nil + if not called then + error(value, 0) + end + return value, why +end +return E diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..7219012da 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -1098,6 +1098,12 @@ function Commands.g2_object(ctx, index, visible) obj, mapId = objectByIndex(ctx, index) end if not obj or not mapId then return end + return Gen2Commands.setObjectVisibility(ctx, obj, mapId, visible) +end + +-- Shared native effect after resolving a map object. Public API adapters can +-- supply the resolved template without depending on cached script-ID numbering. +function Gen2Commands.setObjectVisibility(ctx, obj, mapId, visible) local OverworldState = require("src.world.OverworldController") local name = OverworldState.objectToggleKey(obj) -- The PERSISTENT half, which this used to leave to a `setevent` the ROM diff --git a/tests/api3_contract/mods/edits/main.lua b/tests/api3_contract/mods/edits/main.lua new file mode 100644 index 000000000..d26c5321e --- /dev/null +++ b/tests/api3_contract/mods/edits/main.lua @@ -0,0 +1,37 @@ +-- Identical source on both engines; native layout stays in the driver. +local mod = ... +local world = mod.world +assert(mod:supports("world", "toggleObject")) +assert(mod:supports("world", "replaceBlock")) +local ok, why = world:toggleObject("HOME", "NPC", false) +assert(ok == nil and why == "no game") +ok, why = world:replaceBlock(0, 0, 7) +assert(ok == nil and why == "no game") +local events, reenter, nested = {}, false, nil +mod.events:on("world.object_toggled", function(ev) + events[#events + 1] = { kind = "object", mapId = ev.mapId, ref = ev.objName, visible = ev.visible } + if reenter then + nested = { world:replaceBlock(0, 0, 7) } + end +end) +mod.events:on("world.block_replaced", function(ev) + events[#events + 1] = { kind = "block", mapId = ev.mapId, x = ev.bx, y = ev.by, block = ev.block } +end) +mod.exports.toggle = function(...) + return world:toggleObject(...) +end +mod.exports.replace = function(...) + return world:replaceBlock(...) +end +mod.exports.read = function(...) + return world:activeBlockAt(...) +end +mod.exports.events = function() + return events +end +mod.exports.reenter = function(value) + reenter = value +end +mod.exports.nested = function() + return nested +end diff --git a/tests/api3_contract/mods/edits/manifest.json b/tests/api3_contract/mods/edits/manifest.json new file mode 100644 index 000000000..8d0912a07 --- /dev/null +++ b/tests/api3_contract/mods/edits/manifest.json @@ -0,0 +1 @@ +{"id":"edits","name":"Portable World Edits","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/warp.lua b/tests/api3_contract/warp.lua index 1b1e5d376..5e7493d19 100644 --- a/tests/api3_contract/warp.lua +++ b/tests/api3_contract/warp.lua @@ -8,7 +8,8 @@ local function read(path) f:close() return value end -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, modId) + modId = modId or "warp" local Version = require("src.core.GameVersion") local Runtime = require("src.mods.Runtime") Version.set(version) @@ -32,7 +33,7 @@ function M.fixture(root, backend, version) end local files = {} for _, name in ipairs({ "manifest.json", "main.lua" }) do - files["mods/warp/" .. name] = read(root .. "/mods/warp/" .. name) + files["mods/" .. modId .. "/" .. name] = read(root .. "/mods/" .. modId .. "/" .. name) end local fs = { read = function(path) @@ -45,12 +46,12 @@ function M.fixture(root, backend, version) if files[path] then return { type = "file", size = #files[path] } end - if path == "mods" or path == "mods/warp" then + if path == "mods" or path == "mods/" .. modId then return { type = "directory" } end end, getDirectoryItems = function(path) - return path == "mods" and { "warp" } or {} + return path == "mods" and { modId } or {} end, } local loader = require("src.mods.Loader").new({ fs = fs, dev = false, generation = gen, version = version }) @@ -60,7 +61,7 @@ function M.fixture(root, backend, version) return f.live end assert(loader:load(data), table.concat(loader.errors, "; ")) - f.exports = assert(loader.exports.warp) + f.exports = assert(loader.exports[modId]) local save = { party = {}, flags = {}, modData = {} } local player = { cellX = 0, cellY = 0, facing = "left" } local ow = { diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index c3766dea8..0fe96b9ab 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -6,11 +6,19 @@ assert( root and backend and version - and (mode == "normal" or mode == "rollback" or mode == "scaffold" or mode == "warp"), + and ( + mode == "normal" + or mode == "rollback" + or mode == "scaffold" + or mode == "warp" + or mode == "world_edits" + ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "warp" then +if mode == "world_edits" then + dofile(codeRoot .. "/world_edits.lua").run(root, backend, version) +elseif mode == "warp" then dofile(codeRoot .. "/warp.lua").run(root, backend, version) else dofile(codeRoot .. "/run.lua").run(root, backend, version, mode == "rollback", mode == "scaffold") diff --git a/tests/api3_contract/world_edits.lua b/tests/api3_contract/world_edits.lua new file mode 100644 index 000000000..7d77c555b --- /dev/null +++ b/tests/api3_contract/world_edits.lua @@ -0,0 +1,230 @@ +-- Real loaders, adapters and host commands, with native-shaped map/render seams. +local M = {} +local unpack = table.unpack or unpack +function M.fixture(root, backend, version) + local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/world_edits%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "edits") + local Runtime = require("src.mods.Runtime") + local ow, game = f.ow, f.game + local def = game.data.maps[f.home] + local flagged = + { index = 4, localId = 4, name = "NPC", id = "npc", x = 1, y = 1, eventFlag = "FLAG_A", flag = 7 } + local flagless = { index = 8, localId = 8, name = "TEMP", x = 0, y = 1 } + def.objects = { flagged, flagless } + def.blocks = { 7, 8, 7, 8, 7, 8 } + local tiles = { + walkable = {}, + blocks = { [1] = {}, [8] = {}, [9] = {}, [256] = {} }, + collision = {}, + blockCells = f.gen == 3 and 1 or 2, + blockTiles = f.gen == 3 and 2 or 4, + } + f.flagged, f.flagless, f.visible = flagged, flagless, { NPC = true, TEMP = true } + f.rebuilds, f.syncs, f.reloads = 0, 0, 0 + if backend == "gen1" and f.gen == 2 then + -- Native Gen2 object definitions use list keys; public refs are still 1-based. + flagged.index, flagless.index, flagged.eventFlag = 1, 2, 7 + ow.map = require("src.world.gen2.Map").new(def, tiles) + elseif backend == "gen1" and f.gen == 3 then + -- Native Gen3 uses the reflected layout and field override service. + local overrides = {} + def.midLayout.midAt = function(_, x, y) + return def.blocks[y * def.width + x + 1] + end + f.field.metatileOverrideAt = function(_, x, y) + return overrides[y * 1024 + x] + end + f.field.setMetatile = function(x, y, block) + overrides[y * 1024 + x] = { metatile = block } + f.rebuilds = f.rebuilds + 1 + Runtime.emit("world.block_replaced", { mapId = f.home, bx = x, by = y, block = block }) + end + package.loaded["src.core.game3.objects"] = { + _defs = { flagged, flagless }, + addObject = function(id) + local obj = id == flagged.localId and flagged or flagless + f.visible[obj.name] = true + if obj.flag then + game.save.flags[obj.flag] = false + end + end, + removeObject = function(id) + local obj = id == flagged.localId and flagged or flagless + f.visible[obj.name] = false + if obj.flag then + game.save.flags[obj.flag] = true + end + end, + } + else + ow.map = require("src.world.Map").new(def, tiles) + end + ow.map.renderer = { + rebuild = function() + f.rebuilds = f.rebuilds + 1 + end, + } + function ow:replaceBlock(x, y, block) + self.map:setBlock(x, y, block) + self.map.renderer:rebuild() + self.map.blocksDirty = nil + Runtime.emit("world.block_replaced", { mapId = self.map.id, bx = x, by = y, block = block }) + end + function ow:changeBlock(x, y, block) + self.map.blocks[y * self.map.width + x + 1] = block + self.map.renderer:rebuild() + Runtime.emit("world.block_replaced", { mapId = self.map.id, bx = x, by = y, block = block }) + end + function ow:appearObject(id) + local obj = def.objects[id - 1] + f.visible[obj.name] = true + if obj.eventFlag then + game.save.flags[obj.eventFlag] = false + end + end + function ow:disappearObject(id) + local obj = def.objects[id - 1] + f.visible[obj.name] = false + if obj.eventFlag then + game.save.flags[obj.eventFlag] = true + end + end + function ow:syncObjectVisibility(obj) + f.syncs = f.syncs + 1 + local save = game.save + local session = f.gen == 2 and save.g2ObjectToggles or save.gen3SessionObjects + local key = f.gen == 2 and obj.name or obj.index + local value = session and session.mapId == f.home and session[key] + if obj.eventFlag and save.flags and save.flags[obj.eventFlag] ~= nil then + value = not save.flags[obj.eventFlag] + end + f.visible[obj.name] = value == nil and true or value + end + function ow:setMap(id, x, y, facing, opts) + f.reloads = f.reloads + 1 + assert(id == f.home and x == 0 and y == 0 and facing == "left", "seamless refresh preserves player") + assert(opts.seamless and opts.keepMusic and opts.via == "reload") + for _, obj in ipairs(def.objects) do + local toggles = game.save.objectToggles[id] + if toggles[obj.name] ~= nil then + f.visible[obj.name] = toggles[obj.name] + end + end + end + function f:flagValue() + local key = backend == "gen1" and self.gen > 1 and 7 or "FLAG_A" + return game.save.flags[key] + end + return f +end +function M.run(root, backend, version) + local Json = require("src.link.Json") + local f = M.fixture(root, backend, version) + local report = { invalid = {}, toggles = {}, blocks = {} } + local function unchanged() + assert(#f.exports.events() == 0 and f.rebuilds == 0 and f.reloads == 0 and f.syncs == 0) + assert(next(f.game.save.flags) == nil and f.game.save.objectToggles == nil) + assert(f.visible.NPC and f.visible.TEMP and f.player.cellX == 0 and f.player.cellY == 0) + end + local function refusal(method, args, reason) + local ok, why = f.exports[method](unpack(args, 1, args.n or #args)) + assert(ok == nil and why == reason, tostring(why) .. " ~= " .. reason) + unchanged() + report.invalid[#report.invalid + 1] = why + end + for _, bad in ipairs({ false, "", "bad\nname", string.rep("x", 257) }) do + refusal("toggle", { bad, "NPC", false }, "invalid object toggle") + refusal("toggle", { f.home, bad, false }, "invalid object toggle") + end + for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0 }) do + refusal("toggle", { f.home, bad, false }, "invalid object toggle") + end + refusal("toggle", { "UNKNOWN", "NPC", false }, "unknown map: UNKNOWN") + refusal("toggle", { f.home, "NPC", "yes" }, "invalid object toggle") + for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0, "7", false, f.gen == 3 and 1024 or 256 }) do + refusal("replace", { 0, 0, bad }, "invalid block id") + end + for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0, "0", false }) do + refusal("replace", { bad, 0, 7 }, "invalid block coordinates") + refusal("replace", { 0, bad, 7 }, "invalid block coordinates") + end + refusal("replace", { 2, 0, 7 }, "block coordinates out of bounds") + refusal("replace", { 0, 3, 7 }, "block coordinates out of bounds") + if f.gen ~= 3 then + refusal("replace", { 0, 0, 9 }, "unknown block id") + end + if f.gen > 1 then + refusal("toggle", { f.dest, "NPC", false }, "map is not active: " .. f.dest) + refusal("toggle", { f.home, "UNKNOWN", false }, "no such object: UNKNOWN") + refusal("toggle", { f.home, 0, false }, "no such object: 0") + end + for _, state in ipairs({ "overlay", "moving", "script", "transition" }) do + local other = M.fixture(root, backend, version) + if state == "overlay" then + other.game.stack.states[2] = {} + other.busy = true + elseif state == "moving" then + other.player.moving = true + elseif state == "script" then + other.busy = true + other.ow.runner.isRunning = function() + return true + end + if other.field then + package.loaded["src.core.game3.scripting.space"] = + { vm = { + isRunning = function() + return true + end, + } } + end + else + other.busy, other.ow.transitioning = true, true + if other.field then + other.field.locked = true + end + end + for _, method in ipairs({ "toggle", "replace" }) do + local ok, why + if method == "toggle" then + ok, why = other.exports.toggle(other.home, "NPC", false) + else + ok, why = other.exports.replace(0, 0, 7) + end + assert(ok == nil and type(why) == "string" and #other.exports.events() == 0) + end + end + f = M.fixture(root, backend, version) + for _, visible in ipairs({ false, true, true, false }) do + assert(f.exports.toggle(f.home, "NPC", visible)) + assert(f.visible.NPC == visible and f.visible.TEMP == true) + if f.gen > 1 then + assert(f:flagValue() == not visible) + end + report.toggles[#report.toggles + 1] = { visible = f.visible.NPC, flag = f:flagValue() } + end + -- Numeric refs retain native generation meaning: Gen1 a key, Gen2 list + -- position, Gen3 local id. A portable mod queries the active map definition. + if f.gen > 1 then + assert(f.exports.toggle(f.home, f.gen == 2 and 2 or 8, false)) + assert(not f.visible.TEMP) + assert(f.exports.toggle(f.home, "TEMP", true)) + assert(f.visible.TEMP) + else + assert(f.exports.toggle(f.dest, "LATER", false)) + assert(f.game.save.objectToggles[f.dest].LATER == false) + end + for _, op in ipairs({ { 0, 0, 8 }, { 1, 2, f.gen == 3 and 1023 or 255 }, { 0, 0, 0 } }) do + assert(f.exports.replace(unpack(op))) + local value, metadata = f.exports.read(f.home, op[1], op[2]) + assert(value == op[3] and metadata.coordinateBase == 0 and metadata.tileSize == (f.gen == 3 and 16 or 32)) + report.blocks[#report.blocks + 1] = { x = op[1], y = op[2], block = value, kind = metadata.kind } + end + assert(f.rebuilds == 3 and f.player.cellX == 0 and f.player.cellY == 0) + assert(#f.loader.errors == 0) + report.events = f.exports.events() + report.busy = true + print("API3_CONTRACT " .. Json.encode(report)) + return report +end +return M diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua index 4e5ca3a7f..2ce5e14a9 100644 --- a/tests/engine/api3_compat.lua +++ b/tests/engine/api3_compat.lua @@ -135,6 +135,8 @@ do T.eq(type(battle.snapshot), "function", "early battle method callable") for _, name in ipairs({ "warpTo", + "toggleObject", + "replaceBlock", "party", "current", "activeBlockAt", @@ -146,8 +148,6 @@ do T.eq(m:supports("world", name), true, "implemented world method " .. name) end for _, name in ipairs({ - "toggleObject", - "replaceBlock", "spawnNpc", "removeNpc", "npc", @@ -218,6 +218,8 @@ for _, version in ipairs({ "platinum", "black" }) do local m = boot(3, nil, false, version) T.eq(m:supports("world", "party"), false, "no shared Gen1 contract claimed for " .. version) T.eq(m:supports("world", "warpTo"), false, "no shared warp promised for " .. version) + T.eq(m:supports("world", "toggleObject"), false, "no shared object edits promised for " .. version) + T.eq(m:supports("world", "replaceBlock"), false, "no shared block edits promised for " .. version) local value, why = m.world:party() T.eq(value, nil, "unsupported generation call refuses") T.check(why:find("unsupported:", 1, true), "unsupported generation is explicit") diff --git a/tests/engine/api3_world_edits.lua b/tests/engine/api3_world_edits.lua new file mode 100644 index 000000000..3ef071813 --- /dev/null +++ b/tests/engine/api3_world_edits.lua @@ -0,0 +1,241 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.world_edits") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +love = nil +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared object/block contracts: " .. tostring(why)) +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, key in ipairs({ "flags", "objectToggles", "g2ObjectToggles", "gen3Spawned", "gen3SessionObjects" }) do + local gen = Version.generation(version) + if + gen == 1 and (key == "objectToggles" or key == "flags") + or gen == 2 and (key == "flags" or key == "g2ObjectToggles" or key == "objectToggles") + or gen == 3 + and (key == "flags" or key == "gen3Spawned" or key == "gen3SessionObjects" or key == "objectToggles") + then + local f = fixture(version) + f.game.save[key] = "keep" + local ok, why = f.exports.toggle(f.home, "NPC", false) + T.eq(ok, nil, key .. " malformed store rejected") + T.check(type(why) == "string", "store refusal explained") + T.eq(f.game.save[key], "keep", "malformed store retained") + T.eq(#f.exports.events(), 0, "malformed store emits no event") + T.eq(f.visible.NPC, true, "malformed store leaves native object untouched") + end + end +end +for _, version in ipairs({ "red", "firered" }) do + local f = fixture(version) + local key = f.gen == 1 and "objectToggles" or "gen3Spawned" + f.game.save[key] = { [f.home] = "keep" } + T.eq(f.exports.toggle(f.home, "NPC", false), nil, "malformed nested bucket rejected") + T.eq(f.game.save[key][f.home], "keep", "nested bucket never replaced") + T.eq(#f.exports.events(), 0, "no event for malformed nested bucket") +end +for _, lock in ipairs({ + "linkSession", + "linkNet", + "gen3Locked", + "parallelRunners", + "parallelQueue", + "pendingScripts", + "_modScriptLock", +}) do + local f = fixture() + if lock == "linkSession" then + f.game.linkSession = {} + elseif lock == "linkNet" then + f.game.linkNet = { closed = false } + elseif lock == "parallelRunners" or lock == "parallelQueue" or lock == "pendingScripts" then + f.ow[lock] = { {} } + else + f.ow[lock] = true + end + T.eq(f.exports.toggle(f.home, "NPC", false), nil, lock .. " blocks objects") + T.eq(f.exports.replace(0, 0, 7), nil, lock .. " blocks blocks") + T.eq(#f.exports.events(), 0, "busy requests never emit effects") +end +for _, failure in ipairs({ + "renderer", + "coordinates", + "geometry", + "current_block", + "objects", + "object_runtime", +}) do + local f = fixture("gold") + if failure == "renderer" then + f.ow.map.renderer = nil + elseif failure == "coordinates" then + f.ow.map.def.width = 0 + elseif failure == "geometry" then + f.ow.map.blockCells, f.ow.map.blockTiles = 1, 2 + elseif failure == "current_block" then + f.ow.map.def.blocks[1] = nil + elseif failure == "objects" then + f.ow.map.def.objects = nil + else + f.ow.syncObjectVisibility = nil + end + local ok, why + if failure == "objects" or failure == "object_runtime" then + ok, why = f.exports.toggle(f.home, "NPC", false) + else + ok, why = f.exports.replace(0, 0, 7) + end + T.eq(ok, nil, failure .. " refuses safely") + T.check(type(why) == "string", "native context refusal explained") + T.eq(#f.exports.events(), 0, "invalid context emits nothing") + T.eq(f.rebuilds + f.syncs + f.reloads, 0, "invalid context performs no native work") +end +for _, operation in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + if operation == "rollback" then + f.loader:_rollback("edits") + elseif operation == "reload" then + fixture() + else + Loader.teardown() + end + local ok, why = f.exports.toggle(f.home, "NPC", false) + T.eq(ok, nil, operation .. " retires object action") + T.eq(why, "world facade is retired", "retired object diagnostic") + ok, why = f.exports.replace(0, 0, 7) + T.eq(ok, nil, operation .. " retires block action") + T.eq(why, "world facade is retired", "retired block diagnostic") + T.eq(#f.exports.events(), 0, "retired facade emits nothing") +end +do + local f = fixture() + f.exports.reenter(true) + T.check(f.exports.toggle(f.home, "NPC", false), "outer edit accepted") + T.eq(f.exports.nested()[1], nil, "event cannot reenter a partially committed edit") + T.eq(f.exports.nested()[2], "world is busy", "reentry refused explicitly") + T.eq(f.rebuilds, 0, "nested edit never redraws") + T.eq(#f.exports.events(), 1, "outer edit emits once") + f.exports.reenter(false) + T.check(f.exports.replace(0, 0, 8), "guard released after successful edit") + local rebuild = f.ow.map.renderer.rebuild + f.ow.map.renderer.rebuild = function() + error("draw probe", 0) + end + local ok, why = f.exports.replace(0, 0, 7) + T.eq(ok, nil, "native redraw failure contained") + T.check(why:find("draw probe", 1, true), "native failure explained") + f.ow.map.renderer.rebuild = rebuild + T.check(f.exports.replace(0, 0, 8), "guard released after native exception") +end +-- Packed Gen3 source reads remain observational; replacement uses instance patches. +do + local f = fixture("emerald") + f.ow.map.def.blocks = string.rep(string.char(7, 0), 6) + f.ow.map.def._blockArray = nil + f.ow.map.collisionPatch[1] = 1 + f.ow.map.shutCells = { ["0:0"] = true } + T.check(f.exports.replace(0, 0, 8), "packed metatile map writable") + T.eq(f.exports.read(f.home, 0, 0), 8, "reads see metatile patch") + T.eq(f.ow.map.def.blocks, string.rep(string.char(7, 0), 6), "generated source bytes untouched") + T.eq(f.ow.map.def._blockArray, nil, "validation does not decode/cache source") + T.eq(f.ow.map.collisionPatch[1], 0, "native default clears stale collision override") + T.eq(f.ow.map.shutCells["0:0"], nil, "native default clears stale shut cell") +end +-- Native controller methods; no actor construction or graphics are mocked. +love = require("tests.love_stub") +local OW = require("src.world.OverworldController") +local function bind(game) + for i = 1, 100 do + local name, old = debug.getupvalue(OW.syncObjectVisibility, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(OW.syncObjectVisibility, i, game) + return function() + debug.setupvalue(OW.syncObjectVisibility, i, old) + end + end + end + error("native Game upvalue missing") +end +for _, version in ipairs({ "gold", "silver", "crystal", "firered", "emerald" }) do + local f = fixture(version) + local restore = bind(f.game) + setmetatable(f.ow, { __index = OW }) + f.ow.syncObjectVisibility = OW.syncObjectVisibility + f.ow.replaceBlock = OW.replaceBlock + local function actor(def, x) + return { + id = f.home .. "_obj_" .. def.index, + def = def, + cellX = x, + cellY = 1, + px = x * 16, + py = 16, + facing = "right", + } + end + local target, sibling = actor(f.flagged, 3), actor(f.flagless, 2) + -- Distinct live actors share one persistent flag; only the named actor moves. + f.flagless.eventFlag = f.flagged.eventFlag + f.ow.npcs = { target, sibling } + f.ow.entities = { f.player, target, sibling } + f.ow.npcPool = { [target.id] = target, [sibling.id] = sibling } + T.check(f.exports.toggle(f.home, "NPC", false), version .. " actual native removal") + T.eq(#f.ow.npcs, 1, "only target removed") + T.eq(f.ow.npcs[1], sibling, "shared-flag sibling retained live") + T.eq(sibling.cellX, 2, "unrelated actor not relocated") + T.check(f.exports.toggle(f.home, "NPC", true), "actual native respawn") + T.eq(#f.ow.npcs, 2, "one actor restored") + T.eq(target.cellX, 3, "pooled position retained") + T.eq(target.facing, "right", "pooled facing retained") + f.flagless.eventFlag = nil + T.check(f.exports.toggle(f.home, "TEMP", false), "flagless object hidden for visit") + T.eq(OW.objectVisible(f.game.save, f.home, f.flagless), false, "native spawn predicate sees session hide") + f.game.save.g2ObjectToggles = nil + f.game.save.gen3SessionObjects = nil + T.eq(OW.objectVisible(f.game.save, f.home, f.flagless), true, "flagless default restored after visit reset") + T.check(f.exports.replace(1, 2, 7), "actual native block/redraw path") + T.eq(f.ow.map:blockAt(1, 2), 7, "actual Map returns patched block") + T.eq(f.ow.map.def.blocks[6], 8, "generated map record retained") + T.eq(f.ow.map.blocksDirty, nil, "native redraw consumes dirty flag") + restore() +end +for _, version in ipairs({ "firered", "emerald" }) do + local f = fixture(version) + T.check(f.exports.toggle("HOME", "NPC", false), "unprefixed Gen3 active map resolves") + T.eq(f.exports.events()[1].mapId, f.home, "event reports resolved native map ID") +end +do + local f = fixture("gold") + require("src.script.Gen2Commands") + local Commands = require("src.script.Commands") + Commands.g2ResetObjectBase() + local ctx = { game = f.game, save = f.game.save, overworld = f.ow } + Commands.g2_object(ctx, 2, true) -- Cache the native script numbering first. + f.game.data.field = { objectScriptBase = 1 } + T.check(f.exports.toggle(f.home, 1, false), "public slot resolves independently of cached script IDs") + T.eq(f.visible.NPC, false, "current native template receives visibility effect") + Commands.g2_object(ctx, 1, true) + T.eq(f.visible.NPC, false, "public adapter leaves existing script-ID cache unchanged") + Commands.g2ResetObjectBase() +end +-- Existing native/API-2 facade retains its own coercion/validation behavior. +do + local f = fixture() + local api = require("src.world.WorldAPI").new(f.game, "legacy") + T.check(api:toggleObject("UNKNOWN", "missing", "truthy"), "legacy off-map toggle unchanged") + T.eq(f.game.save.objectToggles.UNKNOWN.missing, true, "legacy boolean coercion unchanged") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 object and block edits") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 52ef00c31..b20738ec1 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From f18425648ef6bbc17b67b7ed46ad514f381a53a1 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:36:15 +0200 Subject: [PATCH 15/67] Add API 3 public NPC actions with cross-engine contracts --- docs/api3-npc.md | 142 +++++ docs/api3-portability.md | 13 +- docs/api3-warp.md | 2 +- docs/api3-world-edits.md | 2 +- docs/modding.md | 3 +- src/mods/Api3.lua | 11 +- src/mods/Api3NPC.lua | 565 ++++++++++++++++++++ src/mods/Api3WorldEdits.lua | 25 +- src/mods/Loader.lua | 4 + tests/api3_contract/mods/npcs/main.lua | 28 + tests/api3_contract/mods/npcs/manifest.json | 1 + tests/api3_contract/npcs.lua | 320 +++++++++++ tests/api3_contract/worker.lua | 5 +- tests/engine/api3_compat.lua | 9 +- tests/engine/api3_npc.lua | 423 +++++++++++++++ tools/api3-contract-check/check.py | 2 +- 16 files changed, 1533 insertions(+), 22 deletions(-) create mode 100644 docs/api3-npc.md create mode 100644 src/mods/Api3NPC.lua create mode 100644 tests/api3_contract/mods/npcs/main.lua create mode 100644 tests/api3_contract/mods/npcs/manifest.json create mode 100644 tests/api3_contract/npcs.lua create mode 100644 tests/engine/api3_npc.lua diff --git a/docs/api3-npc.md b/docs/api3-npc.md new file mode 100644 index 000000000..3a38e387b --- /dev/null +++ b/docs/api3-npc.md @@ -0,0 +1,142 @@ +# API-3 public NPC actions + +Generations 1–3 support the same public creation, lookup and removal calls as +Gen1Recomp. Check availability and retain the world facade at mod entry: + +```lua +local mod = ... +local world = mod.world + +local function greet(mapId, spriteId) + if not mod:supports("world", "spawnNpc") then return end + local id, why = world:spawnNpc(mapId, { + name = "GREETER", sprite = spriteId, x = 3, y = 4, facing = "down", + }) + if not id then return nil, why end + local npc, reason = world:npc(mapId, id) + if not npc then return nil, reason end + return npc:scriptMove("down", 2, function() + npc:face("left") + end) +end +``` + +Invoke this later from an idle world. Reading the facade never bootstraps Game. +Gen4/5 report these three calls unavailable. API-1/2 retain their native behavior; +this does not add portable `queueScript` verbs. + +## Creation, lookup and ownership + +- `spawnNpc(mapId, definition)` returns the native runtime ID or `nil, reason`. + It can add a runtime definition to a known inactive map; an actor appears when + the native map lifecycle instantiates it. The return value promises a recorded + definition, not immediate live lookup or a common lifecycle event order. +- `npc(mapId, reference)` returns an opaque handle or `nil, reason`. References + are the live actor's string ID, its definition's numeric index or its name. + Only the active map and currently live neighboring map actors can be queried. +- `removeNpc(id)` returns `true` or `nil, reason`. Only runtime objects owned by + the calling mod may be removed. Imported objects and other owners are refused. + A mod may still look up and control an imported live actor. + +Map, sprite and object identifiers remain dataset-specific. Gen3 recognizes the +current version's `FR_`, `EM_`, `RU_` or `SA_` map prefix when an exact map ID is +absent. IDs/names are nonempty strings of at most 256 bytes without control +characters; numeric references are finite nonnegative integers. Use the ID +returned by `spawnNpc` for removal. + +Definitions are copied before native construction. Required `x` and `y` are +whole nonnegative walk-cell coordinates, at most 1,000,000. Optional `facing` is +`up`, `down`, `left` or `right`; optional `name` and `sprite` are identifiers. +A supplied sprite must exist in the active sprite registry. Native generation +fields remain supported as data; this API does not invent a canonical sprite, +movement or NPC definition schema. The host assigns runtime ownership and +identity, ignoring caller-supplied ownership/index markers. + +The copy accepts plain acyclic tables, strings, finite numbers and booleans. +Functions, userdata, metatables and cycles are refused before native writes. +Limits are depth 16, 4096 visited values/keys, 256 entries per table, 4096 bytes +per string and 64 KiB total string bytes. Refusal returns `nil, reason`. + +## Handles + +Handles expose `id` and these methods; they never expose the native actor: + +| Method | Result and behavior | +| --- | --- | +| `position()` | `x, y` in zero-based 16-pixel walk cells, in the actor's own map frame. | +| `isMoving()` | Boolean native movement state. | +| `canStep(direction)` | Boolean native collision permission, including the host's current seam cast for connected maps. It is a query, not a reservation. | +| `face(direction)` | Set facing. | +| `stepNow(direction)` | Begin one authored step; refuse an already moving actor. | +| `placeAt(x, y, facing?)` | Place at whole nonnegative coordinates up to 1,000,000, clear in-flight movement and synchronize pixel position. | +| `setPassable(boolean)` | Set the live actor's passability. | +| `setAppearance(spriteId)` | Refresh a live actor using an existing sprite registry entry. | +| `scriptMove(direction, tiles?, onDone?)` | Queue native movement, default one cell; `tiles` must be an integer from 1 to 1024. | +| `marchInPlace(onDone?)` | Queue one native stationary march. | + +Directions are the four lowercase names above. Mutations return `true` or +`nil, reason`; a stale handle returns `nil, "NPC handle is stale"`. Handles are +bound to the original actor, map instance, Game, save, data and version. Reused +native IDs or editing the public `id` field cannot redirect a retained handle. +The facade follows the current Game, while old actor handles do not. + +Spawn, removal and handle mutations use the shared API-3 idle-world guard: +a save and topmost overworld are required; menus, native/portable/parallel +scripts, player movement, controller locks, transitions and link sessions refuse +admission. Reads remain usable during overlays. Synchronous reentry into NPC or +object/block mutations is refused. Movement tracks require the active map; +other handle calls also work for live neighboring actors. + +`stepNow` and `placeAt` are authored operations, matching the native distinction +from `canStep`: they do not automatically perform collision validation or clamp +to map bounds. Choose coordinates and query collision as appropriate for the +scene. Appearance changes construct a renderer before committing the actor +change, retain its cell position and leave the native template sprite intact; +they are not persistent content edits. + +## Movement and mod lifetime + +Completion callbacks are optional, one-shot and attributed to the initiating +mod. Native synchronous completion is deferred until admission finishes and +the shared mutation guard is released. Native callback arguments, including +nil holes, are preserved. Callback errors are contained and reported once. + +Pending completions are permanently retired on observed Game/save/data/world, +map, stack, version, actor or runtime-bus replacement, and on native map lifecycle +events. The input-step boundary observes replacements before and after native +updates. Retiring removes only the movement entries owned by that operation; +it does not pop screens or cancel another mod's tracks. + +Rollback, reload and teardown retire the mod's facade and pending tracks and +remove its recorded runtime definitions/actors, including definitions spawned +on inactive maps and instantiated later as neighbors. Foreign runtime objects +and imported actors remain. Controls already applied to imported actors are +ordinary native scene changes, not a transactional patch with automatic undo. +Retained handles/facades return `nil, "world facade is retired"` after retirement. + +## Verification + +```sh +luajit tests/engine/api3_npc.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The clean Gen1 reference stays pinned to +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same public-only NPC mod runs +through both production loaders for Red, Blue, Yellow, Gold, Silver, Crystal, +FireRed and Emerald. Its eight cases extend the full checker to 48 comparisons +and 96 isolated runs. The shared fixture compares creation/lookup, detached +definitions, collision/passability, appearance, authored/queued movement, +callback replay/arguments, optional callbacks and ownership-aware removal. + +Local tests additionally cover bounded validation, busy worlds, reentry, stale +sessions/handles, callback errors, lifecycle cancellation and scoped cleanup. +They exercise the host's actual NPC constructor, native runtime add/remove, +scriptMove/marchInPlace/updateScriptMoves, NPC updates and seam collision casts. +Synthetic datasets and a deterministic SpriteRenderer construction seam keep +these ROM-free. The tests verify the documented shared subset, not retail-ROM +playthroughs, GPU artwork or identical native internals/event timing. Host +validation and lifecycle guards are intentionally stricter than some reference +native paths. diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 0c7b584ed..5065c4c1a 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -79,12 +79,13 @@ The common world calls currently enabled for generations 1–3 are: - `queueScript` for the existing bounded portable script language; - [`warpTo`](api3-warp.md) for validated native map transitions; - [`toggleObject` and `replaceBlock`](api3-world-edits.md) for validated native - visibility and active block/metatile edits. + visibility and active block/metatile edits; +- [`spawnNpc`, `removeNpc` and `npc`](api3-npc.md) for runtime NPC creation, + ownership-aware removal and opaque live actor controls. Check `mod:supports("world", name)` before relying on a call. The other Gen1 world method names remain callable but explicitly return -`nil, "unsupported: ..."`: runtime NPC operations, -`startWildBattle` and `invalidateMap` have not yet been verified against the +`nil, "unsupported: ..."`: `startWildBattle` and `invalidateMap` have not yet been verified against the Gen1 API-3 contract. Their native/API-2 counterparts are unaffected. Native symbolic flag mappings and `mod.datasets:open` are also explicitly unavailable. Gen4/5 do not claim the shared world surface in this first entry PR. @@ -131,8 +132,8 @@ The runner also generates an API-3 mod through the real scaffold CLI and loads those same generated files on both engines for each version. This adds eight comparisons to the sixteen normal/rollback cases. Eight additional warp cases use the same public mod on both engines and native-shaped timing seams. -Eight object/block cases extend this to 40 comparisons and 80 isolated loader -runs. It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 48 comparisons +and 96 isolated loader runs. It compares JSON projections of public results and records hashes of both shared and generated fixture files. It refuses a different or modified reference. Coverage includes canonical content reads/writes/iteration and native merge @@ -144,6 +145,8 @@ arguments, destination events, boundary coordinates, arrival options, callback argument holes/replay, stale sessions/owners, synchronous completion and error attribution. Object/block cases compare native reference routing, visibility/flag changes, block geometry/bounds, native reads and one event per successful edit. +NPC cases compare runtime creation/lookup, movement/appearance, optional +completion callbacks and ownership-aware removal using the same public handles. Separate Gen2 tests check API-1/2 behavior, contradictory manifest fields, honest availability and malformed flag stores. CI pins the same external commit and uploads the report. diff --git a/docs/api3-warp.md b/docs/api3-warp.md index dbb19a6a8..6e4c1f055 100644 --- a/docs/api3-warp.md +++ b/docs/api3-warp.md @@ -119,7 +119,7 @@ and replaces only native transition timing seams. It compares validation reasons, dispatch arguments, public destination events and callback results. The mod has no engine imports or backend-specific branches. Together with the existing normal, rollback, generated-starter and object/block cases, CI now -performs 40 comparisons / 80 isolated loader runs. +performs 48 comparisons / 96 isolated loader runs. The local suite additionally drives Gen2Recomped's real native `startWarpTo` and `Transition` using synthetic map materialization, including diff --git a/docs/api3-world-edits.md b/docs/api3-world-edits.md index 4297c602f..1e66a6fa1 100644 --- a/docs/api3-world-edits.md +++ b/docs/api3-world-edits.md @@ -112,7 +112,7 @@ runs under both production loaders for Red, Blue, Yellow, Gold, Silver, Crystal, FireRed and Emerald. Engine-specific setup stays in the shared driver, never in the mod. Native-shaped map/render/object seams let the driver compare refusal reasons, visibility/flag effects, native block reads, geometry -and event payloads. The full checker now runs 40 comparisons / 80 isolated +and event payloads. The full checker now runs 48 comparisons / 96 isolated processes, including all previous entry, rollback, generated-starter and warp cases. diff --git a/docs/modding.md b/docs/modding.md index 153ac03e7..d6169c421 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -24,7 +24,8 @@ The [API-3 entry and cross-engine contracts](api3-portability.md) describe using the same manifest and mod source on Gen1Recomp and Gen2Recomped. API 3 selects the unified adapters automatically; API 1/2 keep their defaults. See [API-3 world warps](api3-warp.md) for validated map transitions and callback lifetime, -and [API-3 object/block edits](api3-world-edits.md) for native visibility and terrain changes. +[API-3 object/block edits](api3-world-edits.md) for native visibility and terrain changes, +and [API-3 NPC actions](api3-npc.md) for runtime actors and movement handles. ## Feature queries (`mod:supports`) diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index 29d7f0139..8e678aca5 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -4,6 +4,7 @@ local Records = require("src.mods.Api3Records") local Runtime = require("src.mods.Runtime") local Warp = require("src.mods.Api3Warp") local Edits = require("src.mods.Api3WorldEdits") +local NPC = require("src.mods.Api3NPC") local copy = require("src.mods.Merge").deepCopy local A = {} local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty @@ -14,7 +15,9 @@ local SUPPORTED = {} for name in ([[overworld current activeBlockAt party canReorderParty reorderParty availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters - setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock]]):gmatch("%S+") + setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock spawnNpc removeNpc npc]]):gmatch( + "%S+" + ) do SUPPORTED[name] = true end @@ -80,14 +83,18 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) end return nativeWorld end - local warp, licensed + local warp, licensed, npc if supportedGeneration then warp, licensed = Warp.new(loader, api.id, gen, current, native) + npc = NPC.new(loader, api.id, gen, current, native, licensed, engineRequire) end local function dispatch(name, ...) if not supportedGeneration or not SUPPORTED[name] then return nil, "unsupported: API 3 world method " .. name end + if name == "spawnNpc" or name == "removeNpc" or name == "npc" then + return npc(name, ...) + end if name == "warpTo" then return warp(...) end diff --git a/src/mods/Api3NPC.lua b/src/mods/Api3NPC.lua new file mode 100644 index 000000000..8a4091f2a --- /dev/null +++ b/src/mods/Api3NPC.lua @@ -0,0 +1,565 @@ +-- Public API-3 NPC handles. Engine objects never cross the handle boundary. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Edits = require("src.mods.Api3WorldEdits") +local Collision = require("src.world.Collision") +local copy = require("src.mods.Merge").deepCopy +local N = {} +local services = setmetatable({}, { __mode = "k" }) +local observers = setmetatable({}, { __mode = "k" }) +local moves = {} +local dirs = { up = true, down = true, left = true, right = true } +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function integer(n, minimum, maximum) + return type(n) == "number" + and n == n + and n >= (minimum or 0) + and n < math.huge + and n % 1 == 0 + and (not maximum or n <= maximum) +end +local function message(value) + local ok, text = pcall(tostring, value) + return ok and text or "unprintable error" +end +local function identifier(s) + return type(s) == "string" and #s > 0 and #s <= 256 and not s:find("%c") +end +local function reference(v) + return identifier(v) or integer(v) +end +-- Bounded data-only copy, including extra generation-native definition fields. +local function definition(value) + local visiting, nodes, bytes = {}, 0, 0 + local function clone(v, depth) + nodes = nodes + 1 + if nodes > 4096 or depth > 16 then + error("NPC definition too large", 0) + end + local t = type(v) + if t == "string" then + bytes = bytes + #v + if #v > 4096 or bytes > 65536 then + error("NPC definition too large", 0) + end + return v + elseif t == "number" then + if v ~= v or v == math.huge or v == -math.huge then + error("invalid NPC definition", 0) + end + return v + elseif t == "nil" or t == "boolean" then + return v + elseif t ~= "table" or getmetatable(v) ~= nil or visiting[v] then + error("invalid NPC definition", 0) + end + visiting[v] = true + local out, count = {}, 0 + for k, item in pairs(v) do + count = count + 1 + if count > 256 or type(k) ~= "string" and not integer(k) then + error("invalid NPC definition", 0) + end + out[clone(k, depth + 1)] = clone(item, depth + 1) + end + visiting[v] = nil + return out + end + if type(value) ~= "table" then + return nil, "invalid NPC definition" + end + local ok, result = pcall(clone, value, 0) + if not ok then + return nil, result + end + if not integer(result.x, 0, 1000000) or not integer(result.y, 0, 1000000) then + return nil, "invalid NPC coordinates" + end + for _, key in ipairs({ "name", "sprite" }) do + if result[key] ~= nil and not identifier(result[key]) then + return nil, "invalid NPC " .. key + end + end + if result.facing ~= nil and not dirs[result.facing] then + return nil, "invalid NPC facing" + end + return result +end +local function removeFrom(list, predicate) + for i = #(list or {}), 1, -1 do + if predicate(list[i]) then + table.remove(list, i) + end + end +end +local function retire(move) + move.done = true + moves[move] = nil + move.callback = nil + local removed = false + removeFrom(move.world.scriptMoves, function(entry) + if move.entries[entry] then + removed = true + return true + end + end) + if removed then + local foreign = false + for _, entry in ipairs(move.world.scriptMoves or {}) do + if entry.entity == move.actor then + foreign = true + end + end + if not foreign then + move.actor.moving, move.actor.marching = false, false + move.actor.targetX, move.actor.targetY = nil, nil + move.actor.px, move.actor.py = move.actor.cellX * 16, move.actor.cellY * 16 + end + end +end +local function removeSpawn(token) + -- Scoped by exact native record identity, even after Game/provider changes. + local rows = token.data.maps and token.data.maps[token.mapId] + local removed = false + removeFrom(rows and rows.objects, function(row) + if row == token.record then + removed = true + return true + end + end) + local function owned(actor) + return actor + and ( + actor == token.actor + or actor.def == token.record + or removed and actor.id == token.mapId .. "_obj_" .. tostring(token.record.index) + ) + end + removeFrom(token.world.npcs, owned) + removeFrom(token.world.entities, owned) + removeFrom(token.world.ghosts, function(g) + return owned(g.npc) + end) + for id, actor in pairs(token.world.npcPool or {}) do + if owned(actor) then + token.world.npcPool[id] = nil + end + end +end +function N.cancelAll(loader, owner) + for service in pairs(services) do + if (not loader or loader == service.loader) and (not owner or owner == service.owner) then + service.closed = true + for move in pairs(moves) do + if move.service == service then + retire(move) + end + end + for _, token in ipairs(service.spawned) do + removeSpawn(token) + end + service.spawned = {} + services[service] = nil + end + end +end +local function inspect(loader) + for move in pairs(moves) do + if move.service.loader == loader then + local ok, valid = pcall(move.valid) + if not ok or not valid then + retire(move) + end + end + end +end +local function observe(loader) + if observers[loader] and observers[loader][1] == loader.hooks then + return + end + observers[loader] = setmetatable({ loader.hooks }, { __mode = "v" }) + loader.hooks:wrap("input.step", function(nextFn, ...) + if next(moves) == nil then + return nextFn(...) + end + inspect(loader) + local result = pack(pcall(nextFn, ...)) + inspect(loader) + if not result[1] then + error(result[2], 0) + end + return unpack(result, 2, result.n) + end, math.huge, "engine:api3_npc") + for _, event in ipairs({ "map.exited", "map.entered" }) do + loader.events:on(event, function() + for move in pairs(moves) do + if move.service.loader == loader then + retire(move) + end + end + end, math.huge, "engine:api3_npc") + end +end +function N.new(loader, owner, gen, current, native, licensed, req) + local service = { loader = loader, owner = owner, spawned = {} } + services[service] = true + observe(loader) + local function alive() + return not service.closed and licensed() + end + local function resolveGame() + if not alive() then + return nil, nil, "world facade is retired" + end + local game = current() + if not game then + return nil, nil, "no game" + end + local ow = native(game):overworld() + if not ow then + return nil, nil, "no overworld" + end + return game, ow + end + local function mapId(game, id) + if gen == 3 and not (game.data.maps or {})[id] then + local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_" + if (game.data.maps or {})[prefix .. id] then + return prefix .. id + end + end + return id + end + local function actorAt(ow, id, ref) + local function matches(actor) + local def = actor.def or {} + return actor.id == ref or def.index == ref or def.name == ref + end + if ow.map and ow.map.id == id then + for _, actor in ipairs(ow.npcs or {}) do + if matches(actor) then + return actor, ow.map, ow.cast or ow.entities + end + end + return nil, "no such object: " .. tostring(ref) + end + for _, g in ipairs(ow.ghosts or {}) do + local actor = g.npc + local map = g.map + if map and map.id == id and actor and matches(actor) then + local peers = g.nb and g.nb.cast or g.peers + if not peers then + peers = {} + for _, sibling in ipairs(ow.ghosts) do + if sibling.map == map then + peers[#peers + 1] = sibling.npc + end + end + end + return actor, map, peers, true + end + end + return nil, "map is not live" + end + local function sprite(game, id) + return game.data and game.data.sprites and game.data.sprites[id] + end + local function queue(game, ow, actor, valid, name, a, b, callback) + local hooks, events = Runtime.hooks, Runtime.events + local move = { service = service, world = ow, actor = actor, entries = {}, callback = callback } + local save, data, stack, map, version, fastWorld = + game.save, game.data, game.stack, ow.map, Version.get(), game.overworld + function move.valid() + return alive() + and current() == game + and game.save == save + and game.data == data + and game.stack == stack + and game.overworld == fastWorld + and ow.map == map + and Version.get() == version + and Runtime.hooks == hooks + and Runtime.events == events + and valid() + end + local accepted, deferred + local function finish(...) + if move.done then + return + end + if accepted == nil then + if not deferred then + deferred = pack(...) + end + return + end + local checked, valid = pcall(move.valid) + local fn = accepted and checked and valid and move.callback + retire(move) + if not fn then + return + end + local previous = Runtime.currentMod + Runtime.currentMod = owner + local ok, why = pcall(fn, ...) + Runtime.currentMod = previous + if not ok then + Runtime.reportError(owner, "NPC movement callback failed: " .. message(why)) + end + end + local before = {} + for _, entry in ipairs(ow.scriptMoves or {}) do + before[entry] = true + end + moves[move] = true + local called, result, detail + if name == "scriptMove" then + called, result, detail = pcall(ow.scriptMove, ow, actor, a, b or 1, finish) + else + called, result, detail = pcall(ow.marchInPlace, ow, actor, finish) + end + for _, entry in ipairs(ow.scriptMoves or {}) do + if not before[entry] and entry.entity == actor then + move.entries[entry] = true + end + end + accepted = called and result ~= false and not (result == nil and detail ~= nil) + if accepted then + local checked, valid = pcall(move.valid) + accepted = checked and valid and not move.done + end + if not accepted then + retire(move) + return nil, + called and (detail or "NPC movement failed") or "world operation failed: " .. message(result) + end + local drain = deferred and function() + finish(unpack(deferred, 1, deferred.n)) + end + return true, nil, drain + end + local function handle(game, ow, id, ref) + local actor, map = actorAt(ow, id, ref) + if not actor then + return nil, map + end + local publicId = actor.id + if not identifier(publicId) then + return nil, "NPC identity unavailable" + end + local save, data, version, fastWorld = game.save, game.data, Version.get(), game.overworld + local function live() + if + not alive() + or current() ~= game + or game.save ~= save + or game.data ~= data + or game.overworld ~= fastWorld + or Version.get() ~= version + or native(game):overworld() ~= ow + then + return + end + local found, activeMap, peers, ghost = actorAt(ow, id, publicId) + if found ~= actor or activeMap ~= map then + return + end + return actor, activeMap, peers, ghost + end + local out = { id = publicId } + local function invoke(name, a, b, c) + if not alive() then + return nil, "world facade is retired" + end + local npc, onMap, peers, ghost = live() + if not npc then + return nil, "NPC handle is stale" + end + if name == "position" then + return npc.cellX, npc.cellY + end + if name == "isMoving" then + return not not npc.moving + end + if name == "canStep" or name == "stepNow" or name == "face" or name == "scriptMove" then + if not dirs[a] then + return nil, "invalid direction" + end + end + if name == "canStep" then + return not not Collision.canMove(onMap, peers or {}, npc, a) + end + if + name == "scriptMove" and (b ~= nil and not integer(b, 1, 1024) or c ~= nil and type(c) ~= "function") + then + return nil, "invalid movement" + end + if name == "marchInPlace" and a ~= nil and type(a) ~= "function" then + return nil, "invalid callback" + end + if + name == "placeAt" + and (not integer(a, 0, 1000000) or not integer(b, 0, 1000000) or c ~= nil and not dirs[c]) + then + return nil, "invalid coordinates" + end + if name == "setPassable" and type(a) ~= "boolean" then + return nil, "invalid passability" + end + if name == "setAppearance" and not identifier(a) then + return nil, "invalid sprite id" + end + local ok, why = Edits.ready(game, ow) + if not ok then + return nil, why + end + if ghost and (name == "scriptMove" or name == "marchInPlace") then + return nil, "movement tracks require the active map" + end + local drain + local value, detail = Edits.run(game, function() + if name == "scriptMove" or name == "marchInPlace" then + local value, why + local callback = a + if name == "scriptMove" then + callback = c + end + value, why, drain = queue(game, ow, npc, live, name, a, b, callback) + return value, why + elseif name == "face" then + npc.facing = a + elseif name == "stepNow" then + if npc.moving then + return nil, "already moving" + end + npc.facing = a + npc.targetX, npc.targetY = Collision.target(npc.cellX, npc.cellY, a) + npc.moving, npc.progress = true, 0 + elseif name == "placeAt" then + npc.moving, npc.marching, npc.progress = false, false, 0 + npc.targetX, npc.targetY = nil, nil + npc.cellX, npc.cellY, npc.px, npc.py = a, b, a * 16, b * 16 + if c then + npc.facing = c + end + elseif name == "setPassable" then + npc.passable = a + elseif name == "setAppearance" then + if not sprite(game, a) then + return nil, "unknown sprite: " .. a + end + local proxy = { id = npc.id, def = copy(npc.def) } + proxy.def.sprite = a + proxy.def.graphicsId, proxy.def.graphics, proxy.def.graphicsVar, proxy.def.varSprite = + nil, nil, nil, nil + req("src.world.NPC").refreshSprite(proxy, game.data) + npc.def, npc.sprite, npc.appearance = proxy.def, proxy.sprite, a + npc.big = proxy.sprite and proxy.sprite.big or false + end + return true + end) + if drain then + drain() + end + return value, detail + end + for _, name in ipairs({ + "position", + "isMoving", + "canStep", + "scriptMove", + "marchInPlace", + "face", + "stepNow", + "placeAt", + "setPassable", + "setAppearance", + }) do + local method = name + out[method] = function(_, ...) + local previous = Runtime.currentMod + Runtime.currentMod = nil + local result = pack(pcall(invoke, method, ...)) + Runtime.currentMod = previous + if not result[1] then + return nil, "world operation failed: " .. message(result[2]) + end + return unpack(result, 2, result.n) + end + end + return out + end + return function(name, a, b) + local game, ow, why = resolveGame() + if not game then + return nil, why + end + if name == "npc" then + if not identifier(a) or not reference(b) then + return nil, "invalid NPC reference" + end + return handle(game, ow, mapId(game, a), b) + end + local ok, reason = Edits.ready(game, ow) + if not ok then + return nil, reason + end + return Edits.run(game, function() + if name == "spawnNpc" then + if not identifier(a) or type(b) ~= "table" then + return nil, "invalid NPC definition" + end + local id = mapId(game, a) + local mapDef = game.data.maps and game.data.maps[id] + if not mapDef then + return nil, "unknown map: " .. a + end + local row, err = definition(b) + if not row then + return nil, err + end + if row.sprite and not sprite(game, row.sprite) then + return nil, "unknown sprite: " .. row.sprite + end + local before = {} + for _, item in ipairs(mapDef.objects or {}) do + before[item] = true + end + local called, value, detail = pcall(native(game).spawnNpc, native(game), id, row) + for _, item in ipairs(mapDef.objects or {}) do + if not before[item] and item.runtime and item.owner == owner then + local publicId = id .. "_obj_" .. tostring(item.index) + local actor = actorAt(ow, id, publicId) + local token = { data = game.data, world = ow, mapId = id, record = item, actor = actor } + if not called or not value then + removeSpawn(token) + else + service.spawned[#service.spawned + 1] = token + end + end + end + if not called then + return nil, "world operation failed: " .. message(value) + end + return value, detail + end + if not reference(a) or gen == 3 and not identifier(a) then + return nil, "invalid NPC id" + end + local value, err = native(game):removeNpc(a) + if value then + for i = #service.spawned, 1, -1 do + local token = service.spawned[i] + if token.mapId .. "_obj_" .. tostring(token.record.index) == a then + table.remove(service.spawned, i) + end + end + end + return value, err + end) + end +end +return N diff --git a/src/mods/Api3WorldEdits.lua b/src/mods/Api3WorldEdits.lua index 522adcb04..c21586e25 100644 --- a/src/mods/Api3WorldEdits.lua +++ b/src/mods/Api3WorldEdits.lua @@ -130,11 +130,8 @@ local function replace(game, ow, api, gen, req, bx, by, block) Runtime.emit("world.block_replaced", { mapId = ow.map.id, bx = bx, by = by, block = block }) return true end -function E.call(game, api, generation, req, name, ...) - local ow = api:overworld() - if not ow then - return nil, "no overworld" - end +-- Shared admission/commit guard for synchronous API-3 world mutations. +function E.ready(game, ow) local ok, reason = Actions.ready(game, ow) if not ok then return nil, reason @@ -142,13 +139,27 @@ function E.call(game, api, generation, req, name, ...) if running[game] or ow.gen3Locked or #(ow.parallelRunners or {}) > 0 or #(ow.parallelQueue or {}) > 0 then return nil, "world is busy" end + return true +end +function E.run(game, operation, ...) running[game] = true - local operation = name == "toggleObject" and toggle or replace - local called, value, why = pcall(operation, game, ow, api, generation, req, ...) + local called, value, why = pcall(operation, ...) running[game] = nil if not called then error(value, 0) end return value, why end +function E.call(game, api, generation, req, name, ...) + local ow = api:overworld() + if not ow then + return nil, "no overworld" + end + local ok, reason = E.ready(game, ow) + if not ok then + return nil, reason + end + local operation = name == "toggleObject" and toggle or replace + return E.run(game, operation, game, ow, api, generation, req, ...) +end return E diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 17ecd49e0..c24867e06 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -13,6 +13,7 @@ local Assets = require("src.render.Assets") local ModUI = require("src.ui.ModUI") local PortableScripts = require("src.mods.PortableScripts") local Api3Warp = require("src.mods.Api3Warp") +local Api3NPC = require("src.mods.Api3NPC") local AssetTransform = require("src.mods.AssetTransform") local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") @@ -1263,6 +1264,7 @@ end -- migration it took goes with them. The journal only exists around an -- entry chunk; a later failure (script validation) purges every registry. function Loader:_rollback(modId) + Api3NPC.cancelAll(self, modId) Api3Warp.cancelAll(self, modId) PortableScripts.cancelAll(self, modId, "mod rolled back") for name in pairs(self.journal or self.content) do @@ -1379,6 +1381,7 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + Api3NPC.cancelAll() Api3Warp.cancelAll() PortableScripts.cancelAll(nil, nil, "mods reloaded") -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST @@ -1724,6 +1727,7 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + Api3NPC.cancelAll() Api3Warp.cancelAll() PortableScripts.cancelAll(nil, nil, "mods unloaded") local undone = select(1, ModSeams.unwindAll()) diff --git a/tests/api3_contract/mods/npcs/main.lua b/tests/api3_contract/mods/npcs/main.lua new file mode 100644 index 000000000..696dfaafe --- /dev/null +++ b/tests/api3_contract/mods/npcs/main.lua @@ -0,0 +1,28 @@ +-- Same public-only mod on both engines. Native timing stays in the driver. +local mod = ... +local world = mod.world +for _, name in ipairs({ "spawnNpc", "removeNpc", "npc" }) do + assert(mod:supports("world", name)) +end +local ok, why = world:npc("HOME", 1) +assert(ok == nil and why == "no game") +local calls, args = 0, nil +mod.exports.spawn = function(...) + return world:spawnNpc(...) +end +mod.exports.remove = function(...) + return world:removeNpc(...) +end +mod.exports.npc = function(...) + return world:npc(...) +end +mod.exports.toggle = function(...) + return world:toggleObject(...) +end +mod.exports.done = function(...) + calls = calls + 1 + args = { n = select("#", ...), first = (...), third = select(3, ...) } +end +mod.exports.result = function() + return { calls = calls, args = args } +end diff --git a/tests/api3_contract/mods/npcs/manifest.json b/tests/api3_contract/mods/npcs/manifest.json new file mode 100644 index 000000000..16a8a3457 --- /dev/null +++ b/tests/api3_contract/mods/npcs/manifest.json @@ -0,0 +1 @@ +{"id":"npcs","name":"Portable NPC Contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/npcs.lua b/tests/api3_contract/npcs.lua new file mode 100644 index 000000000..7ee2a243f --- /dev/null +++ b/tests/api3_contract/npcs.lua @@ -0,0 +1,320 @@ +-- Real loaders/native public adapters with native actor/map/timing seams. +local M = {} +local unpack = table.unpack or unpack +function M.fixture(root, backend, version) + local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/npcs%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "npcs") + local Runtime = require("src.mods.Runtime") + love = love or require("tests.love_stub") + -- Render construction has no bearing on the public state contract. + local Renderer = require("src.render.SpriteRenderer") + local savedRenderer = Renderer.new + Renderer.new = function(def) + return { def = def, big = false } + end + f.restoreRenderer = function() + Renderer.new = savedRenderer + end + local game, ow = f.game, f.ow + game.data.sprites = { + PROBE = { image = "fixture.png", frames = 6, walker = true }, + ALT = { image = "alt.png", frames = 6, walker = true }, + } + game.data.gen2Sprites = game.data.sprites + ow.game, ow.sprites = game, game.data.sprites + local tiles = { + blocks = { [2] = {} }, + walkable = { 0 }, + collision = { [2] = { 0, 0, 0, 0 } }, + blockCells = f.gen == 3 and 1 or 2, + blockTiles = f.gen == 3 and 2 or 4, + } + for i = 1, 16 do + tiles.blocks[2][i] = 0 + end + if not (backend == "gen1" and f.gen == 2) then + tiles.collision = nil + end + for _, id in ipairs({ f.home, f.dest }) do + local def = game.data.maps[id] + def.width, def.height, def.objects, def.blocks = 8, 8, {}, {} + for i = 1, 64 do + def.blocks[i] = 1 + end + end + local base = { + index = 1, + localId = 1, + name = "IMPORTED", + sprite = "PROBE", + x = 6, + y = 6, + movement = "STAY", + range = "DOWN", + } + game.data.maps[f.home].objects = { base } + if backend == "gen1" and f.gen == 2 then + ow.map = require("src.world.gen2.Map").new(game.data.maps[f.home], tiles) + elseif not (backend == "gen1" and f.gen == 3) then + ow.map = require("src.world.Map").new(game.data.maps[f.home], tiles) + end + ow.npcs, ow.entities, ow.npcPool = {}, { f.player }, {} + f.player.cellX, f.player.cellY = 7, 7 + function f:make(id, row) + local actor + if backend == "gen1" and self.gen == 2 then + actor = require("src.world.gen2.Npc").new(id, row, game.data.sprites[row.sprite]) + elseif backend == "gen1" and self.gen == 3 then + actor = require("src.core.game3.object_prepare").instance(row, { mapId = id }) + actor.id, actor.publicId = id .. "_obj_" .. row.index, id .. "_obj_" .. row.index + else + actor = require("src.world.NPC").new(game.data, id, row) + end + function actor:queueApiMoves(_, callback) + f:begin(self, "march", nil, 1, callback) + return true + end + return actor + end + f.imported = f:make(f.home, base) + ow.npcs[1] = f.imported + ow.entities[2] = f.imported + ow.npcPool[f.imported.id] = f.imported + function f:begin(actor, kind, dir, count, callback) + self.pending = { actor = actor, kind = kind, dir = dir, count = count, callback = callback } + self.busy = true + actor.moving = true + if backend == "gen2" or self.gen == 1 then + local entry = + { entity = actor, dir = dir, remaining = count, onDone = callback, inPlace = kind == "march" } + ow.scriptMoves[#ow.scriptMoves + 1] = entry + end + if self.synchronous then + self:complete("sync", nil, "tail") + end + if self.failMovement then + return false, "movement refused" + end + return true + end + function ow:scriptMove(actor, dir, count, callback) + return f:begin(actor, "move", dir, count, callback) + end + function ow:marchInPlace(actor, callback) + return f:begin(actor, "march", nil, 1, callback) + end + function ow:beginMovement(objectId, bytes, callback) + local actor + for _, candidate in ipairs(self.npcs) do + if candidate.def.index + 1 == objectId then + actor = candidate + end + end + local dir = ({ [0x0C] = "down", [0x0D] = "up", [0x0E] = "left", [0x0F] = "right" })[bytes[1]] + return f:begin(assert(actor), "move", assert(dir), #bytes - 1, callback) + end + function ow:applySpritePalette(actor) + f.paletteActor = actor + end + function f:complete(...) + local pending = self.pending + assert(pending, "no pending move") + local actor = pending.actor + if pending.kind == "move" then + local delta = ({ up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } })[pending.dir] + actor.cellX, actor.cellY = + actor.cellX + delta[1] * pending.count, actor.cellY + delta[2] * pending.count + end + actor.moving, actor.marching = false, false + actor.px, actor.py = actor.cellX * 16, actor.cellY * 16 + self.busy = false + ow.scriptMoves = {} + if pending.callback then + pending.callback(...) + end + end + function f:spawn(id, row, owner) + local def = assert(game.data.maps[id]) + local index = 0 + for _, existing in ipairs(def.objects) do + index = math.max(index, existing.index) + end + row.index, row.localId, row.runtime, row.owner = index + 1, index + 1, true, owner + def.objects[#def.objects + 1] = row + local publicId = id .. "_obj_" .. row.index + if id == self.home then + local actor = self:make(id, row) + ow.npcs[#ow.npcs + 1] = actor + ow.entities[#ow.entities + 1] = actor + ow.npcPool[publicId] = actor + if self.byId then + self.byId[row.localId] = actor + self.order[#self.order + 1] = row.localId + end + end + return publicId + end + function ow:addRuntimeObject(id, row, owner) + return f:spawn(id, row, owner) + end + function f:remove(id, owner) + for _, def in pairs(game.data.maps) do + for i, row in ipairs(def.objects or {}) do + if row.runtime and def.id .. "_obj_" .. row.index == id then + if row.owner ~= owner then + return nil, "not owned by " .. tostring(owner) + end + table.remove(def.objects, i) + for _, list in ipairs({ ow.npcs, ow.entities }) do + for j = #list, 1, -1 do + if list[j].id == id then + table.remove(list, j) + end + end + end + ow.npcPool[id] = nil + if self.byId then + self.byId[row.localId] = nil + for j = #self.order, 1, -1 do + if self.order[j] == row.localId then + table.remove(self.order, j) + end + end + end + return true + end + end + end + return nil, "no runtime object " .. tostring(id) + end + function ow:removeRuntimeObject(id, owner) + return f:remove(id, owner) + end + if backend == "gen1" and f.gen == 3 then + f.byId, f.order = { [1] = f.imported }, { 1 } + package.loaded["src.core.game3.objects"] = { + _byId = f.byId, + _order = f.order, + addRuntimeObject = function(id, row, owner) + return f:spawn(id, row, owner) + end, + removeRuntimeObject = function(id, owner) + return f:remove(id, owner) + end, + scriptFace = function(actor, dir) + actor.facing = dir + end, + scriptStep = function(actor, dir) + local delta = ({ up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } })[dir] + actor.facing = dir + actor.targetX = actor.cellX + delta[1] + actor.targetY = actor.cellY + delta[2] + actor.moving = true + end, + startTrack = function(id, actions, callback) + local count = 0 + for _, action in ipairs(actions) do + if action.kind == "step" then + count = count + 1 + end + end + return f:begin(f.byId[id], count > 0 and "move" or "march", actions[1].dir, count, callback) + end, + } + package.loaded["src.core.game3.collision"] = { + canEnter = function() + return true + end, + isWater = function() + return false + end, + } + end + function f:update() + return self.loader.hooks:call("input.step", function() + return "updated", nil, "tail" + end, game, 0) + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local ex = f.exports + local report = { invalid = {}, actions = {} } + local function refused(value, why) + assert(value == nil and type(why) == "string" and #why > 0, tostring(why)) + end + refused(ex.spawn("UNKNOWN", { x = 0, y = 0 })) + refused(ex.spawn(f.home, { x = -1, y = 0 })) + refused(ex.spawn(f.home, { x = 0.5, y = 0 })) + refused(ex.npc(f.home, false)) + refused(ex.remove(false)) + assert(#f.game.data.maps[f.home].objects == 1 and #f.ow.npcs == 1) + local definition = { name = "RUNTIME", sprite = "PROBE", x = 0, y = 0, movement = "STAY", range = "DOWN" } + local id = assert(ex.spawn(f.home, definition)) + local handle = assert(ex.npc(f.home, id)) + assert(handle.id == id and ex.npc(f.home, "RUNTIME").id == id) + definition.x, definition.sprite = 99, "changed" + local actor = f.ow.npcPool[id] + assert(actor.cellX == 0 and actor.def.sprite == "PROBE") + for _, method in ipairs({ "scriptMove", "face", "stepNow", "canStep" }) do + refused(handle[method](handle, "north")) + end + refused(handle:scriptMove("right", 0)) + refused(handle:scriptMove("right", 1025)) + refused(handle:scriptMove("right", 1, false)) + refused(handle:marchInPlace(false)) + refused(handle:placeAt(-1, 0)) + refused(handle:setPassable("yes")) + refused(handle:setAppearance("")) + assert(handle:face("right")) + local allowed, blocked = handle:canStep("right") + assert(allowed == true, tostring(allowed) .. ": " .. tostring(blocked)) + assert(handle:setPassable(true) and actor.passable == true) + assert(handle:setAppearance("ALT")) + assert(handle:position() == 0 and handle:isMoving() == false) + assert(handle:stepNow("right")) + assert(handle:isMoving() == true and actor.targetX == 1) + refused(handle:stepNow("right")) + assert(handle:placeAt(2, 2, "up")) + assert(handle:isMoving() == false and actor.px == 32 and actor.py == 32) + local x, y = handle:position() + assert(x == 2 and y == 2) + assert(handle:scriptMove("right", 2, ex.done)) + assert(ex.result().calls == 0) + f:complete("done", nil, "tail") + x, y = handle:position() + assert(x == 4 and y == 2) + assert(ex.result().calls == 1 and ex.result().args.n == 3 and ex.result().args.third == "tail") + assert(handle:marchInPlace(ex.done)) + f:complete("march") + x, y = handle:position() + assert(x == 4 and y == 2 and ex.result().calls == 2) + assert(handle:scriptMove("left")) + f:complete() + assert(handle:position() == 3 and ex.result().calls == 2) + assert(handle:marchInPlace()) + f:complete() + assert(handle:placeAt(4, 2)) + local imported = assert(ex.npc(f.home, "IMPORTED")) + refused(ex.remove(imported.id)) + local other = f:spawn(f.home, { name = "OTHER", sprite = "PROBE", x = 5, y = 5 }, "other") + refused(ex.remove(other)) + assert(ex.remove(id)) + refused(ex.npc(f.home, id)) + assert(ex.npc(f.home, other) and ex.npc(f.home, imported.id)) + report.actions = { + x = x, + y = y, + passable = actor.passable, + callbacks = ex.result().calls, + definitionCopied = true, + ownedRemoval = true, + } + report.invalid = true + assert(#f.loader.errors == 0) + f.restoreRenderer() + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index 0fe96b9ab..b288ae8c1 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -12,11 +12,14 @@ assert( or mode == "scaffold" or mode == "warp" or mode == "world_edits" + or mode == "npcs" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "world_edits" then +if mode == "npcs" then + dofile(codeRoot .. "/npcs.lua").run(root, backend, version) +elseif mode == "world_edits" then dofile(codeRoot .. "/world_edits.lua").run(root, backend, version) elseif mode == "warp" then dofile(codeRoot .. "/warp.lua").run(root, backend, version) diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua index 2ce5e14a9..35e00aece 100644 --- a/tests/engine/api3_compat.lua +++ b/tests/engine/api3_compat.lua @@ -134,6 +134,9 @@ do T.eq(type(world.party), "function", "early world method callable") T.eq(type(battle.snapshot), "function", "early battle method callable") for _, name in ipairs({ + "spawnNpc", + "removeNpc", + "npc", "warpTo", "toggleObject", "replaceBlock", @@ -148,9 +151,6 @@ do T.eq(m:supports("world", name), true, "implemented world method " .. name) end for _, name in ipairs({ - "spawnNpc", - "removeNpc", - "npc", "startWildBattle", "invalidateMap", }) do @@ -217,6 +217,9 @@ end for _, version in ipairs({ "platinum", "black" }) do local m = boot(3, nil, false, version) T.eq(m:supports("world", "party"), false, "no shared Gen1 contract claimed for " .. version) + for _, name in ipairs({ "spawnNpc", "removeNpc", "npc" }) do + T.eq(m:supports("world", name), false, "no shared NPC API promised for " .. version) + end T.eq(m:supports("world", "warpTo"), false, "no shared warp promised for " .. version) T.eq(m:supports("world", "toggleObject"), false, "no shared object edits promised for " .. version) T.eq(m:supports("world", "replaceBlock"), false, "no shared block edits promised for " .. version) diff --git a/tests/engine/api3_npc.lua b/tests/engine/api3_npc.lua new file mode 100644 index 000000000..f3c4844c4 --- /dev/null +++ b/tests/engine/api3_npc.lua @@ -0,0 +1,423 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.npcs") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local Renderer = require("src.render.SpriteRenderer") +local originalRenderer = Renderer.new +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +love = nil +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +local function spawn(f) + local id = assert( + f.exports.spawn( + f.home, + { x = 0, y = 0, name = "OWN", sprite = "PROBE", movement = "STAY", range = "DOWN" } + ) + ) + return assert(f.exports.npc(f.home, id)), f.ow.npcPool[id] +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared NPC contract: " .. tostring(why)) +end +local cycle = { x = 0, y = 0 } +cycle.self = cycle +local meta = setmetatable({ x = 0, y = 0 }, {}) +local deep = { x = 0, y = 0 } +local child = deep +for _ = 1, 18 do + child.next = {} + child = child.next +end +local wide = { x = 0, y = 0 } +for i = 1, 260 do + wide[i] = i +end +for _, row in ipairs({ + cycle, + meta, + deep, + wide, + { x = 0, y = 0, handler = function() end }, + { x = math.huge, y = 0 }, + { x = 0 / 0, y = 0 }, + { x = 1000001, y = 0 }, + { x = 0, y = -1 }, + { x = 0, y = 0.5 }, + { x = 0, y = 0, name = "bad\nname" }, + { x = 0, y = 0, sprite = "missing" }, + { x = 0, y = 0, facing = "north" }, + { x = 0, y = 0, payload = string.rep("x", 4097) }, +}) do + local f = fixture() + local value, why = f.exports.spawn(f.home, row) + T.eq(value, nil, "invalid definition refused") + T.check(type(why) == "string", "definition failure explained") + T.eq(#f.game.data.maps[f.home].objects, 1, "invalid definition adds no template") + T.eq(#f.ow.npcs, 1, "invalid definition adds no actor") +end +for _, version in ipairs({ "red", "gold", "firered" }) do + for _, lock in ipairs({ "overlay", "player", "script", "link", "parallel", "controller" }) do + local f = fixture(version) + local handle, actor = spawn(f) + if lock == "overlay" then + f.game.stack.states[2] = {} + elseif lock == "player" then + f.player.moving = true + elseif lock == "script" then + f.ow.runner.isRunning = function() + return true + end + elseif lock == "link" then + f.game.linkSession = {} + elseif lock == "parallel" then + f.ow.parallelRunners = { {} } + else + f.ow._modScriptLock = {} + end + T.eq(handle:position(), 0, "busy world permits position read") + T.eq(handle:isMoving(), false, "busy world permits motion read") + T.eq(handle:face("left"), nil, lock .. " blocks mutation") + T.eq(f.exports.spawn(f.home, { x = 0, y = 0 }), nil, lock .. " blocks spawn") + T.eq(f.exports.remove(handle.id), nil, lock .. " blocks removal") + T.eq(actor.facing, "down", "refusal does not change actor") + end +end +for _, method in ipairs({ "face", "stepNow", "scriptMove", "canStep" }) do + for _, bad in ipairs({ "north", false, {}, 1 }) do + local f = fixture() + local handle = spawn(f) + local ok, why = handle[method](handle, bad) + T.eq(ok, nil, "bad direction refused for " .. method) + T.eq(why, "invalid direction", "bad direction diagnosed") + T.eq(#f.ow.scriptMoves, 0, "no queued movement on invalid direction") + end +end +for _, bad in ipairs({ 0, -1, 0.5, math.huge, 0 / 0, 1025, "1" }) do + local f = fixture() + local handle = spawn(f) + T.eq(handle:scriptMove("up", bad), nil, "invalid move count refused") + T.eq(#f.ow.scriptMoves, 0, "invalid count has no effect") +end +do + local f = fixture() + local handle, actor = spawn(f) + local native = f.ow.addRuntimeObject + function f.ow:addRuntimeObject(...) + local value, why = handle:face("left") + T.eq(value, nil, "native construction cannot reenter NPC mutation") + T.eq(why, "world is busy", "NPC reentry diagnosed") + value, why = f.exports.toggle(f.home, "IMPORTED", false) + T.eq(value, nil, "NPC construction cannot reenter object edit") + T.eq(why, "world is busy", "shared mutation guard diagnosed") + return native(self, ...) + end + T.check(f.exports.spawn(f.home, { x = 2, y = 2, sprite = "PROBE" }), "outer construction succeeds") + T.eq(actor.facing, "down", "reentry leaves actor untouched") + T.check(handle:face("right"), "construction releases guard") + local constructor = Renderer.new + Renderer.new = function() + error("renderer failed", 0) + end + local def, sprite = actor.def, actor.sprite + T.eq(handle:setAppearance("ALT"), nil, "failed renderer construction contained") + T.eq(actor.def, def, "failed renderer retains original definition") + T.eq(actor.sprite, sprite, "failed renderer retains original sprite") + Renderer.new = constructor + T.check(handle:face("up"), "renderer exception releases guard") +end +for _, change in ipairs({ "game", "save", "data", "stack", "world", "map", "actor", "version", "bus" }) do + local f = fixture() + local handle, actor = spawn(f) + T.check(handle:scriptMove("right", 2, f.exports.done), "movement starts before " .. change) + local callback = f.pending.callback + local restore + if change == "game" then + local old = f.live + f.live = {} + restore = function() + f.live = old + end + elseif change == "version" then + Version.set("blue") + restore = function() + Version.set("red") + end + elseif change == "world" then + local old = f.game.overworld + f.game.overworld = {} + restore = function() + f.game.overworld = old + end + elseif change == "bus" then + local old = Runtime.events + Runtime.events = require("src.mods.Events").new() + restore = function() + Runtime.events = old + end + elseif change == "actor" then + table.remove(f.ow.npcs, #f.ow.npcs) + restore = function() + f.ow.npcs[#f.ow.npcs + 1] = actor + end + else + local object = (change == "map") and f.ow or f.game + local old = object[change] + object[change] = {} + restore = function() + object[change] = old + end + end + f:update() + T.eq(#f.ow.scriptMoves, 0, "changed context retires only owned movement") + restore() + callback("stale") + callback("replayed") + T.eq(f.exports.result().calls, 0, "restored " .. change .. " cannot revive callback") +end +for _, event in ipairs({ "map.exited", "map.entered" }) do + local f = fixture() + local handle = spawn(f) + T.check(handle:scriptMove("right", 1, f.exports.done), "movement starts before map lifecycle") + local callback = f.pending.callback + Runtime.emit(event, { mapId = f.home }) + T.eq(#f.ow.scriptMoves, 0, "map lifecycle retires movement immediately") + callback() + T.eq(f.exports.result().calls, 0, "old map callback suppressed") +end +for _, action in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + local handle, actor = spawn(f) + local foreign = f:spawn(f.home, { name = "OTHER", sprite = "PROBE", x = 5, y = 5 }, "other") + T.check(handle:scriptMove("right", 1, f.exports.done), "queue before " .. action) + local callback = f.pending.callback + local foreignEntry = { + entity = f.imported, + remaining = 1, + onDone = function() + error("do not invoke") + end, + } + f.ow.scriptMoves[#f.ow.scriptMoves + 1] = foreignEntry + if action == "rollback" then + f.loader:_rollback("npcs") + elseif action == "reload" then + fixture() + else + Loader.teardown() + end + T.eq(#f.ow.scriptMoves, 1, "unload removes only owned queue entries") + T.eq(f.ow.scriptMoves[1], foreignEntry, "foreign movement retained") + T.eq(#f.game.data.maps[f.home].objects, 2, "unload removes only own runtime template") + T.eq(f.ow.npcPool[handle.id], nil, "own actor removed from native pool") + T.check(f.ow.npcPool[foreign], "foreign owner retained") + callback() + T.eq(f.exports.result().calls, 0, "unload suppresses native callback") + T.eq(handle:position(), nil, "unloaded handle retired") + T.eq(f.exports.npc(f.home, "IMPORTED"), nil, "unloaded facade retired") +end +do + local f = fixture() + local handle, actor = spawn(f) + T.check(f.exports.remove(handle.id), "own removal succeeds") + local replacement = assert(f.exports.spawn(f.home, { name = "NEW", sprite = "PROBE", x = 1, y = 1 })) + T.eq(replacement, handle.id, "native ID can be reused") + local value, why = handle:face("left") + T.eq(value, nil, "old handle cannot change replacement with same ID") + T.eq(why, "NPC handle is stale", "stale handle diagnosed") + T.eq(f.exports.npc(f.home, replacement):position(), 1, "replacement remains untouched") +end +do + local f = fixture() + local handle, actor = spawn(f) + f.synchronous = true + T.check( + handle:scriptMove("right", 1, function(...) + T.eq(Runtime.currentMod, "npcs", "movement callback attributed") + f.exports.done(...) + f.synchronous = false + T.check(handle:face("up"), "synchronous callback runs after commit guard released") + end), + "synchronous move accepted" + ) + T.eq(f.exports.result().calls, 1, "synchronous callback called once") + T.eq(f.exports.result().args.n, 3, "nil argument holes preserved") + f.pending.callback() + T.eq(f.exports.result().calls, 1, "duplicate completion ignored") + T.eq(Runtime.currentMod, nil, "callback restores runtime owner") +end +for _, err in ipairs({ + "authored failure", + setmetatable({}, { + __tostring = function() + error("hostile tostring") + end, + }), +}) do + local f = fixture() + local handle = spawn(f) + T.check( + handle:scriptMove("right", 1, function() + error(err, 0) + end), + "throwing callback admitted" + ) + T.check( + pcall(function() + f:complete() + f.pending.callback() + end), + "callback errors cannot escape native completion" + ) + T.eq(#f.loader.errors, 1, "callback error attributed once") + T.check( + f.loader.errors[1]:find("npcs: NPC movement callback failed:", 1, true), + "callback error names owner" + ) +end +do + local f = fixture() + local handle = spawn(f) + f.failMovement, f.synchronous = true, true + T.eq(handle:scriptMove("right", 1, f.exports.done), nil, "native refusal never counts as admission") + T.eq(f.exports.result().calls, 0, "callback deferred until native result") + T.eq(#f.ow.scriptMoves, 0, "native refusal leaves no owned queue entry") +end +do + local f = fixture() + local handle = spawn(f) + local native = f.ow.scriptMove + function f.ow:scriptMove(...) + Runtime.emit("map.exited", { mapId = f.home }) + return native(self, ...) + end + T.eq(handle:scriptMove("right", 1, f.exports.done), nil, "map change during admission refuses completion") + T.eq(#f.ow.scriptMoves, 0, "late-created owned entry cleaned after cancellation race") +end +do + local f = fixture() + local handle = spawn(f) + T.check(handle:scriptMove("right"), "optional count/callback accepted") + f:complete() + T.eq(handle:position(), 1, "default count is one") + T.eq(#f.loader.errors, 0, "omitted callback never treated as a callable") +end +-- Real native object creation/removal, NPC updates and movement queue. +local OW = require("src.world.OverworldController") +local function bind(game) + for i = 1, 100 do + local name, old = debug.getupvalue(OW.addRuntimeObject, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(OW.addRuntimeObject, i, game) + return function() + debug.setupvalue(OW.addRuntimeObject, i, old) + end + end + end + error("native Game upvalue missing") +end +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + local restore = bind(f.game) + f.ow.addRuntimeObject, f.ow.removeRuntimeObject = OW.addRuntimeObject, OW.removeRuntimeObject + f.ow.scriptMove, f.ow.marchInPlace = OW.scriptMove, OW.marchInPlace + local handle, actor = spawn(f) + actor.frozen = true + T.check(handle:scriptMove("right", 2, f.exports.done), version .. " native movement starts") + for _ = 1, 40 do + OW.updateScriptMoves(f.ow) + actor:update(f.ow.map, f.ow.entities) + end + T.eq(handle:position(), 2, "native queue walks exactly two cells") + T.eq(f.exports.result().calls, 1, "native completion exactly once") + T.eq(#f.ow.scriptMoves, 0, "native movement releases world input") + T.check(handle:marchInPlace(f.exports.done), "native march starts") + for _ = 1, 20 do + OW.updateScriptMoves(f.ow) + actor:update(f.ow.map, f.ow.entities) + end + T.eq(handle:position(), 2, "march does not translate") + T.eq(f.exports.result().calls, 2, "native march completes") + T.check(handle:setAppearance("ALT"), "native renderer refresh path") + T.eq(actor.def.sprite, "ALT", "actor appearance changed") + T.eq( + f.game.data.maps[f.home].objects[2].sprite, + "PROBE", + "native template data not rewritten by appearance" + ) + T.check(f.exports.remove(handle.id), "native removal succeeds") + T.eq(#f.ow.npcs, 1, "native removal retains imported actor") + restore() +end +do + local f = fixture() + local native = f.ow.addRuntimeObject + function f.ow:addRuntimeObject(...) + native(self, ...) + error("creation failure", 0) + end + T.eq(f.exports.spawn(f.home, { sprite = "PROBE", x = 0, y = 0 }), nil, "creation exception contained") + T.eq(#f.game.data.maps[f.home].objects, 1, "failed creation template rolled back") + T.eq(#f.ow.npcs, 1, "failed creation actor rolled back") + T.eq(next(f.ow.npcPool), f.imported.id, "failed creation leaves pool unchanged") +end +do + local f = fixture() + local row = + { name = "LATER", sprite = "PROBE", x = 1, y = 1, owner = "forged", runtime = false, index = 900 } + local id = assert(f.exports.spawn(f.dest, row)) + local record = f.game.data.maps[f.dest].objects[1] + T.eq(record.owner, "npcs", "caller cannot forge runtime ownership") + T.eq(record.runtime, true, "native runtime marker assigned") + T.eq(record.index, 1, "native identity replaces supplied index") + T.eq(#f.ow.npcs, 1, "inactive spawn adds no active actor") + local actor = f:make(f.dest, record) + actor.def = require("src.mods.Merge").deepCopy(record) -- Native placement/appearance clones. + local map = require("src.world.Map").new(f.game.data.maps[f.dest], f.ow.map.tileset) + f.ow.ghosts = { { npc = actor, map = map } } + f.ow.npcPool[id] = actor + local handle = assert(f.exports.npc(f.dest, id)) + T.eq(handle:position(), 1, "live neighbor uses its own coordinate frame") + T.eq(handle:canStep("right"), true, "neighbor collision uses neighbor map") + local homeHandle, homeActor = spawn(f) + homeActor.cellX, homeActor.cellY = 2, 1 + local nb = { map = map, cx = 0, cy = 0, peers = { actor } } + f.ow.neighbors = { nb } + f.ow.ghosts[1].nb = nb + require("src.world.OverworldController").updateCast(f.ow) + T.eq(handle:canStep("right"), false, "neighbor cast sees translated primary actor") + T.eq(homeHandle:canStep("left"), false, "primary cast sees translated neighbor actor") + homeActor.passable = true + actor.passable = true + require("src.world.OverworldController").updateCast(f.ow) + T.eq(handle:canStep("right"), true, "neighbor cast respects primary passability") + T.eq(homeHandle:canStep("left"), true, "primary cast respects neighbor passability") + T.check(handle:face("left"), "neighbor can face") + local ok, why = handle:scriptMove("right", 1) + T.eq(ok, nil, "neighbor track refused") + T.eq(why, "movement tracks require the active map", "neighbor restriction explicit") + Loader.teardown() + T.eq(#f.game.data.maps[f.dest].objects, 0, "inactive template cleaned on unload") + T.eq(#f.ow.ghosts, 0, "later neighbor actor clone cleaned on unload") + T.eq(f.ow.npcPool[id], nil, "neighbor pool cleaned on unload") +end +do + local f = fixture() + local handle, actor = spawn(f) + handle.id = f.imported.id + T.check(handle:face("left"), "public ID field cannot redirect private handle binding") + T.eq(actor.facing, "left", "original binding changed") + T.eq(f.imported.facing, "down", "imported actor unaffected") +end +Renderer.new = originalRenderer +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 public NPC actions") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index b20738ec1..fdaef60d3 100644 --- a/tools/api3-contract-check/check.py +++ b/tools/api3-contract-check/check.py @@ -50,7 +50,7 @@ def main(): for path in (scaffold_root / "mods/portable").iterdir() if path.is_file()} for version in VERSIONS: - for mode in ("normal", "rollback", "scaffold", "warp", "world_edits"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 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 16/67] 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 17/67] 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 18/67] Keep the authenticated gallery documentation byte-identical --- mods/examples/example_silly_oak/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mods/examples/example_silly_oak/README.md b/mods/examples/example_silly_oak/README.md index 7278827ea..3a9e77ce0 100644 --- a/mods/examples/example_silly_oak/README.md +++ b/mods/examples/example_silly_oak/README.md @@ -35,8 +35,8 @@ SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \ | Seam | Where | |---|---| -| `hooks:wrap("intro.oak_speech.build")` | `main.lua` — reshape the step list | -| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` — anchored on vanilla step ids | +| `hooks:wrap("intro.oak_speech.build")` | `main.lua` -- reshape the step list | +| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` -- anchored on vanilla step ids | | step kinds `say` / `yesno` / `choice` | `main.lua` | | pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` | | `events:on("intro.oak_speech.answered")` | `main.lua` → `mod.save` | From d176fe86049fecf66b17155665eab27ec476b378 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:54:50 +0100 Subject: [PATCH 19/67] compat(M1): add native module aliases and named unsupported imports --- src/core/ModCompat.lua | 11 +++++++++++ src/mods/Gen1Compat.lua | 22 ++++++++++++++++++++++ tests/engine/compat_module_aliases.lua | 19 +++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 src/mods/Gen1Compat.lua create mode 100644 tests/engine/compat_module_aliases.lua diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..31a1015a2 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -100,6 +100,13 @@ ModCompat.ALIASES = { ["src.render.GbcPalette"] = "src.render.GBCFX", } +-- Extend the data vocabulary without changing loader permissions or public +-- module objects. Existing aliases retain their native identities. +local Compatibility = require("src.mods.Gen1Compat") +for name, target in pairs(Compatibility.ALIASES) do + if ModCompat.ALIASES[name] == nil then ModCompat.ALIASES[name] = target end +end + local seen = {} local installed = false @@ -107,6 +114,8 @@ local installed = false -- matters because "the target is missing too" is a build problem, not a mod -- problem, and should not be reported as an unknown module. function ModCompat.resolve(name) + local missing = Compatibility.unavailable(name) + if missing then return nil, missing end local target = ModCompat.ALIASES[name] if not target then return nil, nil end local ok, value = pcall(require, target) @@ -129,6 +138,8 @@ function ModCompat.install() local loaders = package.loaders or package.searchers if type(loaders) ~= "table" then return false end loaders[#loaders + 1] = function(name) + local missing = Compatibility.unavailable(name) + if missing then return function() error(missing, 0) end end if not ModCompat.ALIASES[name] then -- the standard "not found" contract: a string the require error joins return "\n\tno mod-compat alias for '" .. tostring(name) .. "'" diff --git a/src/mods/Gen1Compat.lua b/src/mods/Gen1Compat.lua new file mode 100644 index 000000000..9004bdc62 --- /dev/null +++ b/src/mods/Gen1Compat.lua @@ -0,0 +1,22 @@ +-- Compatibility spellings, kept separate from the loader's execution policy. +-- Only interchangeable native objects belong in ALIASES. Unsupported modules +-- fail at import with an actionable diagnostic instead of a misleading facade. +local C = {} +C.ALIASES = { + ['src.world.gen2.WorldAPI'] = 'src.world.WorldAPI', + ['src.world.gen2.Follower'] = 'src.world.OptionalFollower', +} +C.UNAVAILABLE = { + ['src.import.pmd.Sprite'] = { 'mystery_dungeon_follower' }, + ['src.world.game3.Follower'] = { 'mystery_dungeon_follower' }, + ['src.core.gen2.Unown'] = { 'mystery_dungeon_follower' }, + ['src.core.game3.pokemon'] = { 'mystery_dungeon_follower' }, + ['src.core.game3.player'] = { 'mystery_dungeon_follower' }, +} +function C.unavailable(name) + local consumers = C.UNAVAILABLE[name] + if not consumers then return nil end + return ('Compatibility module %s is unavailable in this engine; affected mods: %s') + :format(name, table.concat(consumers, ', ')) +end +return C diff --git a/tests/engine/compat_module_aliases.lua b/tests/engine/compat_module_aliases.lua new file mode 100644 index 000000000..b04529a8e --- /dev/null +++ b/tests/engine/compat_module_aliases.lua @@ -0,0 +1,19 @@ +local T=require('tests.harness') +love=require('tests.love_stub') +local C=require('src.core.ModCompat') +C.install() +T.eq(require('src.world.gen2.WorldAPI'),require('src.world.WorldAPI'),'world API alias preserves object identity') +if loadfile('src/world/OptionalFollower.lua') then + T.eq(require('src.world.gen2.Follower'),require('src.world.OptionalFollower'),'Gen2 follower alias preserves object identity') +else + T.raises(function()require('src.world.gen2.Follower')end,'src.world.OptionalFollower','alias identifies an unavailable native follower provider') +end +local names=require('src.mods.Gen1Compat') +for name in pairs(names.UNAVAILABLE)do + local ok,reason=pcall(require,name) + T.check(not ok,'unsupported import fails '..name) + T.check(tostring(reason):find(name,1,true)~=nil,'failure identifies module '..name) + T.check(tostring(reason):find('mystery_dungeon_follower',1,true)~=nil,'failure identifies affected consumer '..name) + T.check(not tostring(reason):find('no file ',1,true),'failure does not masquerade as a filesystem search '..name) +end +T.finish() From b8ebe739478cbb6f32e5eab2917cb1cf2b9d77b8 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:00:40 +0100 Subject: [PATCH 20/67] Implement native Gen2 radio records and wall-radio script lifecycle --- docs/native-gen2-radio.md | 49 ++++++++ src/core/ModCompat.lua | 1 + src/mods/Loader.lua | 7 +- src/mods/RadioContent.lua | 115 +++++++++++++++++++ src/mods/Schemas.lua | 8 ++ src/script/Gen2Commands.lua | 9 +- src/ui/Gen2MapRadio.lua | 60 ++++++++++ src/ui/PokegearMenu.lua | 17 ++- src/world/Gen2Radio.lua | 103 +++++++++++++++++ tests/engine/gen2_radio_content.lua | 170 ++++++++++++++++++++++++++++ tests/engine/gen2_radio_runtime.lua | 88 ++++++++++++++ 11 files changed, 622 insertions(+), 5 deletions(-) create mode 100644 docs/native-gen2-radio.md create mode 100644 src/mods/RadioContent.lua create mode 100644 src/ui/Gen2MapRadio.lua create mode 100644 src/world/Gen2Radio.lua create mode 100644 tests/engine/gen2_radio_content.lua create mode 100644 tests/engine/gen2_radio_runtime.lua diff --git a/docs/native-gen2-radio.md b/docs/native-gen2-radio.md new file mode 100644 index 000000000..844d9df63 --- /dev/null +++ b/docs/native-gen2-radio.md @@ -0,0 +1,49 @@ +# Native Gen2 radio content and wall-radio lifecycle + +The native `MapRadio` special previously only assigned a successful check +result. It now opens a native radio overlay, holds its station announcement +for 100 update frames, and resumes the suspended native script once on A/B. +Closing a covered or already closed overlay cannot pop another state or +resume the script again. The existing `setval` result is preserved. + +`mod.content.radio_channels` provides the shared native station records for +Gold, Silver and Crystal. Records accept an optional `channel` integer +0–255 and optional authored display `name`. Positive wall-radio channel +numbers must be unique. Channel zero remains the automatic Pokemon Channel: +Johto morning selects Pokedex, other Johto hours select Oak, and Kanto +selects Places & People. Region detection uses the loaded map definition +and imported Pallet landmark boundary when available. + +API 1/2 native mods can inspect detached records, patch, +override, register and remove them. The native Pokegear uses the same +records for its existing program slots; a new numbered station is available +to wall radios without inventing a Pokegear tuning frequency. An explicitly +empty dataset remains empty. Built-in names retain engine translation, +whereas authored names are displayed literally. + +Content writes are frozen after load. Failed entries roll back their earlier +writes and subscriptions. Reusing the mounted dataset on reload retires old +mod writes while preserving unrelated external edits and removals. A mod-free +load does not materialize an extra namespace. Other generations receive an unavailable content handle and cannot create +the native target. This standalone patch does not add the fork capability +query or unified API prerequisites. + +`radio.channel` is emitted on tuning, not on drawing or advancing text: +`station` is the stable station ID, `name` is its display name, `channel` is +the wall script byte or native Pokegear knob position, and `source` is +`map_radio` or `pokegear`. + +Focused synthetic acceptance: 113 content/loader checks and 73 native +VM/UI/input checks. These tests use the actual loader, bytecode lowering, +coroutine runner, native state stack and Pokegear input path. They do not +establish GPU or unchanged-mod gameplay acceptance. + +The existing native Pokegear program text is reused. Full cartridge show +state machines, schedule-dependent program data, songs and map-music +restoration are still open parity work. This change is not complete radio +parity and must receive new runtime/package/GPU qualification before release. + +Original contributions by Roxas2712 are offered to Cedric / UNDERdecoded +under the specific contribution permission in +[LICENSE-ROXAS2712.md](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). +Existing project, third-party and previously granted rights remain effective. diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..014cf6cfb 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -67,6 +67,7 @@ ModCompat.ALIASES = { -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", + ["src.ui.gen2.MapRadio"] = "src.ui.Gen2MapRadio", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..ebc79b831 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -717,7 +717,7 @@ function Loader:_contentApi(mod, registry, deprecation) if apiLevel >= 2 then error(err, 0) end Logger.warn("[%s] %s", modId, err) end - return { + local api = { register = function(_, id, value) note() validate("register", id, value) @@ -750,6 +750,10 @@ function Loader:_contentApi(mod, registry, deprecation) return registry:each() end, } + if registry.name == "radio_channels" then + return require("src.mods.RadioContent").api(loader,mod,api,loader:_generation()) + end + return api end -- mod.commands is sugar over the commands registry; the engine's own verbs @@ -1326,6 +1330,7 @@ function Loader:load(data) end end end + require("src.mods.RadioContent").prepare(self, data) -- vanilla content is registrations too, and they land before discovery so -- a mod's register collides with the engine's and has to say override require("src.mods.Builtins").install(self.content, data) diff --git a/src/mods/RadioContent.lua b/src/mods/RadioContent.lua new file mode 100644 index 000000000..083a13941 --- /dev/null +++ b/src/mods/RadioContent.lua @@ -0,0 +1,115 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Virtual native station records; failed entries never mutate a live dial. +local Merge=require("src.mods.Merge") +local Radio=require("src.world.Gen2Radio") +local M={} +local previous=setmetatable({},{__mode="k"}) +local pending=setmetatable({},{__mode="k"}) +local function equal(a,b,seen) + if a==b then return true end + if type(a)~="table" or type(b)~="table" then return false end + seen=seen or {};if seen[a]==b then return true end;seen[a]=b + for k,v in pairs(a)do if not equal(v,b[k],seen)then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +local function finite(value,seen) + if type(value)=="number" then assert(value==value and math.abs(value)0 then self.hold=self.hold-1;return end + local input=self.game.input + if input and (input:wasPressed("a") or input:wasPressed("b"))then self:close();return end + self.frames=self.frames+1 + local lines=self.program and self.program.lines or {} + if self.frames%100==0 and #lines>0 then self.line=self.line+2;if self.line>#lines then self.line=1 end end +end +function M:draw() + self.gear:textbox(0,12,18,4) + local lines=self.program and self.program.lines or {} + if self.hold>0 or #lines==0 then self.gear:text('"'..(self:name() or '')..'"',1,14,Radio.isLiteral(self.game.data,self.station)) + else self.gear:printBoxText(lines[self.line] or "",lines[self.line+1] or "")end +end +M.channelRecord=Radio.channelRecord +M.STATIONS=Radio.STATIONS +function M.registerInto(registry,data,owner) + local n=0 + for id,row in pairs(Radio.defaults())do registry:register(id,row,owner);n=n+1 end + return n +end +return M diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index f0da924a2..a3455893b 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -318,9 +318,10 @@ function PokegearMenu:drawModeArrow() love.graphics.setColor(1, 1, 1, 1) end -function PokegearMenu:text(str, tx, ty) +function PokegearMenu:text(str, tx, ty, literal) love.graphics.setColor(0, 0, 0, 1) - Font.draw(Strings(tostring(str or "")), tx * 8, ty * 8) + local text=tostring(str or "") + Font.draw(literal and text or Strings(text), tx * 8, ty * 8) love.graphics.setColor(1, 1, 1, 1) end @@ -427,6 +428,9 @@ function PokegearMenu:contacts() end function PokegearMenu:stationList() + if require("src.core.GameVersion").isGen2() then + return require("src.world.Gen2Radio").gearList(self.game.data,self.game.save,STATIONS,hasExpn(self.game.save)) + end local list = {} for _, st in ipairs(STATIONS) do list[#list + 1] = st end if hasExpn(self.game.save) then @@ -488,9 +492,11 @@ function PokegearMenu:update(dt) return end end + if card.id ~= "RADIO" then self._radioTuned=nil end if card.id == "RADIO" then local stations = self:stationList() local n = #stations + self.station = math.max(1, math.min(self.station, n)) if input:wasPressed("up") then -- pret: up winds knob toward higher frequencies (clamp, no wrap) if self.station < n then @@ -515,6 +521,11 @@ function PokegearMenu:update(dt) end end local st = stations[self.station] + if require("src.core.GameVersion").isGen2() and st and self._radioTuned~=st.id then + self._radioTuned=st.id + self.radioLine=1 + require("src.world.Gen2Radio").notify(self.game.data,st.id,st.knob,"pokegear") + end if st and st.flute then self.game.save.g2RadioChannel = "POKE_FLUTE" self.game.save.g2MapMusic = "MUSIC_POKE_FLUTE_CHANNEL" @@ -724,7 +735,7 @@ function PokegearMenu:drawRadio() -- Station name at (2,9) — UpdateRadioStation if st and st.name then - self:text(st.name, 2, 9) + self:text(st.name, 2, 9, st.nameLiteral) end -- Tuning knob marker (SPRITE_ANIM_OBJ_RADIO_TUNING_KNOB) diff --git a/src/world/Gen2Radio.lua b/src/world/Gen2Radio.lua new file mode 100644 index 000000000..f9eb6a1ef --- /dev/null +++ b/src/world/Gen2Radio.lua @@ -0,0 +1,103 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Station identities/MapRadio numbers are shared by both native radio UIs. +-- Program text stays in the existing native Pokegear implementation. +local Copy = require("src.mods.Merge").deepCopy +local R = {} +local literal=setmetatable({},{__mode="k"}) +local WALL = { + "OAKS_POKEMON_TALK", "POKEDEX_SHOW", "POKEMON_MUSIC", "LUCKY_CHANNEL", + "UNOWN_RADIO", "PLACES_AND_PEOPLE", "LETS_ALL_SING", "ROCKET_RADIO", +} +local NAMES = { + OAKS_POKEMON_TALK="OAK'S POKéMON TALK", POKEDEX_SHOW="POKéDEX SHOW", + POKEMON_MUSIC="POKéMON MUSIC", LUCKY_CHANNEL="LUCKY CHANNEL", + UNOWN_RADIO="UNOWN RADIO", PLACES_AND_PEOPLE="PLACES & PEOPLE", + LETS_ALL_SING="LET'S ALL SING", ROCKET_RADIO="ROCKET RADIO", + POKE_FLUTE_RADIO="POKé FLUTE", EVOLUTION_RADIO="EVOLUTION RADIO", + BUENAS_PASSWORD="BUENA'S PASSWORD", +} +local GEAR = { "OAKS_POKEMON_TALK", "POKEDEX_SHOW", "POKEMON_MUSIC", + "LUCKY_CHANNEL", "BUENAS_PASSWORD", "PLACES_AND_PEOPLE" } + +function R.defaults() + local rows={} + for id,name in pairs(NAMES)do rows[id]={name=name} end + for channel,id in ipairs(WALL)do rows[id].channel=channel end + return rows +end + +function R.records(data) + local rows=data and data.gen2RadioChannels + -- An explicitly empty mounted table is authoritative, including removals. + return type(rows)=="table" and rows or R.defaults() +end + +function R.literalState(data)return data and literal[data]end +function R.setLiteralState(data,state)if data then literal[data]=state end end +function R.isLiteral(data,id) + local state=data and literal[data] + return state and state[id]==true or state==nil and data and type(data.gen2RadioChannels)=="table" +end + +function R.channelRecord(data,channel) + local ids={} + for id,row in pairs(R.records(data))do + if type(row)=="table" and row.channel==channel then ids[#ids+1]=id end + end + table.sort(ids) + local id=ids[1] + return id and Copy(R.records(data)[id]),id +end + +function R.stationName(data,id) + local row=R.records(data)[id] + if type(row)~="table" then return nil end + return row.name or NAMES[id] or id +end + +function R.resolve(data,channel,map,tod) + if channel~=0 then + local _,id=R.channelRecord(data,channel) + if id then return id end + else + local maps=data and data.maps or {} + local pallet=maps.PALLET_TOWN + local boundary=tonumber(pallet and pallet.landmark) + or (require("src.core.GameVersion").get()=="crystal" and 0x2f or 0x2e) + if tonumber(map and map.landmark) and tonumber(map.landmark)>=boundary then + return R.stationName(data,"PLACES_AND_PEOPLE") and "PLACES_AND_PEOPLE" + end + if tod=="MORNING" or tod=="MORN" then + return R.stationName(data,"POKEDEX_SHOW") and "POKEDEX_SHOW" + end + end + return R.stationName(data,"OAKS_POKEMON_TALK") and "OAKS_POKEMON_TALK" +end + +function R.gearList(data,save,programs,expn) + local rows=R.records(data) + local list={} + for i,id in ipairs(GEAR)do + if type(rows[id])=="table" then + local entry=Copy(programs[i] or {}) + entry.id,entry.name,entry.nameLiteral=id,R.stationName(data,id),R.isLiteral(data,id) + list[#list+1]=entry + end + end + if expn and type(rows.POKE_FLUTE_RADIO)=="table" then + list[#list+1]={id="POKE_FLUTE_RADIO",name=R.stationName(data,"POKE_FLUTE_RADIO"),nameLiteral=R.isLiteral(data,"POKE_FLUTE_RADIO"), + knob=78,lines={"Playing the","POKé FLUTE...","A soothing melody.","..."},flute=true} + end + return list +end + +function R.notify(data,id,channel,source) + local name=id and R.stationName(data,id) + local Runtime=require("src.mods.Runtime") + if name and Runtime.wants("radio.channel") then + Runtime.emit("radio.channel",{station=id,channel=channel,name=name,source=source}) + end +end + +R.STATIONS=WALL +return R diff --git a/tests/engine/gen2_radio_content.lua b/tests/engine/gen2_radio_content.lua new file mode 100644 index 000000000..435a9b11f --- /dev/null +++ b/tests/engine/gen2_radio_content.lua @@ -0,0 +1,170 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Loader=require('src.mods.Loader') +local Json=require('src.link.Json') +local Radio=require('src.world.Gen2Radio') +local Gear=require('src.ui.PokegearMenu') +local Runtime=require('src.mods.Runtime') +local original=V.get() +local function size(t)local n=0;for _ in pairs(t or {})do n=n+1 end;return n end +local function load(data,source,api,format,extra) + -- The real hot-reload path reconstructs engine-seeded namespaces before + -- loading mods. Retain the mounted radio target under test. + for root in pairs(require('src.mods.Builtins').namespaceRoots())do data[root]=nil end + local files={} + if source then + files['mods/radio/manifest.json']=Json.encode({id='radio',name='Radio contract',version='1.0.0', + api=api or 2,content_format=format or 'native',entry='main.lua',affects_link=false}) + files['mods/radio/main.lua']='local mod=...;mod.exports.api=mod; '..source + end + for k,v in pairs(extra or {})do files[k]=v end + return T.sdk.loadMods(source and {'mods/radio'} or {},{data=data,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +local function fixture() + local d=T.fixtures.fresh();d.maps.PALLET_TOWN={landmark=47};return d +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + local d=fixture();local r=load(d) + T.eq(#r.errors,0,'no-mod load succeeds: '..version) + T.eq(d.gen2RadioChannels,nil,'no-mod virtual base creates no namespace') + T.eq(size(Radio.records(d)),11,'all native station identities are readable') + local gear=setmetatable({game={data=d,save={}}},{__index=Gear}) + T.eq(#gear:stationList(),6,'vanilla native tuner count retained') + T.eq(gear:stationList()[1].name,"OAK'S POKéMON TALK",'native program name retained') + T.eq(Radio.resolve(d,0,{landmark=1},'MORNING'),'POKEDEX_SHOW','morning Pokemon Channel') + T.eq(Radio.resolve(d,0,{landmark=1},'DAY'),'OAKS_POKEMON_TALK','day Pokemon Channel') + T.eq(Radio.resolve(d,0,{landmark=47},'MORNING'),'PLACES_AND_PEOPLE','imported Kanto boundary wins over time') + gear.game.save.flags={ENGINE_EXPN_CARD=true} + T.eq(#gear:stationList(),7,'EXPN keeps the native flute slot') + finish(r) +end +V.set('crystal') +for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture() + local r=load(d,[[ + local radio=mod.content.radio_channels + local row=assert(radio:get('OAKS_POKEMON_TALK'));row.name='BORROWED' + assert(radio:get('OAKS_POKEMON_TALK').name~='BORROWED') + for id,value in radio:each()do value.name='ITERATOR MUTATION' end + assert(radio:get('OAKS_POKEMON_TALK').name~='ITERATOR MUTATION') + radio:patch('OAKS_POKEMON_TALK',{name='AUTHORED TALK'}) + radio:register('PIRATE_RADIO',{channel=9,name='PIRATE RADIO'}) + radio:remove('POKEDEX_SHOW') + radio:patch('LUCKY_CHANNEL',{channel=11}) + mod.events:on("radio.channel",function(e) + mod.exports.event={station=e.station,channel=e.channel,name=e.name,source=e.source} + e.name='CALLBACK MUTATION' + end) + ]],mode[1],mode[2]) + T.eq(#r.errors,0,'real loader accepts radio contract '..mode[1]..'/'..mode[2]) + local e=r.loader.exports.radio + T.eq(d.gen2RadioChannels.OAKS_POKEMON_TALK.name,'AUTHORED TALK','merge reaches actual native station records') + T.eq(Radio.resolve(d,9),'PIRATE_RADIO','wall radio consumes a new registered station') + T.eq(Radio.resolve(d,11),'LUCKY_CHANNEL','wall radio consumes a changed channel number') + T.eq(Radio.channelRecord(d,4),nil,'previous channel number is not retained') + T.eq(Radio.resolve(d,0,{landmark=1},'MORNING'),nil,'removed morning station cannot resurrect') + local gear=setmetatable({game={data=d,save={}}},{__index=Gear}) + T.eq(#gear:stationList(),5,'native tuner consumes removal') + T.eq(gear:stationList()[1].name,'AUTHORED TALK','native tuner consumes authored name') + T.eq(gear:stationList()[1].nameLiteral,true,'authored text bypasses engine string catalog') + T.eq(Radio.isLiteral(d,'LUCKY_CHANNEL'),false,'channel-only patch retains built-in translation ownership') + Radio.notify(d,'PIRATE_RADIO',9,'map_radio') + T.eq(e.event.station,'PIRATE_RADIO','real mod event receives station identity') + T.eq(e.event.name,'PIRATE RADIO','real mod event receives authored station name') + T.eq(e.event.channel,9,'real mod event receives script dial number') + T.eq(e.event.source,'map_radio','real mod event identifies wall producer') + T.eq(Radio.stationName(d,'PIRATE_RADIO'),'PIRATE RADIO','callback mutation cannot edit content') + local borrowed=e.api.content.radio_channels:get('PIRATE_RADIO');borrowed.name='OUTSIDE' + T.eq(Radio.stationName(d,'PIRATE_RADIO'),'PIRATE RADIO','post-load get is detached') + T.raises(function()e.api.content.radio_channels:patch('PIRATE_RADIO',{name='LATE'})end,'frozen','station mutations freeze after load') + finish(r) + r=load(d) + T.eq(d.gen2RadioChannels,nil,'reload without mods removes generated namespace') + T.eq(Radio.resolve(d,2),'POKEDEX_SHOW','reload restores removed native station') + T.eq(Radio.channelRecord(d,9),nil,'reload removes mod-only station') + finish(r) +end +-- Failed entries must roll back earlier valid edits and event subscriptions. +for _,bad in ipairs({ + "r:register('DUPLICATE',{channel=1,name='bad'})", + "r:patch('LUCKY_CHANNEL',{channel=256})", + "r:patch('LUCKY_CHANNEL',{channel=0/0})", + "r:patch('LUCKY_CHANNEL',{name=123})", + "local cycle={};cycle.self=cycle;r:register('CYCLE',cycle)", + "r:register('ROCKET_RADIO',{channel=10})", +})do + local d=fixture();local r=load(d,"local r=mod.content.radio_channels;r:patch('OAKS_POKEMON_TALK',{name='SHOULD ROLLBACK'});"..bad) + T.eq(#r.errors,1,'malformed/ambiguous station fails one mod') + T.eq(Radio.stationName(d,'OAKS_POKEMON_TALK'),"OAK'S POKéMON TALK",'failed entry cannot leak prior valid edit') + T.eq(d.gen2RadioChannels,nil,'failed entry leaves no namespace') + finish(r) +end +-- External edits between hot reloads survive without retaining old mod rows. +local d=fixture();local r=load(d,[[ + mod.content.radio_channels:register('PIRATE_RADIO',{channel=9,name='MOD'}) + mod.content.radio_channels:patch('OAKS_POKEMON_TALK',{name='MOD TALK'}) +]]) +d.gen2RadioChannels.OAKS_POKEMON_TALK.name='EXTERNAL' +d.gen2RadioChannels.LUCKY_CHANNEL=nil +finish(r);r=load(d) +T.eq(Radio.stationName(d,'OAKS_POKEMON_TALK'),'EXTERNAL','external edit survives reload') +T.eq(Radio.channelRecord(d,9),nil,'external edit does not pin stale mod-only rows') +T.eq(d.gen2RadioChannels.LUCKY_CHANNEL,nil,'external removal survives reload') +T.eq(Radio.isLiteral(d,'OAKS_POKEMON_TALK'),true,'external authored name stays literal') +finish(r) +d=fixture();d.gen2RadioChannels={AUTHORED={channel=22,name='BASE'}} +local base=d.gen2RadioChannels.AUTHORED +r=load(d,[[ + local r=mod.content.radio_channels + local row={channel=23,name='REPLACEMENT'} + r:override('AUTHORED',row);row.name='LEAK' + r:patch('AUTHORED',{name=mod.DELETE}) + r:register('TEMP',{channel=24,name='TEMP'}) +]]) +T.eq(#r.errors,0,'override and optional field deletion are valid') +T.eq(d.gen2RadioChannels.AUTHORED.name,nil,'DELETE removes optional authored name') +T.eq(d.gen2RadioChannels.AUTHORED.channel,23,'override replaces original dial position') +T.eq(Radio.isLiteral(d,'AUTHORED'),false,'deleted authored name no longer claims literal ownership') +finish(r);r=load(d) +T.eq(d.gen2RadioChannels.AUTHORED,base,'unmodified authored base row identity is restored') +T.eq(d.gen2RadioChannels.AUTHORED.name,'BASE','reload restores original authored name') +T.eq(d.gen2RadioChannels.TEMP,nil,'reload removes temporary station from authored dataset') +finish(r) +-- A failed contribution must not roll back a preceding successful owner. +d=fixture() +local extras={ + ['mods/broken/manifest.json']=Json.encode({id='broken',name='Broken radio',version='1',api=2,entry='main.lua',dependencies={'radio'}}), + ['mods/broken/main.lua']=[[ + local mod=...;mod.content.radio_channels:patch('OAKS_POKEMON_TALK',{name='BROKEN'}) + mod.events:on("radio.channel",function()error('leaked failed listener')end) + mod.content.radio_channels:register('BAD',{channel=1}) + ]], +} +r=load(d,"mod.content.radio_channels:patch('OAKS_POKEMON_TALK',{name='GOOD'})",2,'native',extras) +T.eq(#r.errors,1,'only dependent broken contributor fails') +T.eq(Radio.stationName(d,'OAKS_POKEMON_TALK'),'GOOD','successful contribution survives later rollback') +local errors=#r.errors;Radio.notify(d,'OAKS_POKEMON_TALK',1,'map_radio') +T.eq(#r.errors,errors,'failed contributor listener is removed with its operations') +finish(r) +d=fixture();d.gen2RadioChannels={};r=load(d) +T.eq(size(Radio.records(d)),0,'explicit empty authored namespace stays authoritative') +T.eq(#setmetatable({game={data=d,save={}}},{__index=Gear}):stationList(),0,'empty authored tuner cannot resurrect defaults') +finish(r) +for _,version in ipairs({'red','emerald','platinum','black'})do + V.set(version);d=T.fixtures.fresh() + r=load(d,[[ + assert(mod.content.radio_channels:get('OAKS_POKEMON_TALK')==nil) + assert(mod.content.radio_channels:each()()==nil) + assert(mod.content.radio_channels:register('NOT_NATIVE',{channel=9})==nil) + ]],2,'native') + T.eq(#r.errors,0,'other target has honest unsupported contract: '..version) + T.eq(d.gen2RadioChannels,nil,'other generation creates no radio target') + T.eq(next(r.loader.content.radio_channels.ops),nil,'other generation appends no operation') + finish(r) +end +V.set(original) +T.finish('native radio content, consumers, target isolation and rollback') diff --git a/tests/engine/gen2_radio_runtime.lua b/tests/engine/gen2_radio_runtime.lua new file mode 100644 index 000000000..c791f3dbe --- /dev/null +++ b/tests/engine/gen2_radio_runtime.lua @@ -0,0 +1,88 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get();V.set('crystal') +require('src.core.ModCompat').install() +local Radio=require('src.world.Gen2Radio') +local Wall=require('src.ui.gen2.MapRadio') +local Gear=require('src.ui.PokegearMenu') +local VM=require('src.script.Gen2ScriptVM') +local Runner=require('src.script.ScriptRunner') +local Runtime=require('src.mods.Runtime') +local oldEvents=Runtime.events;local events=require('src.mods.Events').new();Runtime.events=events +local emitted={};events:on("radio.channel",function(e)emitted[#emitted+1]=e end) +local data=T.fixtures.fresh();require('src.render.Font').load(data) +data.maps.PALLET_TOWN={landmark=47} +data.gen2RadioChannels=Radio.defaults();data.gen2RadioChannels.TEST_RADIO={channel=9,name='TEST RADIO'} +local game={data=data,save={},stack=setmetatable({},{__index=require('src.core.StateStack')}),overworld={map={def={landmark=1}}}} +game.stack:init() +function game.overworld:timeOfDay()return 'MORNING'end +local function press(screen,key) + game.input={wasPressed=function(_,k)return k==key end,isDown=function()return false end} + screen:update(1/60) +end +local parent={name='test parent'};game.stack:push(parent) +for _,channel in ipairs({0,1,9})do + local completed=0 + local ir={map_scripts={scripts={probe={{'setval',channel},{'special',0,'MapRadio'},{'setval',77},{'end'}}}}} + local rows=assert(VM.compile(ir,'probe')) + local runner=Runner.new(game,game.overworld) + local before=#emitted + runner:run(rows,{lastCheck=false,onDone=function()completed=completed+1 end}) + local wall=game.stack:top() + T.eq(getmetatable(wall),Wall,'actual special opens the native wall UI') + T.check(runner:isRunning(),'native VM waits for wall radio input') + T.eq(runner.ctx.g2Var,channel,'opening preserves the script dial byte') + T.eq(runner.ctx.lastCheck,channel~=0,'opening retains the result of the preceding setval') + T.eq(#emitted,before+1,'actual tune emits once') + T.eq(emitted[#emitted].channel,channel,'event preserves actual script channel') + T.eq(emitted[#emitted].source,'map_radio','event identifies native wall special') + T.eq(wall.station,channel==0 and 'POKEDEX_SHOW' or channel==9 and 'TEST_RADIO' or 'OAKS_POKEMON_TALK','native imported-map/time resolution') + T.eq(game.save.g2Roamers,nil,'opening wall radio does not initialize unrelated roaming state') + wall:draw() + for _=1,100 do press(wall,'b')end + T.eq(game.stack:top(),wall,'hundred-frame station announcement ignores exit input') + T.eq(completed,0,'announcement cannot resume script early') + wall:draw() + local cover={name='cover'};game.stack:push(cover) + T.eq(wall:close(),false,'covered radio cannot pop another screen') + T.eq(completed,0,'covered radio cannot resume its parent') + T.eq(game.stack:top(),cover,'covered close preserves top owner') + game.stack:pop();press(wall,channel==9 and 'a' or 'b') + T.eq(game.stack:top(),parent,'closing returns to the existing parent') + T.eq(completed,1,'native script resumes exactly once') + T.eq(runner.ctx.g2Var,77,'real VM executes the instruction following the special') + T.eq(runner:isRunning(),false,'real script terminates') + wall:close();T.eq(completed,1,'repeated close cannot replay script continuation') + T.eq(#emitted,before+1,'draw and close do not emit extra tuning events') +end +game.overworld.map.def.landmark=47 +local wall=Wall.new(game,{channel=0}) +T.eq(wall.station,'PLACES_AND_PEOPLE','real loaded map definition selects Kanto') +local alternate={flags={ENGINE_EXPN_CARD=true}} +wall=Wall.new(game,{channel=9,save=alternate}) +T.eq(wall.game.save,alternate,'caller-owned save is retained') +T.eq(game.save==alternate,false,'alternate save does not replace player save') +data.gen2RadioChannels={} +local completed=0;local runner=Runner.new(game,game.overworld) +runner:run(assert(VM.compile({map_scripts={scripts={empty={{'setval',1},{'special',0,'MapRadio'},{'end'}}}}},'empty')), + {lastCheck=false,onDone=function()completed=completed+1 end}) +T.eq(game.stack:top(),parent,'empty station registry cannot strand an empty UI') +T.eq(completed,1,'unavailable station allows script to continue') +T.eq(runner.ctx.lastCheck,true,'unavailable station preserves preceding setval result') +data.gen2RadioChannels=Radio.defaults() +game.save.flags={ENGINE_RADIO_CARD=true} +local gear=Gear.new(game) +for i,card in ipairs(gear.cards)do if card.id=='RADIO' then gear.index=i end end +T.eq(gear:current().id,'RADIO','actual owned card selects native radio') +local before=#emitted +press(gear);press(gear) +T.eq(#emitted,before+1,'Pokegear emits initial tune once') +T.eq(emitted[#emitted].source,'pokegear','Pokegear identifies the other real producer') +press(gear,'up') +T.eq(#emitted,before+2,'changing native tuner emits once') +T.eq(emitted[#emitted].station,'POKEDEX_SHOW','tuner event carries station identity') +press(gear,'a');T.eq(#emitted,before+2,'advancing program text does not retune') +V.set('red');T.raises(function()Wall.new(game)end,'Gen2 host','wall radio rejects another host') +Runtime.events=oldEvents;V.set(original) +T.finish('native radio VM, station lifecycle, Pokegear input and events') From 33b8a5bec5c157a01311c15c573a342a2949237d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:00:58 +0100 Subject: [PATCH 21/67] Keep standalone radio registry visible to the coverage ratchet --- tests/engine/gen2_radio_content.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/engine/gen2_radio_content.lua b/tests/engine/gen2_radio_content.lua index 435a9b11f..a5fa1054e 100644 --- a/tests/engine/gen2_radio_content.lua +++ b/tests/engine/gen2_radio_content.lua @@ -46,7 +46,7 @@ V.set('crystal') for _,mode in ipairs({{1,'native'},{2,'native'}})do local d=fixture() local r=load(d,[[ - local radio=mod.content.radio_channels + local radio=mod.content["radio_channels"] local row=assert(radio:get('OAKS_POKEMON_TALK'));row.name='BORROWED' assert(radio:get('OAKS_POKEMON_TALK').name~='BORROWED') for id,value in radio:each()do value.name='ITERATOR MUTATION' end From a7c266fdc5cfd036519ed80ca49d9450514abaf2 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:53:10 +0100 Subject: [PATCH 22/67] Expose native Pokegear presentation and optional window-resolution UI --- docs/native-gen2-pokegear-presentation.md | 45 +++++++++++++++++ src/core/Game.lua | 9 +++- src/render/ScreenSpaceUI.lua | 54 ++++++++++++++++++++ src/ui/PokegearMenu.lua | 55 +++++++++++++++++++- tests/engine/gen2_pokegear_presentation.lua | 56 +++++++++++++++++++++ tests/engine/screen_space_ui.lua | 39 ++++++++++++++ 6 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 docs/native-gen2-pokegear-presentation.md create mode 100644 src/render/ScreenSpaceUI.lua create mode 100644 tests/engine/gen2_pokegear_presentation.lua create mode 100644 tests/engine/screen_space_ui.lua diff --git a/docs/native-gen2-pokegear-presentation.md b/docs/native-gen2-pokegear-presentation.md new file mode 100644 index 000000000..854c5c481 --- /dev/null +++ b/docs/native-gen2-pokegear-presentation.md @@ -0,0 +1,45 @@ +# Native Pokegear state and window-resolution presentation + +Actual unchanged Johto GPU inspection found a mismatch that the native +radio command/input assertions alone did not detect. The native card was +RADIO, but a source renderer asked for `card()` (or its legacy cursor), +fell back to CLOCK and painted the wrong panel. It also received the small +cartridge canvas rather than a window-resolution drawing boundary. + +The native Pokegear now exposes `card()`, `cardLabel()`, `stations()` and +`currentStation()` over its existing native selection and tuner. Station +rows carry the real native identity and knob-derived frequency. Its `radio` +view follows native program text advancement and retuning; leaving or closing +the card clears that view. This is presentation of the existing native +program text, not the complete cartridge show or song state machine. + +`drawsWidescreen()` / `drawWidescreen(width, height)` are an optional +screen-space drawing boundary. A replacement of the Pokegear's fallback +drawer opts into it; the unmodified native menu retains its original canvas. +The normal world/compositor runs first. Only the selected wide owner's small +UI drawing is omitted; native overlays above it remain on their own existing +canvas and are composited after the high-resolution owner. This works even +when a mod's `render.compose` hook owns the ordinary world composite. +Graphics state is restored around the window drawer and overlay. + +Focused synthetic tests cover native card navigation, retuning, text +advancement, borrowed-record isolation, ordinary no-mod behavior, window +owner selection, overlay ordering and graphics-state isolation. Real +unchanged-mod GPU acceptance of this correction is still pending. + +The earlier exact `e435f5ff` package passed 36 automatic checks per OpenGL +and Metal backend with actual Crystal and the unchanged Johto package. +All eight fresh OS images were inspected. Wall-radio names were upright, +but the four Pokegear images showed CLOCK instead of RADIO, so that set was +rejected as complete radio visual acceptance and was not published. Metal +deprecation overlays also remain visible at the lower edge. + +Full radio shows/audio, source phone/map facade completion, ordinary story +admission and Windows/Intel hardware GPU gameplay remain open. In particular, +the pre-existing native Snorlax special checks only EXPN; a real native VM +probe incorrectly returns true without flute music or adjacency. That story +gate requires a separate audio/ownership correction and is not fixed here. + +This standalone upstream composition has not received unchanged-mod GPU acceptance. +Original contributions by Roxas2712 are offered under the specific permission in +[LICENSE-ROXAS2712.md](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). Existing project and third-party rights remain effective. diff --git a/src/core/Game.lua b/src/core/Game.lua index 9dc07ce9d..0758a9423 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1013,6 +1013,9 @@ function Game:_draw() -- a world-bg battle keeps the map drawing under whatever it opened, so the -- world pass can run for a frame whose CLEAR is still an opaque menu's local drawFrom = Game.drawBaseInStack(self.stack, base) + local ScreenSpaceUI = require('src.render.ScreenSpaceUI') + local windowUI, windowUIIndex = ScreenSpaceUI.select(self.stack,self.stack,drawFrom) + local windowUIOverlay = false local worldDrawn = self.stack.states[drawFrom] == self.overworld -- A wide battle holds its 304px surface through every menu or prompt it -- opens. States that do not draw the wide battle composition are centred @@ -1089,7 +1092,10 @@ function Game:_draw() local ownsSurface = wideState or (native ~= nil and state == native) or Game.wantsThisSurface(state) - if state and state.draw then + if state == windowUI then + love.graphics.clear(0,0,0,0) + elseif state and state.draw then + if windowUIIndex and i > windowUIIndex then windowUIOverlay = true end -- ...and it is centred by ITS OWN size, not by the Game Boy's (see -- Game.homeOffset). classicOffset still gates it: a surface that is -- the Game Boy's centres nothing, which is every frame outside a wide @@ -1169,6 +1175,7 @@ function Game:_draw() worldZones = self.overworld:sgbWorldZones() end local viewport = Renderer:endFrame(zones, worldZones) + if windowUI then ScreenSpaceUI.draw(windowUI,Renderer,viewport,zones,windowUIOverlay) end -- Persistent tool status is screen-space UI: draw it over the completed -- render pipeline with exact playfield/margin geometry, but below mobile -- controls. It never becomes an updating game state. diff --git a/src/render/ScreenSpaceUI.lua b/src/render/ScreenSpaceUI.lua new file mode 100644 index 000000000..4ca2a5bea --- /dev/null +++ b/src/render/ScreenSpaceUI.lua @@ -0,0 +1,54 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Optional window-resolution UI. Simulation/input remain native; only an +-- explicitly advertised drawer leaves the cartridge-sized UI canvas. +local M = {} +local function visible(stack, state) + return not stack.renderVisible or stack:renderVisible(state) +end +function M.select(stack, scene, first) + for i = #scene.states, first, -1 do + local state = scene.states[i] + if visible(stack, state) and type(state.drawsWidescreen) == 'function' + and type(state.drawWidescreen) == 'function' and state:drawsWidescreen() then + return state, i + end + end +end +function M.classic(draw, renderer, width, height) + local G = love.graphics + local uw, uh = renderer:uiSize() + local p = renderer.uiPresentation + if not p then + local scale = math.max(1, math.floor(math.min(width / uw, height / uh))) + p = { x=(width-uw*scale)/2, y=(height-uh*scale)/2, scaleX=scale, scaleY=scale } + end + G.push('all') + G.origin() + G.translate(p.x+(uw-renderer.WIDTH)*p.scaleX/2, p.y+(uh-renderer.HEIGHT)*p.scaleY/2) + G.scale(p.scaleX,p.scaleY) + local ok, err = pcall(draw) + G.pop() + if not ok then error(err,0) end +end +function M.draw(owner, renderer, viewport, zones, overlay) + local G = love.graphics + G.push('all') + G.origin();G.setShader();G.setScissor();G.setColor(1,1,1,1);G.setBlendMode('alpha') + local ok, err = pcall(owner.drawWidescreen, owner, viewport.width, viewport.height) + G.pop() + if not ok then error(err,0) end + -- The classic canvas contains only states above this owner. Reapply it + -- after the high-resolution drawer so a native textbox keeps its parent. + if overlay then + local p = renderer.uiPresentation + if not p then + p = {x=viewport.gameX,y=viewport.gameY,scaleX=viewport.scale/(viewport.dpiX or 1),scaleY=viewport.scale/(viewport.dpiY or 1)} + end + G.push('all');G.origin();G.setShader();G.setColor(1,1,1,1);G.setBlendMode('alpha','premultiplied') + local overlayOK, overlayError = pcall(renderer.blitCanvas,renderer,renderer.canvas,p.scaleX,p.scaleY,zones,p.scaleX,p.scaleY, + p.x,p.y,0,0,viewport.width,viewport.height,viewport.dpiX or 1,viewport.dpiY or 1) + G.pop() + if not overlayOK then error(overlayError,0) end + end +end +return M diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index a3455893b..e6d6c8e60 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -371,6 +371,18 @@ function PokegearMenu:current() return self.cards[self.index] end +-- Source renderers observe the selected native card. Keep one cursor: +-- falling back to a separate cardIndex leaves them drawing the clock while +-- native input and callbacks are already operating the radio. +function PokegearMenu:card() + return self:current() +end + +function PokegearMenu:cardLabel(card) + card = card or self:current() + return card and Strings(card.label or card.id) or "" +end + -- The registered phone numbers, newest id last. -- -- Gen2 keeps two halves of this and the card used to read neither: which ids @@ -444,6 +456,33 @@ function PokegearMenu:stationList() return list end +-- Read-only presentation of the existing native tuner, not a second tuner +-- or a reproduction of the cartridge's complete radio show state machine. +function PokegearMenu:stations() + if not require("src.core.GameVersion").isGen2() then return {} end + local rows = self:stationList() + for _, row in ipairs(rows) do + row.station = row.id + row.frequency = ("%04.1f"):format(((row.knob or 0) + 2) / 4) + end + return rows +end + +function PokegearMenu:currentStation() + return self:stations()[self.station] +end + +function PokegearMenu:syncRadioView(station) + if not require("src.core.GameVersion").isGen2() or not station then + self.radio = nil + return + end + local view = self.radio + if not view or view.station ~= station.id then view = {}; self.radio = view end + local lines, line = station.lines or {}, self.radioLine or 1 + view.station, view.top, view.bottom = station.id, lines[line] or "", lines[line + 1] or "" +end + function PokegearMenu:update(dt) self.blink = (self.blink + dt) % 1 -- A call in progress owns the card: PokegearPhone_MakePhoneCall does not @@ -461,6 +500,7 @@ function PokegearMenu:update(dt) end local input = self.game.input if input:wasPressed("b") then + self.radio = nil Sound.play(self.game.data, "Press_AB") self.game.stack:pop() if self.onCancel then self.onCancel() end @@ -492,7 +532,7 @@ function PokegearMenu:update(dt) return end end - if card.id ~= "RADIO" then self._radioTuned=nil end + if card.id ~= "RADIO" then self._radioTuned=nil;self.radio=nil end if card.id == "RADIO" then local stations = self:stationList() local n = #stations @@ -526,6 +566,7 @@ function PokegearMenu:update(dt) self.radioLine=1 require("src.world.Gen2Radio").notify(self.game.data,st.id,st.knob,"pokegear") end + self:syncRadioView(st) if st and st.flute then self.game.save.g2RadioChannel = "POKE_FLUTE" self.game.save.g2MapMusic = "MUSIC_POKE_FLUTE_CHANNEL" @@ -935,4 +976,16 @@ function PokegearMenu:draw() love.graphics.setColor(1, 1, 1, 1) end +-- Native presentation keeps its original canvas. A replacement of this +-- optional drawer opts a skin into window-resolution composition. +local classicDraw = PokegearMenu.draw +local function drawWidescreen(self,width,height) + return require('src.render.ScreenSpaceUI').classic(function()classicDraw(self)end, + require('src.render.Renderer'),width,height) +end +PokegearMenu.drawWidescreen = drawWidescreen +function PokegearMenu:drawsWidescreen() + return self.drawWidescreen ~= drawWidescreen +end + return PokegearMenu diff --git a/tests/engine/gen2_pokegear_presentation.lua b/tests/engine/gen2_pokegear_presentation.lua new file mode 100644 index 000000000..305d80b69 --- /dev/null +++ b/tests/engine/gen2_pokegear_presentation.lua @@ -0,0 +1,56 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get();V.set('crystal') +local Gear=require('src.ui.PokegearMenu') +local Radio=require('src.world.Gen2Radio') +local data=T.fixtures.fresh();data.gen2RadioChannels=Radio.defaults() +data.gen2RadioChannels.OAKS_POKEMON_TALK.name='AUTHORED RADIO' +local stack=setmetatable({},{__index=require('src.core.StateStack')});stack:init() +local game={data=data,save={flags={ENGINE_RADIO_CARD=true}},stack=stack} +local function press(menu,key) + game.input={wasPressed=function(_,k)return k==key end,isDown=function()return false end} + menu:update(1/60) +end +-- A source renderer must observe the card reached through native input, +-- not an independent presentation cursor defaulting to CLOCK. +local menu=Gear.new(game);stack:push(menu) +T.eq(menu:card(),menu:current(),'source and native consumers share selected row') +T.eq(menu:cardLabel(),'CLOCK','source label resolves actual selected native card') +for _=1,#menu.cards do if menu:current().id~='RADIO' then press(menu,'right')end end +T.eq(menu:card().id,'RADIO','native card navigation reaches the source renderer') +local stations=menu:stations() +T.eq(stations[1].name,'AUTHORED RADIO','source tuner consumes mounted authored name') +T.eq(stations[1].station,'OAKS_POKEMON_TALK','source tuner receives stable station identity') +T.eq(stations[1].frequency,'04.5','frequency derives from existing native knob units') +T.eq(menu:currentStation().station,'OAKS_POKEMON_TALK','source selection reads native tuner cursor') +T.check(menu.radio and menu.radio.top=='PROF.OAK: Hello!','source panel sees the actual native program line') +local view=menu.radio;press(menu,'a') +T.eq(menu.radio,view,'advancing a program retains its live presentation object') +T.eq(menu.radio.top,'Wild #MON habits.','native line advancement reaches source panel') +press(menu,'up') +T.eq(menu:currentStation().station,'POKEDEX_SHOW','native retune changes source selection') +T.eq(menu.radio.station,'POKEDEX_SHOW','native retune changes the presented broadcast') +T.eq(menu.radio.top,'DJ MARY: Hi!','retune resets visible program text') +stations[1].name='LEAK';stations[1].lines[1]='LEAK' +T.eq(menu:stations()[1].name,'AUTHORED RADIO','source station rows are detached') +T.eq(menu:stations()[1].lines[1],'PROF.OAK: Hello!','source line snapshots cannot corrupt program text') +press(menu,'left');T.eq(menu.radio,nil,'leaving radio clears its active display') +press(menu,'right');T.check(menu.radio~=nil,'returning to radio restores a current program view') +press(menu,'b');T.eq(menu.radio,nil,'closing stops presenting a stale broadcast') +T.eq(stack:top(),nil,'native close retains its original stack ownership') +T.eq(menu:drawsWidescreen(),false,'unmodified native presentation stays on its original canvas') +local wide=menu.drawWidescreen +menu.drawWidescreen=function()end +T.eq(menu:drawsWidescreen(),true,'replacement window drawer opts into source presentation') +menu.drawWidescreen=wide +T.eq(menu:drawsWidescreen(),false,'removing replacement restores native composition') +data.gen2RadioChannels={};menu=Gear.new(game) +for _=1,#menu.cards do if menu:current().id~='RADIO' then press(menu,'right')end end +T.eq(#menu:stations(),0,'removed stations stay absent from source presentation') +T.eq(menu:currentStation(),nil,'empty tuner has no invented source selection') +T.eq(menu.radio,nil,'empty tuner has no fabricated broadcast') +V.set('red');menu=Gear.new(game) +T.eq(#menu:stations(),0,'other generation cannot expose native GSC stations') +V.set(original) +T.finish('native Pokegear input and source presentation agree') diff --git a/tests/engine/screen_space_ui.lua b/tests/engine/screen_space_ui.lua new file mode 100644 index 000000000..bae8bc2cc --- /dev/null +++ b/tests/engine/screen_space_ui.lua @@ -0,0 +1,39 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local UI=require('src.render.ScreenSpaceUI') +local stack=setmetatable({},{__index=require('src.core.StateStack')});stack:init() +local scene={states={}} +T.eq(UI.select(stack,scene,1),nil,'ordinary empty frame requests no window-resolution UI') +local trace={} +local parent={drawsWidescreen=function()return true end,drawWidescreen=function(_,w,h) + trace[#trace+1]='wide';T.eq(w,800,'window width reaches the advertised drawer');T.eq(h,720,'window height reaches the advertised drawer') + love.graphics.setColor(1,0,0,1) +end} +local overlay={draw=function()end} +scene.states={parent,overlay} +local owner,index=UI.select(stack,scene,1) +T.eq(owner,parent,'native overlay retains the visible wide parent') +T.eq(index,1,'wide owner position is preserved for overlay separation') +parent.drawsWidescreen=function()return false end +T.eq(UI.select(stack,scene,1),nil,'drawer alone does not opt in') +parent.drawsWidescreen=function()return true end +T.eq(UI.select(stack,scene,2),nil,'hidden below draw boundary is not resurrected') +local renderer={canvas={},WIDTH=160,HEIGHT=144,uiPresentation={x=0,y=0,scaleX=5,scaleY=5}} +function renderer:uiSize()return 160,144 end +function renderer:blitCanvas(canvas,sx,sy) + trace[#trace+1]='overlay';T.eq(canvas,self.canvas,'overlay reuses native canvas');T.eq(sx,5,'native overlay horizontal scale');T.eq(sy,5,'native overlay vertical scale') +end +local viewport={width=800,height=720,gameX=0,gameY=0,scale=5,dpiX=1,dpiY=1} +love.graphics.setColor(.2,.3,.4,1) +UI.draw(parent,renderer,viewport,nil,true) +T.same(trace,{'wide','overlay'},'native prompt is composited after its window-resolution parent') +local r,g,b=love.graphics.getColor();T.eq(r,.2,'drawer graphics state restored');T.eq(g,.3,'drawer cannot leak color');T.eq(b,.4,'overlay graphics state restored') +trace={};UI.draw(parent,renderer,viewport,nil,false) +T.same(trace,{'wide'},'frame without overlay does not duplicate the cartridge canvas') +parent.drawWidescreen=function()love.graphics.setColor(0,0,0,1);error('deliberate wide drawer failure')end +T.raises(function()UI.draw(parent,renderer,viewport,nil,false)end,'deliberate wide drawer failure','drawer errors remain observable') +T.eq(select(1,love.graphics.getColor()),.2,'failed drawer still restores graphics state') +local drew=0;UI.classic(function()drew=drew+1 end,renderer,800,720) +T.eq(drew,1,'classic fallback executes native draw exactly once') +T.finish('screen-space UI owner, native overlay ordering and graphics isolation') From fd332722acba0edad6a2d9deb13450d9a50e3ee9 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:56:13 +0100 Subject: [PATCH 23/67] compat(M1): defer to native providers and avoid failed-import sentinels --- src/core/ModCompat.lua | 19 ++++++++++++++-- src/mods/Gen1Compat.lua | 13 ++++++++++- tests/engine/compat_module_aliases.lua | 30 +++++++++++++++++++++----- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index 31a1015a2..2d4109584 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -137,9 +137,23 @@ function ModCompat.install() if installed then return false end local loaders = package.loaders or package.searchers if type(loaders) ~= "table" then return false end - loaders[#loaders + 1] = function(name) + local searcher + searcher = function(name) local missing = Compatibility.unavailable(name) - if missing then return function() error(missing, 0) end end + if missing then + -- A later host/plugin provider still outranks an unsupported descriptor. + local following = false + for _, candidate in ipairs(loaders) do + if candidate == searcher then following = true + elseif following then + local provider, origin = candidate(name) + if type(provider) == "function" then return provider, origin end + end + end + -- Raise before require installs its in-progress sentinel. Retrying the + -- import must retain the diagnostic instead of reporting a fake loop. + error(missing, 0) + end if not ModCompat.ALIASES[name] then -- the standard "not found" contract: a string the require error joins return "\n\tno mod-compat alias for '" .. tostring(name) .. "'" @@ -153,6 +167,7 @@ function ModCompat.install() return value end end + loaders[#loaders + 1] = searcher installed = true return true end diff --git a/src/mods/Gen1Compat.lua b/src/mods/Gen1Compat.lua index 9004bdc62..fd427e7db 100644 --- a/src/mods/Gen1Compat.lua +++ b/src/mods/Gen1Compat.lua @@ -13,9 +13,20 @@ C.UNAVAILABLE = { ['src.core.game3.pokemon'] = { 'mystery_dungeon_follower' }, ['src.core.game3.player'] = { 'mystery_dungeon_follower' }, } +-- Concrete host modules and registered providers always take precedence. +function C.hasProvider(name) + if type(name) ~= 'string' then return false end + if type(package.loaded[name]) == 'table' or type(package.preload[name]) == 'function' then return true end + if package.searchpath then + if package.searchpath(name, package.path or '') or package.searchpath(name, package.cpath or '') then return true end + end + local path = name:gsub('%.', '/') .. '.lua' + return love and love.filesystem and love.filesystem.getInfo + and love.filesystem.getInfo(path, 'file') ~= nil or false +end function C.unavailable(name) local consumers = C.UNAVAILABLE[name] - if not consumers then return nil end + if not consumers or C.hasProvider(name) then return nil end return ('Compatibility module %s is unavailable in this engine; affected mods: %s') :format(name, table.concat(consumers, ', ')) end diff --git a/tests/engine/compat_module_aliases.lua b/tests/engine/compat_module_aliases.lua index b04529a8e..583ba2234 100644 --- a/tests/engine/compat_module_aliases.lua +++ b/tests/engine/compat_module_aliases.lua @@ -10,10 +10,30 @@ else end local names=require('src.mods.Gen1Compat') for name in pairs(names.UNAVAILABLE)do - local ok,reason=pcall(require,name) - T.check(not ok,'unsupported import fails '..name) - T.check(tostring(reason):find(name,1,true)~=nil,'failure identifies module '..name) - T.check(tostring(reason):find('mystery_dungeon_follower',1,true)~=nil,'failure identifies affected consumer '..name) - T.check(not tostring(reason):find('no file ',1,true),'failure does not masquerade as a filesystem search '..name) + if names.hasProvider(name)then + local ok,module=pcall(require,name) + T.check(ok,'concrete host provider loads '..name) + T.eq(type(module),'table','concrete host provider remains a module '..name) + T.eq(names.unavailable(name),nil,'existing provider is not marked unavailable '..name) + T.eq(require(name),module,'existing provider retains require identity '..name) + T.check(names.hasProvider(name),'provider remains available '..name) + else + local ok,reason=pcall(require,name) + T.check(not ok,'unsupported import fails '..name) + T.check(tostring(reason):find(name,1,true)~=nil,'failure identifies module '..name) + T.check(tostring(reason):find('mystery_dungeon_follower',1,true)~=nil,'failure identifies affected consumer '..name) + T.check(not tostring(reason):find('no file ',1,true),'failure does not masquerade as a filesystem search '..name) + local again,detail=pcall(require,name) + T.check(not again and reason==detail,'repeat import retains the exact diagnostic '..name) + end end + +local providers=package.loaders or package.searchers +local provided={marker='native-late-provider'} +local late=function(name)if name=='src.import.pmd.Sprite'then return function()return provided end end end +providers[#providers+1]=late +T.eq(require('src.import.pmd.Sprite'),provided,'late concrete provider outranks the missing-module descriptor') +T.check(names.hasProvider('src.import.pmd.Sprite'),'loaded provider is recognized') +T.eq(names.unavailable('src.import.pmd.Sprite'),nil,'loaded provider has no missing-module diagnostic') +table.remove(providers);package.loaded['src.import.pmd.Sprite']=nil T.finish() From 2057dd6c645fca2c31aad92a0e6aa10f9194731b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:21:40 +0100 Subject: [PATCH 24/67] Connect native Gen2 radio music and correct Snorlax admission --- docs/native-gen2-radio-audio.md | 34 ++++++++ src/script/Gen2Commands.lua | 19 ++--- src/ui/Gen2MapRadio.lua | 5 ++ src/ui/PokegearMenu.lua | 24 ++++-- src/world/Gen2RadioAudio.lua | 52 ++++++++++++ tests/engine/gen2_radio_audio.lua | 127 ++++++++++++++++++++++++++++++ 6 files changed, 244 insertions(+), 17 deletions(-) create mode 100644 docs/native-gen2-radio-audio.md create mode 100644 src/world/Gen2RadioAudio.lua create mode 100644 tests/engine/gen2_radio_audio.lua diff --git a/docs/native-gen2-radio-audio.md b/docs/native-gen2-radio-audio.md new file mode 100644 index 000000000..c02ac9859 --- /dev/null +++ b/docs/native-gen2-radio-audio.md @@ -0,0 +1,34 @@ +# Native Gen2 radio music and Snorlax admission + +The native tuner and wall-radio script now start the imported channel song +through `src.core.Music`. Retuning starts the new station once; advancing +text or drawing never restarts it. Leaving the card or removing its screen +transfers the actual playing song to the native map owner, so battle music +can restore it. A new map replaces that override normally. Unavailable or +failed channel audio produces silence and restores map music on exit; it +cannot accidentally leave a previously selected flute song active. + +The EXPN Card still controls availability of the flute station. The native +`SnorlaxAwake` special now checks actual flute playback and the player's +local map cell against the five cartridge proximity cells. Owning EXPN, +stale `g2RadioChannel`/`g2MapMusic` fields and rendering coordinates cannot +substitute for those conditions. A music selection hook replacing the flute +is respected and does not manufacture flute admission. + +`tests/engine/gen2_radio_audio.lua` exercises actual Music with a fake audio +device, native Pokegear input/StateStack and compiled Gen2 scripts through +ScriptRunner: 167 checks across Gold, Silver and Crystal. It covers all five +cells, wrong-cell refusal, real wall-radio suspension/return, retuning, +failed audio, close, battle restoration, map replacement, external music +ownership, mod-selected audio and generation isolation. + +This change covers station entry/exit and the native story gate. Complete +ROM-driven show text, timing, schedules, intermediate jingles and music-show +choices are still open. Device-free tests do not establish audible hardware +playback, original story-trigger admission, unchanged-mod GPU acceptance or +a complete playthrough. Exact-source runtime/GPU qualification is required +before inclusion in the existing preview. + +Original implementation by Roxas2712; existing upstream/third-party credits +and licenses remain applicable. See `NOTICE.md` and +`LICENSE-ROXAS2712.md`. diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index 9f870bf0c..962363f14 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -4802,19 +4802,14 @@ function Commands.g2_magnet_train(ctx) end -- `special SnorlaxAwake` ($5F). ROM checks wMapMusic == MUSIC_POKE_FLUTE_CHANNEL. --- That channel is only unlocked with the EXPN Card from Lavender Radio Tower --- (ENGINE_EXPN_CARD / event flag row $03). Gen2 has no usable Poké Flute item --- in normal play; matching ITEM_056 as "flute" was a false positive. +-- The EXPN Card unlocks the tuner; owning it alone does not wake Snorlax. +-- Admission reads actual playback and the native local cell, not stale save +-- hints or globally rebased rendering coordinates. function Commands.g2_snorlax_awake(ctx) - local save = ctx.save - local flags = save.flags or {} - local Gen2Flags = require("src.script.Gen2Flags") - local expnKey = Gen2Flags.engineFlag(3) -- EVENT_GOT_EXPN_CARD - local hasExpn = flags[expnKey] == true - or flags.EVENT_GOT_EXPN_CARD == true - or flags.ENGINE_EXPN_CARD == true - ctx.lastCheck = hasExpn and true or false - ctx.g2Var = hasExpn and 1 or 0 + local awake = require("src.world.Gen2RadioAudio").snorlaxAwake( + ctx.overworld and ctx.overworld.player) + ctx.lastCheck = awake + ctx.g2Var = awake and 1 or 0 end -- --------------------------------------------------------------------------- diff --git a/src/ui/Gen2MapRadio.lua b/src/ui/Gen2MapRadio.lua index e29a96524..f93655920 100644 --- a/src/ui/Gen2MapRadio.lua +++ b/src/ui/Gen2MapRadio.lua @@ -20,9 +20,14 @@ function M.new(game,opts) -- initialize Pokegear cards, roaming encounters or its portrait sheets. self.gear=setmetatable({game=game},{__index=Gear}) for _,row in ipairs(self.gear:stationList())do if row.id==self.station then self.program=row;break end end + if self.station then self.music=require("src.world.Gen2RadioAudio").tune(game,self.station)end Radio.notify(game.data,self.station,channel,"map_radio") return self end +function M:exit() + local playing=self.music;self.music=nil + require("src.world.Gen2RadioAudio").finish(self.game,playing) +end function M:name()return self.station and Radio.stationName(self.game.data,self.station)end function M:resolveStation(channel) local id=Radio.resolve(self.game.data,channel,self.map,self.tod) diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index e6d6c8e60..500e7ab63 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -483,6 +483,16 @@ function PokegearMenu:syncRadioView(station) view.station, view.top, view.bottom = station.id, lines[line] or "", lines[line + 1] or "" end +function PokegearMenu:exitRadioMusic() + local playing = self._radioMusic + self._radioMusic = nil + require("src.world.Gen2RadioAudio").finish(self.game, playing) +end + +function PokegearMenu:exit() + self:exitRadioMusic() +end + function PokegearMenu:update(dt) self.blink = (self.blink + dt) % 1 -- A call in progress owns the card: PokegearPhone_MakePhoneCall does not @@ -532,7 +542,10 @@ function PokegearMenu:update(dt) return end end - if card.id ~= "RADIO" then self._radioTuned=nil;self.radio=nil end + if card.id ~= "RADIO" then + self:exitRadioMusic() + self._radioTuned=nil;self.radio=nil + end if card.id == "RADIO" then local stations = self:stationList() local n = #stations @@ -564,13 +577,14 @@ function PokegearMenu:update(dt) if require("src.core.GameVersion").isGen2() and st and self._radioTuned~=st.id then self._radioTuned=st.id self.radioLine=1 + self._radioMusic=require("src.world.Gen2RadioAudio").tune(self.game,st.id) require("src.world.Gen2Radio").notify(self.game.data,st.id,st.knob,"pokegear") end - self:syncRadioView(st) - if st and st.flute then - self.game.save.g2RadioChannel = "POKE_FLUTE" - self.game.save.g2MapMusic = "MUSIC_POKE_FLUTE_CHANNEL" + if not st and self._radioTuned then + self._radioTuned=nil + self._radioMusic=require("src.world.Gen2RadioAudio").tune(self.game,nil) end + self:syncRadioView(st) elseif card.id == "PHONE" then local list = self:contacts() if self.phoneSubmenu then diff --git a/src/world/Gen2RadioAudio.lua b/src/world/Gen2RadioAudio.lua new file mode 100644 index 000000000..0327f824c --- /dev/null +++ b/src/world/Gen2RadioAudio.lua @@ -0,0 +1,52 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native station playback owns the map cue after leaving the radio. This +-- covers channel entry/exit; cartridge show-driven song changes are separate. +local Music = require("src.core.Music") +local M = {} +local SONGS = { + OAKS_POKEMON_TALK = "Music_ProfOaksPokemonTalk", + POKEDEX_SHOW = "Music_PokemonCenter", + POKEMON_MUSIC = "Music_TitleScreen", + LUCKY_CHANNEL = "Music_GameCorner", + PLACES_AND_PEOPLE = "Music_ViridianCity", + LETS_ALL_SING = "Music_Bicycle", + ROCKET_RADIO = "Music_RocketTheme", + POKE_FLUTE_RADIO = "Music_PokeFluteChannel", + UNOWN_RADIO = "Music_RuinsOfAlphRadio", + EVOLUTION_RADIO = "Music_LakeOfRageRocketRadio", + BUENAS_PASSWORD = "Music_BuenasPassword", +} +M.FLUTE = SONGS.POKE_FLUTE_RADIO + +function M.tune(game, station) + if not require("src.core.GameVersion").isGen2() then return nil end + local song = SONGS[station] + local songs = game.data and game.data.audio and game.data.audio.songs + Music.stop() + if song and songs and songs[song] then + Music.play(game.data, song) + -- Observe the actual selected cue: an audio hook can replace/silence it, + -- and a missing or failed Source must never manufacture flute playback. + local current = Music.current() + if current then return { song = current } end + end + return { restore = true } +end + +function M.finish(game, playing) + if not playing then return end + if playing.restore and Music.current() == nil then + Music.restoreMap(game.data) + elseif Music.current() == playing.song then + Music.setMapSong(playing.song) + end +end + +function M.snorlaxAwake(player) + if Music.current() ~= M.FLUTE or not player then return false end + local x, y = player.cellX, player.cellY + return (x == 33 and y == 8) or (x == 34 and y == 10) + or (x == 35 and y == 10) or (x == 36 and (y == 8 or y == 9)) +end + +return M diff --git a/tests/engine/gen2_radio_audio.lua b/tests/engine/gen2_radio_audio.lua new file mode 100644 index 000000000..d0626b092 --- /dev/null +++ b/tests/engine/gen2_radio_audio.lua @@ -0,0 +1,127 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +-- Replace only the device. Actual Music, native input, StateStack, VM and +-- ScriptRunner own every transition below. +local oldAudio=love.audio +local starts=0 +love.audio={newSource=function(file) + local s={file=file,playing=false} + function s:play()self.playing=true;starts=starts+1 end + function s:stop()self.playing=false end + function s:pause()self.playing=false end + function s:isPlaying()return self.playing end + function s:setLooping()end + function s:setVolume()end + function s:setFilter()end + return s +end} +local Music=require('src.core.Music') +local Radio=require('src.world.Gen2Radio') +local Audio=require('src.world.Gen2RadioAudio') +local Gear=require('src.ui.PokegearMenu') +local VM=require('src.script.Gen2ScriptVM') +local Runner=require('src.script.ScriptRunner') +local Stack=require('src.core.StateStack') +local function gameFor() + local data=T.fixtures.fresh();data.gen2RadioChannels=Radio.defaults() + data.audio={songs={},mapSongs={A='MAP_A',B='MAP_B'},battle={wild='BATTLE'}} + for _,id in ipairs({'MAP_A','MAP_B','BATTLE','ALT','Music_PokeFluteChannel', + 'Music_ProfOaksPokemonTalk','Music_PokemonCenter','Music_RocketTheme'})do + data.audio.songs[id]={file=id..'.ogg'} + end + local stack=setmetatable({},{__index=Stack});stack:init();stack:push({name='parent'}) + return {data=data,save={flags={ENGINE_RADIO_CARD=true,ENGINE_EXPN_CARD=true}}, + stack=stack,overworld={player={cellX=33,cellY=8},map={def={landmark=1}}}} +end +local function press(game,screen,key) + game.input={wasPressed=function(_,k)return k==key end,isDown=function()return false end} + screen:update(1/60) +end +local function open(game) + local menu=Gear.new(game);game.stack:push(menu) + for _=1,#menu.cards do if menu:current().id~='RADIO' then press(game,menu,'right')end end + return menu +end +local function flute(game,menu) + for _=1,#menu:stationList()do + if not (menu:currentStation() and menu:currentStation().flute)then press(game,menu,'up')end + end +end +local function gate(game,want,label) + local runner=Runner.new(game,game.overworld) + runner:run(assert(VM.compile({map_scripts={scripts={probe={{'special',95,'SnorlaxAwake'},{'end'}}}}},'probe'))) + T.eq(runner.ctx.g2Var,want and 1 or 0,label..' native byte') + T.eq(runner.ctx.lastCheck,want,label..' native branch') + T.eq(runner:isRunning(),false,label..' VM terminates') +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);Music.reload();local game=gameFor() + Music.playMap(game.data,'A',false,false) + gate(game,false,version..' EXPN without music refuses') + game.save.g2RadioChannel='POKE_FLUTE';game.save.g2MapMusic='MUSIC_POKE_FLUTE_CHANNEL' + gate(game,false,version..' stale save hints refuse') + local menu=open(game) + T.eq(Music.current(),'Music_ProfOaksPokemonTalk',version..' real native tuner starts its station') + local count=starts;press(game,menu);press(game,menu,'a') + T.eq(starts,count,version..' idle/text advancement does not restart audio') + flute(game,menu) + T.eq(Music.current(),Audio.FLUTE,version..' tuner plays actual flute Source') + T.eq(Music.mapSong(),'MAP_A',version..' open tuner retains previous map owner') + for _,cell in ipairs({{33,8},{34,10},{35,10},{36,8},{36,9}})do + game.overworld.player.cellX,game.overworld.player.cellY=cell[1],cell[2] + gate(game,true,version..' each native adjacent cell') + end + game.overworld.player.cellX,game.overworld.player.cellY=0,0 + game.overworld.player.x,game.overworld.player.y=33,8 + gate(game,false,version..' render coordinates cannot bypass local proximity') + game.overworld.player.cellX,game.overworld.player.cellY=33,8 + press(game,menu,'b') + T.eq(Music.mapSong(),Audio.FLUTE,version..' normal close transfers radio ownership') + T.eq(Music.current(),Audio.FLUTE,version..' normal close keeps flute playing') + Music.playBattle(game.data,'wild');gate(game,false,version..' battle music cannot wake') + Music.restoreMap(game.data);gate(game,true,version..' battle restoration retains tuned flute') + Music.playMap(game.data,'B',false,false) + T.eq(Music.mapSong(),'MAP_B',version..' next map replaces radio owner') + gate(game,false,version..' next map cannot retain stale flute admission') + menu=open(game);flute(game,menu);press(game,menu,'down') + T.eq(Music.current(),nil,version..' missing channel audio cannot leave flute sounding') + gate(game,false,version..' failed retune cannot keep flute admission') + press(game,menu,'b');T.eq(Music.current(),'MAP_B',version..' failed station restores map on exit') + game.save.flags.ENGINE_EXPN_CARD=nil;menu=open(game) + for _,station in ipairs(menu:stationList())do T.eq(station.flute,nil,version..' flute stays locked without EXPN')end + press(game,menu,'b') +end +V.set('crystal');Music.reload();local game=gameFor();Music.playMap(game.data,'A') +local menu=open(game);flute(game,menu);Music.play(game.data,'ALT');press(game,menu,'b') +T.eq(Music.current(),'ALT','external playback is not replaced when closing covered owner') +T.eq(Music.mapSong(),'MAP_A','external owner prevents stale flute transfer') +-- A mod's music selection hook remains authoritative and does not become a +-- forged flute song just because the dial points at that channel. +local Runtime=require('src.mods.Runtime');local oldHooks=Runtime.hooks +Runtime.hooks=require('src.mods.Hooks').new() +Runtime.hooks:wrap('music.select',function(nextSong,song,ctx) + local selected=nextSong(song,ctx);return selected==Audio.FLUTE and 'ALT' or selected +end,nil,'radio-audio-test') +menu=open(game);flute(game,menu) +T.eq(Music.current(),'ALT','station tuning respects actual hook-selected audio') +gate(game,false,'hook replacement is not flute playback') +press(game,menu,'b');T.eq(Music.mapSong(),'ALT','actual selected song transfers to map owner') +Runtime.hooks=oldHooks +Music.playMap(game.data,'A') +local runner=Runner.new(game,game.overworld) +runner:run(assert(VM.compile({map_scripts={scripts={wall={{'setval',8},{'special',40,'MapRadio'},{'setval',77},{'end'}}}}},'wall'))) +local wall=game.stack:top() +T.eq(Music.current(),'Music_RocketTheme','native imported wall-radio special plays its channel') +T.eq(runner:isRunning(),true,'wall music does not bypass native announcement wait') +for _=1,100 do press(game,wall)end +press(game,wall,'b') +T.eq(runner.ctx.g2Var,77,'wall close resumes native script after transferring music') +T.eq(Music.mapSong(),'Music_RocketTheme','wall close transfers actual channel music') +T.eq(Music.current(),'Music_RocketTheme','wall song continues after close') +local current=Music.current();V.set('red') +T.eq(Audio.tune(game,'POKE_FLUTE_RADIO'),nil,'other generations reject native station tuning') +T.eq(Music.current(),current,'generation rejection leaves current audio intact') +Music.reload();love.audio=oldAudio;V.set(original) +T.finish('native radio audio ownership and actual Snorlax VM admission') From 746511478bd0977997abb69a99d053079f45d47b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:24:01 +0100 Subject: [PATCH 25/67] Include native map-cue accessors for standalone radio ownership --- src/core/Music.lua | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/core/Music.lua b/src/core/Music.lua index e5b95baf6..01b2ef2ad 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -380,6 +380,16 @@ function Music.current() return state.current end +-- Original contribution by Roxas2712; see the offered PR's contribution +-- permission. Radio ownership changes the restorable cue, not playback. +function Music.mapSong() + return state.mapSong +end + +function Music.setMapSong(song) + state.mapSong = song +end + function Music.stop() local previous = state.current stopSource(state.source) From eca35240aaf6fb794dfee4cb879fa3e59b3eb1a8 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:25:26 +0100 Subject: [PATCH 26/67] Link upstream radio contribution permission explicitly --- docs/native-gen2-radio-audio.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/native-gen2-radio-audio.md b/docs/native-gen2-radio-audio.md index c02ac9859..e494dd9dd 100644 --- a/docs/native-gen2-radio-audio.md +++ b/docs/native-gen2-radio-audio.md @@ -30,5 +30,5 @@ a complete playthrough. Exact-source runtime/GPU qualification is required before inclusion in the existing preview. Original implementation by Roxas2712; existing upstream/third-party credits -and licenses remain applicable. See `NOTICE.md` and -`LICENSE-ROXAS2712.md`. +and licenses remain applicable. Specific contribution permission and required credits: +. From 0ef4570098dd77ef0c4b6467ba2c72ea4229af6a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:18:28 +0100 Subject: [PATCH 27/67] Retain native Gen2 phone identities and restore Crystal Buena contact --- docs/native-gen2-phone-identities.md | 43 ++++++ src/import/Gen2PhoneIdentities.lua | 183 +++++++++++++++++++++++++ src/import/RomExtractorGen2.lua | 49 ++++--- src/import/RomImporter.lua | 2 +- tests/engine/gen2_phone_identities.lua | 111 +++++++++++++++ 5 files changed, 368 insertions(+), 20 deletions(-) create mode 100644 docs/native-gen2-phone-identities.md create mode 100644 src/import/Gen2PhoneIdentities.lua create mode 100644 tests/engine/gen2_phone_identities.lua diff --git a/docs/native-gen2-phone-identities.md b/docs/native-gen2-phone-identities.md new file mode 100644 index 000000000..d0e3f3ce5 --- /dev/null +++ b/docs/native-gen2-phone-identities.md @@ -0,0 +1,43 @@ +# Native Gen2 phone identities and Buena import + +The importer used a Gold-sized four-name list for all cartridges. Actual +Crystal contact37 uses non-trainer identity5, so Buena and both of her phone +script pointers were discarded. Read the name range referenced by the actual +native contact rows instead. Native outgoing/incoming pointers and masks stay +in their original directions. Empty and reserved rows remain uncallable. + +Each non-trainer row now preserves `contactNumber` independently of `number`, +which remains the native **map number**. `map_scripts.phoneInfo` carries the +full reserved table size, actual rows read and imported non-trainer names. +Even a completely empty book retains its reserved bound. + +Native constants also retain explicit phone-contact and trainer class/member +orders. These are constant identifiers from reference535a5ec0, not localized +display-name guesses or ROM dialogue. Existing authored identity metadata is +authoritative; explicit manifest metadata supports alternative cartridges. +A partial authored identity set does not silently inherit unrelated retail +roster rows. Retail fallback is limited to Gold/Silver/Crystal. Legacy +`PHONE_OAK` is retained as a compatibility spelling for slot2; the actual +imported display name remains authoritative. + +The `gen2-phone-identities-v1` marker rebuilds only stock G/S/C imports once. +Existing saves are not modified by this change. Other edition cache markers +are unchanged. + +Validation:138 synthetic checks exercise both native directions through +Gen2ScriptVM/ScriptRunner, Buena's actual name consumer, reserved/empty rows, +separate map/contact numbers, copied metadata, authored precedence, generation +isolation and the real cache-readiness gate. A read-only actual Crystal table +probe changes32 to33 callable rows (28 trainer /5 non-trainer),38 reserved +rows and62 to64 queued phone scripts; all trainer members resolve through +explicit metadata. The original ROM is unchanged. This is table extraction, +not full-import, GPU, story-call or complete phone-facade acceptance. + +The semantic `phone_contacts` registry writer, stable added-contact allocation, +rollback/reload and the remaining legacy Phone facade still need implementation. +These fields are prerequisites for connecting those APIs to native consumers; +the native numeric saved phone book remains authoritative. + +Original implementation by Roxas2712. Existing upstream and third-party +licenses/credits remain applicable; see the specific contribution permission and credits: +. diff --git a/src/import/Gen2PhoneIdentities.lua b/src/import/Gen2PhoneIdentities.lua new file mode 100644 index 000000000..2ac555308 --- /dev/null +++ b/src/import/Gen2PhoneIdentities.lua @@ -0,0 +1,183 @@ +-- Original selection/copy logic by Roxas2712. See offered PR contribution permission. +-- Compatibility constant identifiers and numeric orders only; no ROM bytes, +-- artwork, dialogue or scripts. Reference: Gen1Recomp535a5ec0 manifest constants. +-- Existing engine/third-party credits and rights remain unchanged. +local M = {} +local gold = { + ["phoneContactOrder"] = {"PHONE_00", "PHONE_MOM", "PHONE_OAK", "PHONE_BILL", "PHONE_ELM", "PHONE_SCHOOLBOY_JACK", "PHONE_POKEFAN_BEVERLY", "PHONE_SAILOR_HUEY", "PHONE_UNUSED", "PHONE_UNUSED", "PHONE_UNUSED", "PHONE_COOLTRAINERM_GAVEN", "PHONE_COOLTRAINERF_BETH", "PHONE_BIRDKEEPER_JOSE", "PHONE_COOLTRAINERF_REENA", "PHONE_YOUNGSTER_JOEY", "PHONE_BUG_CATCHER_WADE", "PHONE_FISHER_RALPH", "PHONE_PICNICKER_LIZ", "PHONE_HIKER_ANTHONY", "PHONE_CAMPER_TODD", "PHONE_PICNICKER_GINA", "PHONE_JUGGLER_IRWIN", "PHONE_BUG_CATCHER_ARNIE", "PHONE_SCHOOLBOY_ALAN", "PHONE_UNUSED", "PHONE_LASS_DANA", "PHONE_SCHOOLBOY_CHAD", "PHONE_POKEFANM_DEREK", "PHONE_FISHER_CHRIS", "PHONE_POKEMANIAC_BRENT", "PHONE_PICNICKER_TIFFANY", "PHONE_BIRDKEEPER_VANCE", "PHONE_FISHER_WILTON", "PHONE_BLACKBELT_KENJI", "PHONE_HIKER_PARRY", "PHONE_PICNICKER_ERIN"}, + ["trainerClassMembers"] = { + ["BEAUTY"] = {"VICTORIA", "SAMANTHA", "JULIE", "JACLYN", "BRENDA", "CASSIE", "CAROLINE", "CARLENE", "JESSICA", "RACHAEL", "ANGELICA", "KENDRA", "VERONICA", "JULIA", "THERESA", "VALERIE"}, + ["BIKER"] = {"KAZU1", "KAZU2", "DWAYNE", "HARRIS", "ZEKE", "CHARLES", "RILEY", "JOEL", "GLENN"}, + ["BIRD_KEEPER"] = {"ROD", "ABE", "BRYAN", "THEO", "TOBY", "DENIS", "VANCE1", "HANK", "ROY", "BORIS", "BOB", "JOSE1", "PETER", "JOSE2", "PERRY", "BRET", "JOSE3", "VANCE2", "VANCE3"}, + ["BLACKBELT_T"] = {"KENJI1", "YOSHI", "KENJI2", "LAO", "NOB", "KIYO", "LUNG", "KENJI3", "WAI"}, + ["BLAINE"] = {"BLAINE1"}, + ["BLUE"] = {"BLUE1"}, + ["BOARDER"] = {"RONALD", "BRAD", "DOUGLAS"}, + ["BROCK"] = {"BROCK1"}, + ["BRUNO"] = {"BRUNO1"}, + ["BUGSY"] = {"BUGSY1"}, + ["BUG_CATCHER"] = {"DON", "ROB", "ED", "WADE1", "BENNY", "AL", "JOSH", "ARNIE1", "KEN", "WADE2", "WADE3", "DOUG", "ARNIE2", "ARNIE3"}, + ["BURGLAR"] = {"DUNCAN", "EDDIE", "COREY"}, + ["CAL"] = {"CAL1", "CAL2", "CAL3"}, + ["CAMPER"] = {"ROLAND", "TODD1", "IVAN", "ELLIOT", "BARRY", "LLOYD", "DEAN", "SID", "HARVEY", "DALE", "TED", "TODD2", "TODD3", "THOMAS", "LEROY", "DAVID", "JOHN", "JERRY", "SPENCER"}, + ["CHAMPION"] = {"LANCE"}, + ["CHUCK"] = {"CHUCK1"}, + ["CLAIR"] = {"CLAIR1"}, + ["COOLTRAINERF"] = {"GWEN", "LOIS", "FRAN", "LOLA", "KATE", "IRENE", "KELLY", "JOYCE", "BETH1", "REENA1", "MEGAN", "BETH2", "CAROL", "QUINN", "EMMA", "CYBIL", "JENN", "BETH3", "REENA2", "REENA3"}, + ["COOLTRAINERM"] = {"NICK", "AARON", "PAUL", "CODY", "MIKE", "GAVEN1", "GAVEN2", "RYAN", "JAKE", "GAVEN3", "BLAKE", "BRIAN", "ERICK", "ANDY", "TYLER", "SEAN", "KEVIN", "STEVE", "ALLEN"}, + ["ERIKA"] = {"ERIKA1"}, + ["EXECUTIVEF"] = {"EXECUTIVEF_1", "EXECUTIVEF_2"}, + ["EXECUTIVEM"] = {"EXECUTIVEM_1", "EXECUTIVEM_2", "EXECUTIVEM_3", "EXECUTIVEM_4"}, + ["FALKNER"] = {"FALKNER1"}, + ["FIREBREATHER"] = {"OTIS", "DICK", "NED", "BURT", "BILL", "WALT", "RAY", "LYLE"}, + ["FISHER"] = {"JUSTIN", "RALPH1", "ARNOLD", "KYLE", "HENRY", "MARVIN", "CHRIS1", "ANDRE", "RAYMOND", "WILTON1", "EDGAR", "JONAH", "MARTIN", "STEPHEN", "BARNEY", "RALPH2", "RALPH3", "CHRIS2", "CHRIS3", "WILTON2", "SCOTT", "WILTON3"}, + ["GENTLEMAN"] = {"PRESTON", "EDWARD", "GREGORY", "VIRGIL", "ALFRED"}, + ["GRUNTF"] = {"GRUNTF_1", "GRUNTF_2", "GRUNTF_3", "GRUNTF_4", "GRUNTF_5"}, + ["GRUNTM"] = {"GRUNTM_1", "GRUNTM_2", "GRUNTM_3", "GRUNTM_4", "GRUNTM_5", "GRUNTM_6", "GRUNTM_7", "GRUNTM_8", "GRUNTM_9", "GRUNTM_10", "GRUNTM_11", "GRUNTM_12", "GRUNTM_13", "GRUNTM_14", "GRUNTM_15", "GRUNTM_16", "GRUNTM_17", "GRUNTM_18", "GRUNTM_19", "GRUNTM_20", "GRUNTM_21", "GRUNTM_22", "GRUNTM_23", "GRUNTM_24", "GRUNTM_25", "GRUNTM_26", "GRUNTM_27", "GRUNTM_28", "GRUNTM_29", "GRUNTM_30", "GRUNTM_31"}, + ["GUITARIST"] = {"CLYDE", "VINCENT"}, + ["HIKER"] = {"ANTHONY1", "RUSSELL", "PHILLIP", "LEONARD", "ANTHONY2", "BENJAMIN", "ERIK", "MICHAEL", "PARRY1", "TIMOTHY", "BAILEY", "ANTHONY3", "TIM", "NOLAND", "SIDNEY", "KENNY", "JIM", "DANIEL", "PARRY2", "PARRY3"}, + ["JANINE"] = {"JANINE1"}, + ["JASMINE"] = {"JASMINE1"}, + ["JUGGLER"] = {"IRWIN1", "FRITZ", "HORTON", "IRWIN2", "IRWIN3"}, + ["KAREN"] = {"KAREN1"}, + ["KIMONO_GIRL"] = {"KYOKO", "NAOKO", "SAYO", "ZUKI", "KUNI", "MIKI"}, + ["KOGA"] = {"KOGA1"}, + ["LASS"] = {"CARRIE", "BRIDGET", "ALICE", "KRISE", "CONNIE1", "LINDA", "LAURA", "SHANNON", "MICHELLE", "DANA1", "ELLEN", "CONNIE2", "CONNIE3", "DANA2", "DANA3"}, + ["LT_SURGE"] = {"LT_SURGE1"}, + ["MEDIUM"] = {"MARTHA", "GRACE", "BETHANY", "MARGRET", "ETHEL", "REBECCA", "DORIS"}, + ["MISTY"] = {"MISTY1"}, + ["MORTY"] = {"MORTY1"}, + ["OFFICER"] = {"KEITH", "DIRK"}, + ["PICNICKER"] = {"LIZ1", "GINA1", "BROOKE", "KIM", "CINDY", "HOPE", "SHARON", "DEBRA", "GINA2", "ERIN1", "LIZ2", "LIZ3", "HEIDI", "EDNA", "GINA3", "TIFFANY1", "TIFFANY2", "ERIN2", "TANYA", "TIFFANY3", "ERIN3"}, + ["POKEFANF"] = {"BEVERLY1", "RUTH", "BEVERLY2", "BEVERLY3", "GEORGIA"}, + ["POKEFANM"] = {"WILLIAM", "DEREK1", "ROBERT", "JOSHUA", "CARTER", "TREVOR", "BRANDON", "JEREMY", "COLIN", "DEREK2", "DEREK3", "ALEX"}, + ["POKEMANIAC"] = {"LARRY", "ANDREW", "CALVIN", "SHANE", "BEN", "BRENT1", "RON", "ETHAN", "BRENT2", "BRENT3", "ISSAC", "DONALD", "ZACH"}, + ["POKEMON_PROF"] = {}, + ["PRYCE"] = {"PRYCE1"}, + ["PSYCHIC_T"] = {"NATHAN", "FRANKLIN", "HERMAN", "FIDEL", "GREG", "NORMAN", "MARK", "PHIL", "RICHARD", "GILBERT", "JARED", "RODNEY"}, + ["RED"] = {"RED1"}, + ["RIVAL1"] = {"RIVAL1_1_CHIKORITA", "RIVAL1_1_CYNDAQUIL", "RIVAL1_1_TOTODILE", "RIVAL1_2_CHIKORITA", "RIVAL1_2_CYNDAQUIL", "RIVAL1_2_TOTODILE", "RIVAL1_3_CHIKORITA", "RIVAL1_3_CYNDAQUIL", "RIVAL1_3_TOTODILE", "RIVAL1_4_CHIKORITA", "RIVAL1_4_CYNDAQUIL", "RIVAL1_4_TOTODILE", "RIVAL1_5_CHIKORITA", "RIVAL1_5_CYNDAQUIL", "RIVAL1_5_TOTODILE"}, + ["RIVAL2"] = {"RIVAL2_1_CHIKORITA", "RIVAL2_1_CYNDAQUIL", "RIVAL2_1_TOTODILE", "RIVAL2_2_CHIKORITA", "RIVAL2_2_CYNDAQUIL", "RIVAL2_2_TOTODILE"}, + ["SABRINA"] = {"SABRINA1"}, + ["SAGE"] = {"CHOW", "NICO", "JIN", "TROY", "JEFFREY", "PING", "EDMOND", "NEAL", "LI"}, + ["SAILOR"] = {"EUGENE", "HUEY1", "TERRELL", "KENT", "ERNEST", "JEFF", "GARRETT", "KENNETH", "STANLY", "HARRY", "HUEY2", "HUEY3"}, + ["SCHOOLBOY"] = {"JACK1", "KIPP", "ALAN1", "JOHNNY", "DANNY", "TOMMY", "DUDLEY", "JOE", "BILLY", "CHAD1", "NATE", "RICKY", "JACK2", "JACK3", "ALAN2", "ALAN3", "CHAD2", "CHAD3"}, + ["SCIENTIST"] = {"ROSS", "MITCH", "JED", "MARC", "RICH"}, + ["SKIER"] = {"ROXANNE", "CLARISSA"}, + ["SUPER_NERD"] = {"ERIC_UNUSED", "ERIC", "GREGG", "JAY", "DAVE", "SAM", "TOM", "PAT", "SHAWN", "TERU", "RUSS", "NORTON"}, + ["SWIMMERF"] = {"ELAINE", "PAULA", "KAYLEE", "SUSIE", "DENISE", "KARA", "WENDY", "LISA", "JILL", "MARY", "KATIE", "DAWN", "TARA", "NICOLE", "LORI", "JODY", "NIKKI", "DIANA", "BRIANA"}, + ["SWIMMERM"] = {"HAROLD", "SIMON", "RANDALL", "CHARLIE", "GEORGE", "BERKE", "KIRK", "MATHEW", "HAL", "PATON", "DARYL", "WALTER", "TONY", "JEROME", "TUCKER", "RICK", "CAMERON", "SETH", "JAMES", "LEWIS", "PARKER"}, + ["TEACHER"] = {"COLETTE", "HILLARY", "SHIRLEY"}, + ["TRAINER_NONE"] = {}, + ["TWINS"] = {"AMYANDMAY1", "ANNANDANNE1", "ANNANDANNE2", "AMYANDMAY2", "JOANDZOE1", "JOANDZOE2", "MEGANDPEG1", "MEGANDPEG2"}, + ["WHITNEY"] = {"WHITNEY1"}, + ["WILL"] = {"WILL1"}, + ["YOUNGSTER"] = {"JOEY1", "MIKEY", "ALBERT", "GORDON", "SAMUEL", "IAN", "JOEY2", "JOEY3", "WARREN", "JIMMY", "OWEN", "JASON"}, + }, + ["trainerClassOrder"] = {"TRAINER_NONE", "FALKNER", "WHITNEY", "BUGSY", "MORTY", "PRYCE", "JASMINE", "CHUCK", "CLAIR", "RIVAL1", "POKEMON_PROF", "WILL", "CAL", "BRUNO", "KAREN", "KOGA", "CHAMPION", "BROCK", "MISTY", "LT_SURGE", "SCIENTIST", "ERIKA", "YOUNGSTER", "SCHOOLBOY", "BIRD_KEEPER", "LASS", "JANINE", "COOLTRAINERM", "COOLTRAINERF", "BEAUTY", "POKEMANIAC", "GRUNTM", "GENTLEMAN", "SKIER", "TEACHER", "SABRINA", "BUG_CATCHER", "FISHER", "SWIMMERM", "SWIMMERF", "SAILOR", "SUPER_NERD", "RIVAL2", "GUITARIST", "HIKER", "BIKER", "BLAINE", "BURGLAR", "FIREBREATHER", "JUGGLER", "BLACKBELT_T", "EXECUTIVEM", "PSYCHIC_T", "PICNICKER", "CAMPER", "EXECUTIVEF", "SAGE", "MEDIUM", "BOARDER", "POKEFANM", "KIMONO_GIRL", "TWINS", "POKEFANF", "RED", "BLUE", "OFFICER", "GRUNTF"}, +} +local crystal = { + ["phoneContactOrder"] = {"PHONE_00", "PHONE_MOM", "PHONE_OAK", "PHONE_BILL", "PHONE_ELM", "PHONE_SCHOOLBOY_JACK", "PHONE_POKEFAN_BEVERLY", "PHONE_SAILOR_HUEY", "PHONE_UNUSED", "PHONE_UNUSED", "PHONE_UNUSED", "PHONE_COOLTRAINERM_GAVEN", "PHONE_COOLTRAINERF_BETH", "PHONE_BIRDKEEPER_JOSE", "PHONE_COOLTRAINERF_REENA", "PHONE_YOUNGSTER_JOEY", "PHONE_BUG_CATCHER_WADE", "PHONE_FISHER_RALPH", "PHONE_PICNICKER_LIZ", "PHONE_HIKER_ANTHONY", "PHONE_CAMPER_TODD", "PHONE_PICNICKER_GINA", "PHONE_JUGGLER_IRWIN", "PHONE_BUG_CATCHER_ARNIE", "PHONE_SCHOOLBOY_ALAN", "PHONE_UNUSED", "PHONE_LASS_DANA", "PHONE_SCHOOLBOY_CHAD", "PHONE_POKEFANM_DEREK", "PHONE_FISHER_TULLY", "PHONE_POKEMANIAC_BRENT", "PHONE_PICNICKER_TIFFANY", "PHONE_BIRDKEEPER_VANCE", "PHONE_FISHER_WILTON", "PHONE_BLACKBELT_KENJI", "PHONE_HIKER_PARRY", "PHONE_PICNICKER_ERIN", "PHONE_BUENA"}, + ["trainerClassMembers"] = { + ["BEAUTY"] = {"VICTORIA", "SAMANTHA", "JULIE", "JACLYN", "BRENDA", "CASSIE", "CAROLINE", "CARLENE", "JESSICA", "RACHAEL", "ANGELICA", "KENDRA", "VERONICA", "JULIA", "THERESA", "VALERIE", "OLIVIA"}, + ["BIKER"] = {"BIKER_BENNY", "KAZU", "DWAYNE", "HARRIS", "ZEKE", "CHARLES", "RILEY", "JOEL", "GLENN"}, + ["BIRD_KEEPER"] = {"ROD", "ABE", "BRYAN", "THEO", "TOBY", "DENIS", "VANCE1", "HANK", "ROY", "BORIS", "BOB", "JOSE1", "PETER", "JOSE2", "PERRY", "BRET", "JOSE3", "VANCE2", "VANCE3"}, + ["BLACKBELT_T"] = {"KENJI1", "YOSHI", "KENJI2", "LAO", "NOB", "KIYO", "LUNG", "KENJI3", "WAI"}, + ["BLAINE"] = {"BLAINE1"}, + ["BLUE"] = {"BLUE1"}, + ["BOARDER"] = {"RONALD", "BRAD", "DOUGLAS"}, + ["BROCK"] = {"BROCK1"}, + ["BRUNO"] = {"BRUNO1"}, + ["BUGSY"] = {"BUGSY1"}, + ["BUG_CATCHER"] = {"DON", "ROB", "ED", "WADE1", "BUG_CATCHER_BENNY", "AL", "JOSH", "ARNIE1", "KEN", "WADE2", "WADE3", "DOUG", "ARNIE2", "ARNIE3", "WADE4", "WADE5", "ARNIE4", "ARNIE5", "WAYNE"}, + ["BURGLAR"] = {"DUNCAN", "EDDIE", "COREY"}, + ["CAL"] = {"CAL1", "CAL2", "CAL3"}, + ["CAMPER"] = {"ROLAND", "TODD1", "IVAN", "ELLIOT", "BARRY", "LLOYD", "DEAN", "SID", "HARVEY", "DALE", "TED", "TODD2", "TODD3", "THOMAS", "LEROY", "DAVID", "JOHN", "JERRY", "SPENCER", "TODD4", "TODD5", "QUENTIN"}, + ["CHAMPION"] = {"LANCE"}, + ["CHUCK"] = {"CHUCK1"}, + ["CLAIR"] = {"CLAIR1"}, + ["COOLTRAINERF"] = {"GWEN", "LOIS", "FRAN", "LOLA", "KATE", "IRENE", "KELLY", "JOYCE", "BETH1", "REENA1", "MEGAN", "BETH2", "CAROL", "QUINN", "EMMA", "CYBIL", "JENN", "BETH3", "REENA2", "REENA3", "CARA"}, + ["COOLTRAINERM"] = {"NICK", "AARON", "PAUL", "CODY", "MIKE", "GAVEN1", "GAVEN2", "RYAN", "JAKE", "GAVEN3", "BLAKE", "BRIAN", "ERICK", "ANDY", "TYLER", "SEAN", "KEVIN", "STEVE", "ALLEN", "DARIN"}, + ["ERIKA"] = {"ERIKA1"}, + ["EXECUTIVEF"] = {"EXECUTIVEF_1", "EXECUTIVEF_2"}, + ["EXECUTIVEM"] = {"EXECUTIVEM_1", "EXECUTIVEM_2", "EXECUTIVEM_3", "EXECUTIVEM_4"}, + ["FALKNER"] = {"FALKNER1"}, + ["FIREBREATHER"] = {"OTIS", "DICK", "NED", "BURT", "BILL", "WALT", "RAY", "LYLE"}, + ["FISHER"] = {"JUSTIN", "RALPH1", "ARNOLD", "KYLE", "HENRY", "MARVIN", "TULLY1", "ANDRE", "RAYMOND", "WILTON1", "EDGAR", "JONAH", "MARTIN", "STEPHEN", "BARNEY", "RALPH2", "RALPH3", "TULLY2", "TULLY3", "WILTON2", "SCOTT", "WILTON3", "RALPH4", "RALPH5", "TULLY4"}, + ["GENTLEMAN"] = {"PRESTON", "EDWARD", "GREGORY", "VIRGIL", "ALFRED"}, + ["GRUNTF"] = {"GRUNTF_1", "GRUNTF_2", "GRUNTF_3", "GRUNTF_4", "GRUNTF_5"}, + ["GRUNTM"] = {"GRUNTM_1", "GRUNTM_2", "GRUNTM_3", "GRUNTM_4", "GRUNTM_5", "GRUNTM_6", "GRUNTM_7", "GRUNTM_8", "GRUNTM_9", "GRUNTM_10", "GRUNTM_11", "GRUNTM_12", "GRUNTM_13", "GRUNTM_14", "GRUNTM_15", "GRUNTM_16", "GRUNTM_17", "GRUNTM_18", "GRUNTM_19", "GRUNTM_20", "GRUNTM_21", "GRUNTM_22", "GRUNTM_23", "GRUNTM_24", "GRUNTM_25", "GRUNTM_26", "GRUNTM_27", "GRUNTM_28", "GRUNTM_29", "GRUNTM_30", "GRUNTM_31"}, + ["GUITARIST"] = {"CLYDE", "VINCENT"}, + ["HIKER"] = {"ANTHONY1", "RUSSELL", "PHILLIP", "LEONARD", "ANTHONY2", "BENJAMIN", "ERIK", "MICHAEL", "PARRY1", "TIMOTHY", "BAILEY", "ANTHONY3", "TIM", "NOLAND", "SIDNEY", "KENNY", "JIM", "DANIEL", "PARRY2", "PARRY3", "ANTHONY4", "ANTHONY5"}, + ["JANINE"] = {"JANINE1"}, + ["JASMINE"] = {"JASMINE1"}, + ["JUGGLER"] = {"IRWIN1", "FRITZ", "HORTON", "IRWIN2", "IRWIN3", "IRWIN4"}, + ["KAREN"] = {"KAREN1"}, + ["KIMONO_GIRL"] = {"NAOKO_UNUSED", "NAOKO", "SAYO", "ZUKI", "KUNI", "MIKI"}, + ["KOGA"] = {"KOGA1"}, + ["LASS"] = {"CARRIE", "BRIDGET", "ALICE", "KRISE", "CONNIE1", "LINDA", "LAURA", "SHANNON", "MICHELLE", "DANA1", "ELLEN", "CONNIE2", "CONNIE3", "DANA2", "DANA3", "DANA4", "DANA5"}, + ["LT_SURGE"] = {"LT_SURGE1"}, + ["MEDIUM"] = {"MARTHA", "GRACE", "BETHANY", "MARGRET", "ETHEL", "REBECCA", "DORIS"}, + ["MISTY"] = {"MISTY1"}, + ["MORTY"] = {"MORTY1"}, + ["MYSTICALMAN"] = {"EUSINE"}, + ["OFFICER"] = {"KEITH", "DIRK"}, + ["PICNICKER"] = {"LIZ1", "GINA1", "BROOKE", "KIM", "CINDY", "HOPE", "SHARON", "DEBRA", "GINA2", "ERIN1", "LIZ2", "LIZ3", "HEIDI", "EDNA", "GINA3", "TIFFANY1", "TIFFANY2", "ERIN2", "TANYA", "TIFFANY3", "ERIN3", "LIZ4", "LIZ5", "GINA4", "GINA5", "TIFFANY4"}, + ["POKEFANF"] = {"BEVERLY1", "RUTH", "BEVERLY2", "BEVERLY3", "GEORGIA", "JAIME"}, + ["POKEFANM"] = {"WILLIAM", "DEREK1", "ROBERT", "JOSHUA", "CARTER", "TREVOR", "BRANDON", "JEREMY", "COLIN", "DEREK2", "DEREK3", "ALEX", "REX", "ALLAN"}, + ["POKEMANIAC"] = {"LARRY", "ANDREW", "CALVIN", "SHANE", "BEN", "BRENT1", "RON", "ETHAN", "BRENT2", "BRENT3", "ISSAC", "DONALD", "ZACH", "BRENT4", "MILLER"}, + ["POKEMON_PROF"] = {}, + ["PRYCE"] = {"PRYCE1"}, + ["PSYCHIC_T"] = {"NATHAN", "FRANKLIN", "HERMAN", "FIDEL", "GREG", "NORMAN", "MARK", "PHIL", "RICHARD", "GILBERT", "JARED", "RODNEY"}, + ["RED"] = {"RED1"}, + ["RIVAL1"] = {"RIVAL1_1_CHIKORITA", "RIVAL1_1_CYNDAQUIL", "RIVAL1_1_TOTODILE", "RIVAL1_2_CHIKORITA", "RIVAL1_2_CYNDAQUIL", "RIVAL1_2_TOTODILE", "RIVAL1_3_CHIKORITA", "RIVAL1_3_CYNDAQUIL", "RIVAL1_3_TOTODILE", "RIVAL1_4_CHIKORITA", "RIVAL1_4_CYNDAQUIL", "RIVAL1_4_TOTODILE", "RIVAL1_5_CHIKORITA", "RIVAL1_5_CYNDAQUIL", "RIVAL1_5_TOTODILE"}, + ["RIVAL2"] = {"RIVAL2_1_CHIKORITA", "RIVAL2_1_CYNDAQUIL", "RIVAL2_1_TOTODILE", "RIVAL2_2_CHIKORITA", "RIVAL2_2_CYNDAQUIL", "RIVAL2_2_TOTODILE"}, + ["SABRINA"] = {"SABRINA1"}, + ["SAGE"] = {"CHOW", "NICO", "JIN", "TROY", "JEFFREY", "PING", "EDMOND", "NEAL", "LI", "GAKU", "MASA", "KOJI"}, + ["SAILOR"] = {"EUGENE", "HUEY1", "TERRELL", "KENT", "ERNEST", "JEFF", "GARRETT", "KENNETH", "STANLY", "HARRY", "HUEY2", "HUEY3", "HUEY4"}, + ["SCHOOLBOY"] = {"JACK1", "KIPP", "ALAN1", "JOHNNY", "DANNY", "TOMMY", "DUDLEY", "JOE", "BILLY", "CHAD1", "NATE", "RICKY", "JACK2", "JACK3", "ALAN2", "ALAN3", "CHAD2", "CHAD3", "JACK4", "JACK5", "ALAN4", "ALAN5", "CHAD4", "CHAD5"}, + ["SCIENTIST"] = {"ROSS", "MITCH", "JED", "MARC", "RICH"}, + ["SKIER"] = {"ROXANNE", "CLARISSA"}, + ["SUPER_NERD"] = {"STAN", "ERIC", "GREGG", "JAY", "DAVE", "SAM", "TOM", "PAT", "SHAWN", "TERU", "RUSS", "NORTON", "HUGH", "MARKUS"}, + ["SWIMMERF"] = {"ELAINE", "PAULA", "KAYLEE", "SUSIE", "DENISE", "KARA", "WENDY", "LISA", "JILL", "MARY", "KATIE", "DAWN", "TARA", "NICOLE", "LORI", "JODY", "NIKKI", "DIANA", "BRIANA"}, + ["SWIMMERM"] = {"HAROLD", "SIMON", "RANDALL", "CHARLIE", "GEORGE", "BERKE", "KIRK", "MATHEW", "HAL", "PATON", "DARYL", "WALTER", "TONY", "JEROME", "TUCKER", "RICK", "CAMERON", "SETH", "JAMES", "LEWIS", "PARKER"}, + ["TEACHER"] = {"COLETTE", "HILLARY", "SHIRLEY"}, + ["TRAINER_NONE"] = {}, + ["TWINS"] = {"AMYANDMAY1", "ANNANDANNE1", "ANNANDANNE2", "AMYANDMAY2", "JOANDZOE1", "JOANDZOE2", "MEGANDPEG1", "MEGANDPEG2", "LEAANDPIA1", "LEAANDPIA2"}, + ["WHITNEY"] = {"WHITNEY1"}, + ["WILL"] = {"WILL1"}, + ["YOUNGSTER"] = {"JOEY1", "MIKEY", "ALBERT", "GORDON", "SAMUEL", "IAN", "JOEY2", "JOEY3", "WARREN", "JIMMY", "OWEN", "JASON", "JOEY4", "JOEY5"}, + }, + ["trainerClassOrder"] = {"TRAINER_NONE", "FALKNER", "WHITNEY", "BUGSY", "MORTY", "PRYCE", "JASMINE", "CHUCK", "CLAIR", "RIVAL1", "POKEMON_PROF", "WILL", "CAL", "BRUNO", "KAREN", "KOGA", "CHAMPION", "BROCK", "MISTY", "LT_SURGE", "SCIENTIST", "ERIKA", "YOUNGSTER", "SCHOOLBOY", "BIRD_KEEPER", "LASS", "JANINE", "COOLTRAINERM", "COOLTRAINERF", "BEAUTY", "POKEMANIAC", "GRUNTM", "GENTLEMAN", "SKIER", "TEACHER", "SABRINA", "BUG_CATCHER", "FISHER", "SWIMMERM", "SWIMMERF", "SAILOR", "SUPER_NERD", "RIVAL2", "GUITARIST", "HIKER", "BIKER", "BLAINE", "BURGLAR", "FIREBREATHER", "JUGGLER", "BLACKBELT_T", "EXECUTIVEM", "PSYCHIC_T", "PICNICKER", "CAMPER", "EXECUTIVEF", "SAGE", "MEDIUM", "BOARDER", "POKEFANM", "KIMONO_GIRL", "TWINS", "POKEFANF", "RED", "BLUE", "OFFICER", "GRUNTF", "MYSTICALMAN"}, +} +local function copy(value) + if type(value) ~= "table" then return value end + local out = {} + for key, item in pairs(value) do out[key] = copy(item) end + return out +end +function M.forVersion(version) + local data = (version == "gold" or version == "silver") and gold + or version == "crystal" and crystal or nil + return data and copy(data) +end +function M.install(constants, manifest, version) + local supplied = manifest and manifest.constants or {} + local keys = { "phoneContactOrder", "trainerClassOrder", "trainerClassMembers" } + local authored = false + for _, key in ipairs(keys) do + if constants[key] ~= nil or supplied[key] ~= nil then authored = true end + end + -- A partial custom identity set must not silently mix with a retail roster. + local fallback = not authored and M.forVersion(version) or {} + for _, key in ipairs(keys) do + -- Authored native constants win. Manifests can explicitly describe an + -- alternative cartridge; retail identities never guess a hack's roster. + if constants[key] == nil then + local value = type(supplied[key]) == "table" and supplied[key] or fallback[key] + if value then constants[key] = copy(value) end + end + end + return constants +end +return M diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 90b74029e..27bacb8bf 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -961,6 +961,9 @@ function RomExtractorGen2:constants() -- Generation is runtime metadata, not cartridge data. Battle formulas use -- it to select the Gen II critical-hit ladder without guessing from a ROM id. self._constants.generation = 2 + -- Numeric phone/trainer identities are independent of localized names. + require("src.import.Gen2PhoneIdentities").install( + self._constants, self.manifest, self.version) local badges = {} -- A CARTRIDGE MAY HAVE ITS OWN SET. Prism awards TWENTY badges across -- three engine-flag bytes, and its flag keys keep the ENGINE_ prefix that @@ -8431,7 +8434,6 @@ end -- POKeGEAR had no dialogue at all to show. local GEN2_PHONE_ROW_BYTES = 12 local GEN2_PHONE_MAX = 64 -local GEN2_NONTRAINER_NAMES = 5 -- db class, id | db group, number | db ptime, bank, dw callee script (the one -- that runs when YOU ring them) | db ctime, bank, dw caller script (the one -- that runs when THEY ring you -- ELM's is the same $41E1 @@ -8447,14 +8449,30 @@ function RomExtractorGen2:gen2PhoneContacts(pool) local sym = self:symbol("PhoneContacts") if not (sym and self.rom) then return nil end + -- SpecialPhoneCallList closes the table, including the reserved holes. + local limit = GEN2_PHONE_MAX + local stop = self:symbol("SpecialPhoneCallList") + if stop and stop.bank == sym.bank and stop.address > sym.address then + limit = math.floor((stop.address - sym.address) / GEN2_PHONE_ROW_BYTES) + end + local rows, rowsRead, lastName = {}, 0, 0 + for id = 0, limit - 1 do + local ok, row = pcall(self.rom.bytes, self.rom, sym.bank, + sym.address + id * GEN2_PHONE_ROW_BYTES, GEN2_PHONE_ROW_BYTES) + if not ok then break end + rows[id], rowsRead = row, rowsRead + 1 + if row[1] == 0 then lastName = math.max(lastName, row[2]) end + end + -- Polished names can contain a Huffman-compressed suffix ($5D). - -- Decode the string stream before applying its charmap. + -- Decode the string stream before applying its charmap. Read every name + -- referenced by the actual table: the Gold-sized four-name bound discarded + -- Crystal's Buena row (non-trainer identity5), including both call scripts. local names = {} local nsym = self:symbol("NonTrainerCallerNames") if nsym then local polished = self.version == "polishedcrystal" - local count = polished and 7 or GEN2_NONTRAINER_NAMES - for id = 1, count - 1 do + for id = 1, lastName do local ok, address = pcall(self.rom.word, self.rom, nsym.bank, nsym.address + id * 2) if ok and self:gen2InRom(nsym.bank, address) then @@ -8470,18 +8488,9 @@ function RomExtractorGen2:gen2PhoneContacts(pool) end end - -- SpecialPhoneCallList closes the table; sizing off it beats guessing - local limit = GEN2_PHONE_MAX - local stop = self:symbol("SpecialPhoneCallList") - if stop and stop.bank == sym.bank and stop.address > sym.address then - limit = math.floor((stop.address - sym.address) / GEN2_PHONE_ROW_BYTES) - end - local out = {} - for id = 0, limit - 1 do - local ok, row = pcall(self.rom.bytes, self.rom, sym.bank, - sym.address + id * GEN2_PHONE_ROW_BYTES, GEN2_PHONE_ROW_BYTES) - if not ok then break end + for id = 0, rowsRead - 1 do + local row = rows[id] local class, trainer = row[1], row[2] -- the unused rows are class 0 / id 0, which GetCallerName renders as the -- "----------" placeholder; skip them so the POKeGEAR can never list one @@ -8490,7 +8499,8 @@ function RomExtractorGen2:gen2PhoneContacts(pool) entry = { class = class, trainer = trainer } elseif trainer ~= 0 and names[trainer] then local name, role = names[trainer]:match("^([^\n\f\v]+)[\n\f\v]%s*(.*)$") - entry = { name = (name or names[trainer]):gsub(":$", ""), role = role } + entry = { name = (name or names[trainer]):gsub(":$", ""), role = role, + contactNumber = trainer } end -- The contact's OWN map, bytes 3 and 4. GetAvailableCallers (36:$40DE) -- compares it against wMapGroup/wMapNumber and drops the contact from the @@ -8510,8 +8520,9 @@ function RomExtractorGen2:gen2PhoneContacts(pool) end out[id] = entry end - if not next(out) then return nil end - return out + local info = { size = limit, rowsRead = rowsRead, nonTrainerNames = names } + if not next(out) then return nil, info end + return out, info end -- `SpecialPhoneCallList` ([36,$45F6] .. PhoneOutOfAreaScript, 48 bytes) is @@ -8900,7 +8911,7 @@ function RomExtractorGen2:extractMapScripts() self:beginStage("Gen2 map scripts -- drawn in. self._gen2Spawns = out.spawns self._gen2MapDefs = maps - out.phone = self:gen2PhoneContacts(pool) + out.phone, out.phoneInfo = self:gen2PhoneContacts(pool) out.specialCalls = self:gen2SpecialPhoneCalls(pool) -- wVariableSprites as InitializeEventsScript leaves it on a new game, so an -- object_event whose sprite byte is $F0 or more resolves before the map's diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 858974555..1b31573e8 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1306,7 +1306,7 @@ local function markerFor(version) -- Only these three caches need rebuilding; other markers remain stable. -- v8: field.gen2TradeAnim, GSC's own trade movie art (Gen2TradeArt). if version == "gold" or version == "silver" or version == "crystal" then - revision = "gen2-field-encounters-v8:" + revision = "gen2-field-encounters-v8:gen2-phone-identities-v1:" end -- field.tradeArt: the cable-trade movie's tiles out of the ROM -- (Gen1TradeArt); without it the trade ran on drawn stand-ins. diff --git a/tests/engine/gen2_phone_identities.lua b/tests/engine/gen2_phone_identities.lua new file mode 100644 index 000000000..4b1350476 --- /dev/null +++ b/tests/engine/gen2_phone_identities.lua @@ -0,0 +1,111 @@ +-- Copyright (c) 2026 Roxas2712. See offered PR contribution permission. +-- Synthetic native ROM rows distinguish contact numbers from map numbers. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Ids=require('src.import.Gen2PhoneIdentities') +local Extractor=require('src.import.RomExtractorGen2') +local V=require('src.core.GameVersion');local initial=V.get() +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + local ids=Ids.forVersion(version) + T.eq(#ids.phoneContactOrder,version=='crystal' and 38 or 37,version..' retail row bound includes holes') + T.eq(ids.phoneContactOrder[9],'PHONE_UNUSED',version..' reserved contact hole is explicit') + T.eq(ids.phoneContactOrder[2],'PHONE_MOM',version..' contact identity is numeric-order metadata') + T.eq(ids.phoneContactOrder[3],'PHONE_OAK',version..' retain reference spelling without renaming native slot2') + ids.phoneContactOrder[2]='LEAK';ids.trainerClassMembers.FALKNER[1]='LEAK' + T.eq(Ids.forVersion(version).phoneContactOrder[2],'PHONE_MOM',version..' contact metadata detached') + T.neq(Ids.forVersion(version).trainerClassMembers.FALKNER[1],'LEAK',version..' nested trainer metadata detached') + local bytes={};for i=1,0x8000 do bytes[i]=0 end + local function put(at,values)for i,v in ipairs(values)do bytes[at+i]=v end end + -- Four rows: unused0, non-trainer1, trainer2 and a reserved unused3. + put(0x4000+12,{0,2,9,42,7,1,0x00,0x50,2,1,0x10,0x50}) + put(0x4000+24,{12,3,5,6,1,1,0x20,0x50,4,1,0x30,0x50}) + -- Pointer slot2 names the actual imported display string; it is deliberately + -- unrelated to the legacy PHONE_OAK compatibility identifier at index2. + put(0x4204,{0x00,0x43});put(0x4300,{0x81,0x88,0x8A,0x84,0x7F,0x92,0x87,0x8E,0x8F,0x50}) + -- Crystal's late non-trainer record at native index37 names slot5. Earlier + -- code read only names1..4 and silently discarded this entire native row. + put(0x4000+37*12,{0,5,3,77,7,1,0x40,0x50,7,1,0x50,0x50}) + put(0x420a,{0x10,0x43});put(0x4310,{0x81,0x94,0x84,0x8d,0x80,0x50}) + local chunks={};for i=1,#bytes,256 do chunks[#chunks+1]=string.char(unpack(bytes,i,math.min(i+255,#bytes)))end + local e=Extractor.new(table.concat(chunks),version,{symbols={PhoneContacts={1,0x4000},SpecialPhoneCallList={1,0x4030},NonTrainerCallerNames={1,0x4200}}}) + function e:readSourceTable()return {}end + local queued={} + function e:gen2QueueScript(bank,address) + local id=string.format('S%02X_%04X',bank,address);queued[#queued+1]=id;return id + end + local rows,info=e:gen2PhoneContacts({}) + T.eq(info.size,4,version..' imported full native bound retains unused slots') + T.eq(info.rowsRead,4,version..' actual imported row count is explicit') + T.eq(rows[0],nil,version..' unused0 is not invented as a callable contact') + T.eq(rows[3],nil,version..' reserved hole is retained by bound, not a fake contact') + T.eq(rows[1].contactNumber,2,version..' actual non-trainer identity retained') + T.eq(rows[1].number,42,version..' actual map number remains separate') + T.eq(rows[1].group,9,version..' actual map group remains native') + T.eq(rows[1].name,'BIKE SHOP',version..' native display text remains authoritative') + T.eq(info.nonTrainerNames[2],'BIKE SHOP',version..' imported non-trainer lookup retained') + T.eq(rows[1].call,'S01_5000',version..' callee pointer remains outgoing') + T.eq(rows[1].callTime,7,version..' callee time remains outgoing') + T.eq(rows[1].receive,'S01_5010',version..' caller pointer remains incoming') + T.eq(rows[1].receiveTime,2,version..' caller time remains incoming') + T.eq(rows[2].class,12,version..' native trainer class is numeric and unchanged') + T.eq(rows[2].trainer,3,version..' native roster member is unchanged') + T.eq(rows[2].contactNumber,nil,version..' trainer member is not a non-trainer contact number') + T.eq(rows[2].number,6,version..' trainer map number is not replaced by member') + T.eq(#queued,4,version..' both native phone directions are queued') + local constants=e:constants() + T.eq(constants.phoneContactOrder[2],'PHONE_MOM',version..' actual constants producer installs phone identities') + T.eq(constants.generation,2,version..' new metadata preserves native generation') + local empty=Extractor.new(string.rep('\0',0x8000),version,{symbols={PhoneContacts={1,0x4000},SpecialPhoneCallList={1,0x4030}}}) + local none,bounds=empty:gen2PhoneContacts({}) + T.eq(none,nil,version..' empty native book has no fake callable records') + T.eq(bounds.size,4,version..' empty native book still reserves its complete row range') + local buena=Extractor.new(table.concat(chunks),version,{symbols={PhoneContacts={1,0x4000},SpecialPhoneCallList={1,0x41c8},NonTrainerCallerNames={1,0x4200}}}) + local book,meta=buena:gen2PhoneContacts({scripts={},queue={}}) + T.eq(book[37] and book[37].name,'BUENA',version..' referenced name5 retains the complete native contact') + T.eq(book[37] and book[37].contactNumber,5,version..' late non-trainer keeps its own identity') + T.eq(book[37] and book[37].number,77,version..' late non-trainer map number remains independent') + T.eq(meta.size,38,version..' full native row range remains reserved') + local data=T.fixtures.fresh();data.map_scripts={phone=book,scripts={S01_5040={{'setval',41},{'end'}},S01_5050={{'setval',42},{'end'}}}} + local C=require('src.script.Gen2Commands') + T.eq(C.phoneName(data,37),'BUENA',version..' real native phone-name consumer can resolve the restored contact') + local game={data=data,save={},overworld={}} + for _,direction in ipairs({'call','receive'})do + local rows=direction=='call' and C.phoneCallScript(data,37) or C.phoneReceiveScript(data,37) + T.check(rows~=nil,version..' restored '..direction..' script is actually compiled') + local runner=require('src.script.ScriptRunner').new(game,game.overworld);runner:run(rows or {}) + T.eq(runner.ctx.g2Var,direction=='call' and 41 or 42,version..' real native '..direction..' executes correct script') + T.eq(runner:isRunning(),false,version..' native '..direction..' finishes') + end +end +for _,version in ipairs({'red','firered','emerald','prism','polishedcrystal'})do + T.eq(Ids.forVersion(version),nil,version..' does not receive guessed retail identities') +end +local authored={phoneContactOrder={'CUSTOM'},trainerClassOrder={'CUSTOM_CLASS'},trainerClassMembers={CUSTOM_CLASS={'ONE'}}} +local manifest={constants=authored};local mounted={} +Ids.install(mounted,manifest,'prism') +T.eq(mounted.phoneContactOrder[1],'CUSTOM','manifest explicitly describes an alternative cartridge') +authored.trainerClassMembers.CUSTOM_CLASS[1]='LEAK' +T.eq(mounted.trainerClassMembers.CUSTOM_CLASS[1],'ONE','manifest roster metadata is detached') +local original={phoneContactOrder={'NATIVE'}};Ids.install(original,manifest,'gold') +T.eq(original.phoneContactOrder[1],'NATIVE','existing authored constants remain authoritative') +local custom={trainerClassOrder={'CUSTOM'}};Ids.install(custom,nil,'gold') +T.eq(custom.trainerClassMembers,nil,'partial authored roster cannot inherit unrelated retail members') +T.eq(custom.phoneContactOrder,nil,'partial authored identity set cannot guess retail contact names') +local Importer=require('src.import.RomImporter');local marker +for i=1,100 do local name,value=debug.getupvalue(Importer.readyReport,i);if not name then break end;if name=='markerFor'then marker=value;break end end +assert(marker) +for _,version in ipairs({'gold','silver','crystal'})do + local current=marker(version) + T.check(current:find('gen2-phone-identities-v1:',1,true)~=nil,version..' existing native import must rebuild once') + local old=current:gsub('gen2%-phone%-identities%-v1:','') + local V=require('src.core.GameVersion');local previous=V.get();V.set(version) + love.filesystem.write(V.cachePrefix(version)..'rom-cache.complete',old) + T.eq(Importer.readyReport(version).ok,false,version..' real readiness gate rejects missing phone metadata') + V.set(previous) +end +for _,version in ipairs({'red','firered','emerald','prism','polishedcrystal'})do + T.check(not marker(version):find('gen2-phone-identities-v1:',1,true),version..' unrelated cache marker is unchanged') +end +V.set(initial) +T.finish('native phone/trainer identities and reserved-row import metadata') From c63cd5d150bb5b8ad9bc1d7756d417e204cd22db Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:41:33 +0100 Subject: [PATCH 28/67] Connect native Gen2 phone contacts to the semantic content registry --- docs/native-gen2-phone-content.md | 32 +++++ src/mods/Loader.lua | 8 +- src/mods/PhoneContent.lua | 211 ++++++++++++++++++++++++++++ src/mods/Schemas.lua | 13 ++ tests/engine/gen2_phone_content.lua | 165 ++++++++++++++++++++++ 5 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 docs/native-gen2-phone-content.md create mode 100644 src/mods/PhoneContent.lua create mode 100644 tests/engine/gen2_phone_content.lua diff --git a/docs/native-gen2-phone-content.md b/docs/native-gen2-phone-content.md new file mode 100644 index 000000000..01ff19004 --- /dev/null +++ b/docs/native-gen2-phone-content.md @@ -0,0 +1,32 @@ +# Semantic contacts in the native Gen2 phone book + +`phone_contacts` presents imported numeric records through explicit phone and +trainer identity metadata. Changes reach `map_scripts.phone`, the actual +Pokegear, native incoming-call selector and Gen2ScriptVM phone scripts. The +non-trainer `number` is kept separate from native map group/number fields. +Names remain imported or authored text; localized trainer names never select +trainer identities. Ambiguous map spellings are not guessed. + +New contacts append after the full imported row bound, including reserved +holes. Existing indices cannot move; removed records retain reservations +within the load. Explicit indices are recommended when changing enabled mod +sets. Saved numeric slots are never rewritten. Reads and iterator rows are +detached; finite/cycle, identity, index, mask and native-script validation +happens before journaling the operation. Failed entries roll back, reload +retires unchanged owned rows, and external native edits survive. Other +generations report an unavailable target and perform no native writes. + +Caller/callee names and `callerKey`/`calleeKey` currently address existing +imported native script keys. Native VM execution remains authoritative. +Authored new scripts, forward references, the complete legacy Phone facade, +rematch-event semantics and actual story/hardware acceptance remain open. +This is a native content bridge, not a complete phone parity claim. + +The focused synthetic suite runs the actual Loader, Pokegear saved book, +incoming selector, Gen2ScriptVM and ScriptRunner across Gold/Silver/Crystal, +API1/2 native. It also checks invalid-entry rollback, +index ownership, detached reads, reload, external changes and target isolation. +No GPU or complete playthrough claim follows from these tests. + +Original implementation by Roxas2712; existing upstream and third-party rights +and credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..f290104d2 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -717,7 +717,7 @@ function Loader:_contentApi(mod, registry, deprecation) if apiLevel >= 2 then error(err, 0) end Logger.warn("[%s] %s", modId, err) end - return { + local api = { register = function(_, id, value) note() validate("register", id, value) @@ -750,6 +750,10 @@ function Loader:_contentApi(mod, registry, deprecation) return registry:each() end, } + if registry.name == "phone_contacts" then + return require("src.mods.PhoneContent").api(loader,mod,api,loader:_generation()) + end + return api end -- mod.commands is sugar over the commands registry; the engine's own verbs @@ -1328,6 +1332,7 @@ function Loader:load(data) end -- vanilla content is registrations too, and they land before discovery so -- a mod's register collides with the engine's and has to say override + require("src.mods.PhoneContent").prepare(self, data) require("src.mods.Builtins").install(self.content, data) self:_loadState() self:_discover() @@ -1551,6 +1556,7 @@ function Loader:load(data) stampAudioOwners(data, name, registry) end end + require("src.mods.PhoneContent").apply(self, data) -- dangling f.id references are attributed to the id's last writer; -- api 1 mods keep the warning-only compat path if data then diff --git a/src/mods/PhoneContent.lua b/src/mods/PhoneContent.lua new file mode 100644 index 000000000..71878786c --- /dev/null +++ b/src/mods/PhoneContent.lua @@ -0,0 +1,211 @@ +-- Copyright (c) 2026 Roxas2712. See the offered PR contribution permission in docs/native-gen2-phone-content.md. +-- Semantic contacts project onto the native numeric book, never onto saves. +local Merge = require('src.mods.Merge') +local M = {} +local previous = setmetatable({}, {__mode='k'}) +local mounted = setmetatable({}, {__mode='k'}) +local copy = Merge.deepCopy +local function equal(a,b) + if a==b then return true end + if type(a)~='table' or type(b)~='table' then return false end + for k,v in pairs(a) do if not equal(v,b[k]) then return false end end + for k in pairs(b) do if a[k]==nil then return false end end + return true +end +local function finite(v,seen) + if type(v)=='number' then assert(v==v and math.abs(v)=1 and index<=255,'phone_contacts: invalid or exhausted index') + assert(not used[index] or used[index]==id,'phone_contacts: index collision') + assert(old or index>state.floor,'phone_contacts: native rows and holes are reserved') + return index +end +local function nativeRow(loader,id,row) + local state=assert(mounted[loader]);local data=state.data + local old=state.native[id] or {} + local out=copy(old) + out.name=row.name;out.contactNumber=row.number;out.rematchEvent=row.rematchEvent + if row.class or row.member then + assert(row.class and row.member,'phone_contacts: class and member are required together') + assert(row.number==nil,'phone_contacts: trainer and non-trainer identities are exclusive') + local c=data.constants or {} + out.class=assert(position(c.trainerClassOrder,row.class),'phone_contacts: unknown trainer class') + out.trainer=assert(position((c.trainerClassMembers or {})[row.class],row.member),'phone_contacts: unknown trainer member') + out.contactNumber=nil + elseif not (old.class and state.base[id] and not state.base[id].class) then + out.class=nil;out.trainer=nil + end + if row.map then + local def=loader.content.maps:get(row.map) + assert(type(def)=='table' and type(def.group)=='number' and type(def.number)=='number', + 'phone_contacts: map requires native group/number metadata') + out.group,out.number=def.group,def.number + elseif state.base[id] and state.base[id].map then + out.group,out.number=nil,nil + end + out.call=row.calleeKey or row.callee + out.receive=row.callerKey or row.caller + out.callTime,out.receiveTime=row.calleeTime or 0,row.callerTime or 0 + for _,field in ipairs({'call','receive'}) do + local key=out[field];local mask=out[field..'Time'] + assert(mask==0 or type(key)=='string' and key~='','phone_contacts: enabled direction needs a script key') + -- This version delegates existing imported scripts to the real native VM. + -- It does not interpret Gen1 bytecode or fabricate missing script labels. + if key then assert(data.map_scripts and data.map_scripts.scripts and data.map_scripts.scripts[key], + 'phone_contacts: script key is not in the native script pool: '..tostring(key)) end + end + assert(type(row.name)=='string' and row.name~='' or out.class, + 'phone_contacts: contact needs a name or trainer identity') + return out +end +function M.apply(loader,data) + local registry=loader.content.phone_contacts + if loader:_generation()~=2 or not data or not next(registry.ops) then return end + local state=assert(mounted[loader]);local plan={} + for _,id in ipairs(registry.order) do + if registry.ops[id] then + local row=registry:get(id);local old=state.base[id] + local index=row and row.index or old and old.index + if index then plan[#plan+1]={index=index,value=row and nativeRow(loader,id,row)} end + end + end + -- Validate every conversion before the first native write. + local createdPool=data.map_scripts==nil + data.map_scripts=data.map_scripts or {} + local createdPhone=data.map_scripts.phone==nil + data.map_scripts.phone=data.map_scripts.phone or {} + local target=data.map_scripts.phone;local undo={createdPool=createdPool,createdPhone=createdPhone,pool=data.map_scripts,target=target} + for _,entry in ipairs(plan) do + undo[#undo+1]={target=target,index=entry.index,before=target[entry.index], + value=entry.value,snapshot=copy(entry.value)} + target[entry.index]=entry.value + end + previous[data]=undo +end +function M.api(loader,mod,native,generation) + local registry=loader.content.phone_contacts;local available=generation==2 + local reason=not available and 'unsupported: phone_contacts has no native target in this dataset' or nil + local out={available=available,supports=function() return available,reason end} + local function view(row) + row=copy(row) + if row then row.callee=row.calleeKey or row.callee;row.caller=row.callerKey or row.caller end + return row + end + function out:get(id) if not available then return nil,reason end;return view(native:get(id)) end + function out:has(id) return self:get(id)~=nil end + function out:each() + if not available then return function() end end + local nextRow=native:each() + return function() local id,row=nextRow();return id,view(row) end + end + for _,verb in ipairs({'register','override','patch','remove'}) do + out[verb]=function(_,id,value) + if not available then return nil,reason end + assert(type(id)=='string' and id~='','phone_contacts: id is required') + if verb=='remove' then native:remove(id);return nil end + finite(value);assert(type(value)=='table' and value~=Merge.DELETE,'phone_contacts: expected record') + for key in pairs(value) do assert(registry.spec.fields[key],'phone_contacts: unknown field '..tostring(key)) end + local ok,err=require('src.mods.Schemas').check(registry.spec,'phone_contacts',id,value,verb) + assert(ok,err) + local payload=copy(value) + for _,direction in ipairs({'callee','caller'}) do + if payload[direction]~=nil then + local key=direction..'Key' + assert(payload[key]==nil or payload[key]==payload[direction], + 'phone_contacts: conflicting script aliases') + payload[key]=payload[direction];payload[direction]=nil + end + end + payload.index=indexFor(registry,assert(mounted[loader]),id,value) + local effective=Merge.deepMerge(verb=='patch' and copy(registry:get(id) or {}) or {},payload,'record') + nativeRow(loader,id,effective) + if verb~='patch' then payload=effective end + native[verb](native,id,payload) + return out:get(id) + end + end + return out +end +return M diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 9014835cc..7ab049905 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -1259,6 +1259,19 @@ R.text_pointers = { -- ------- persistence +R.phone_contacts = { + semantics = "record", generations = "2", + fields = { + index=f.opt(f.int(1,255)), number=f.opt(f.int(0,255)), + name=f.opt(f.str), class=f.opt(f.str), member=f.opt(f.str), + map=f.opt(f.id("maps")), rematchEvent=f.opt(f.int(0,2047)), + calleeTime=f.opt(f.int(0,7)), callerTime=f.opt(f.int(0,7)), + callee=f.opt(f.str), caller=f.opt(f.str), calleeKey=f.opt(f.str), callerKey=f.opt(f.str), + }, + example='mod.content.phone_contacts:patch("PHONE_YOUNGSTER_JOEY", { map="ROUTE_31" })', + notes="Gen2 native numeric phone book. Existing indices and reserved holes remain fixed; new contacts append at 1..255. Caller/callee keys must name existing native imported scripts, not Gen1 bytecode. New authored scripts, forward script/map references and the legacy Phone facade are separate work.", +} + -- compose, keyed by the owning mod id: the runner walks each owner's chain -- in semver order against the versions recorded in the save R.migrations = { diff --git a/tests/engine/gen2_phone_content.lua b/tests/engine/gen2_phone_content.lua new file mode 100644 index 000000000..6e90948da --- /dev/null +++ b/tests/engine/gen2_phone_content.lua @@ -0,0 +1,165 @@ +-- Copyright (c) 2026 Roxas2712. See the offered PR contribution permission in docs/native-gen2-phone-content.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +local Json=require('src.link.Json');local Loader=require('src.mods.Loader') +local C=require('src.script.Gen2Commands');local Gear=require('src.ui.PokegearMenu') +local function fixture() + local d=T.fixtures.fresh() + d.constants.phoneContactOrder={'PHONE_00','PHONE_MOM','PHONE_JOEY','PHONE_UNUSED'} + d.constants.trainerClassOrder={'YOUNGSTER'} + d.constants.trainerClassMembers={YOUNGSTER={'JOEY1'}} + d.maps.PALLET_TOWN={group=1,number=1};d.maps.ROUTE_31={group=1,number=2} + d.trainers={OPP_LOCALIZED={index=1,name='LOCAL CLASS',partyNames={'LOCAL JOEY'}}} + d.map_scripts={phoneInfo={size=4},phone={ + [1]={name='NATIVE MOM',contactNumber=1,group=1,number=1,call='OUT',callTime=7,receive='IN',receiveTime=0}, + [2]={class=1,trainer=1,group=1,number=2,call='OUT',callTime=7,receive='IN',receiveTime=7}, + },scripts={OUT={{'setval',41},{'end'}},IN={{'setval',42},{'end'}}}} + return d +end +local function load(d,source,api,format) + for root in pairs(require('src.mods.Builtins').namespaceRoots()) do d[root]=nil end + local files={} + if source then + files['mods/phone/manifest.json']=Json.encode({id='phone',name='Phone content',version='1.0.0', + api=api or 2,content_format=format or 'native',entry='main.lua',affects_link=false}) + files['mods/phone/main.lua']='local mod=...;mod.exports.api=mod;'..source + end + return T.sdk.loadMods(source and {'mods/phone'} or {},{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r) Loader.teardown();r.release() end +for _,version in ipairs({'gold','silver','crystal'}) do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}}) do + local d=fixture();local native=d.map_scripts.phone[2] + local r=load(d,[[ + assert(mod.content.phone_contacts:supports()) + local p=mod.content.phone_contacts + assert(p:get('PHONE_MOM').number==1) + assert(p:get('PHONE_MOM').map=='PALLET_TOWN') + assert(p:get('PHONE_MOM').callee=='OUT' and p:get('PHONE_MOM').caller=='IN') + assert(p:get('PHONE_JOEY').class=='YOUNGSTER') + assert(p:get('PHONE_JOEY').member=='JOEY1') + local row=p:get('PHONE_MOM');row.name='LEAK' + assert(p:get('PHONE_MOM').name=='NATIVE MOM') + for id,value in p:each() do value.name='LEAK' end + assert(p:get('PHONE_MOM').name=='NATIVE MOM') + p:patch('PHONE_MOM',{name='AUTHORED MOM',map='ROUTE_31',callerTime=7}) + p:register('PHONE_EXTRA',{name='EXTRA',map='PALLET_TOWN',calleeTime=7,callerTime=7,calleeKey='OUT',callerKey='IN'}) + mod.exports.index=p:get('PHONE_EXTRA').index + p:remove('PHONE_JOEY') + ]],mode[1],mode[2]) + local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' real loader accepts semantic contacts') + local e=r.loader.exports.phone;local index=e and e.index + T.eq(index,4,label..' added contact skips all native holes') + T.eq(C.phoneName(d,1),'AUTHORED MOM',label..' native name consumer uses authored row') + T.eq(d.map_scripts.phone[1].contactNumber,1,label..' contact number stays separate') + T.eq(d.map_scripts.phone[1].number,2,label..' actual map number changes') + T.eq(d.map_scripts.phone[1].receiveTime,7,label..' native incoming time changes') + T.eq(C.phoneName(d,2),nil,label..' native removed contact stays absent') + T.eq(native.trainer,1,label..' source row remains unchanged') + if index then + local game={data=d,save={g2Phone={[index]=true}},overworld={}} + local gear=setmetatable({game=game},{__index=Gear}) + T.eq(#gear:contacts(),1,label..' actual native saved phone book sees addition') + for _,direction in ipairs({'call','receive'}) do + local rows=direction=='call' and C.phoneCallScript(d,index) or C.phoneReceiveScript(d,index) + T.check(rows~=nil,label..' '..direction..' compiles through native VM') + local runner=require('src.script.ScriptRunner').new(game,game.overworld);runner:run(rows or {}) + T.eq(runner.ctx.g2Var,direction=='call' and 41 or 42,label..' '..direction..' executes correct native direction') + end + T.eq(C.availableCallers(d,game.save,d.maps.ROUTE_31,'DAY')[1],index,label..' native incoming selector sees added contact') + T.eq(#C.availableCallers(d,game.save,d.maps.PALLET_TOWN,'DAY'),0,label..' same-map incoming suppression remains native') + end + finish(r) + local clean=load(d) + T.eq(#clean.errors,0,label..' disabling/reloading succeeds') + T.eq(C.phoneName(d,1),'NATIVE MOM',label..' disabling restores base name') + T.eq(C.phoneName(d,2),'LOCAL JOEY',label..' disabling restores native trainer') + T.eq(d.map_scripts.phone[4],nil,label..' disabling removes added numeric row') + finish(clean) + end +end +V.set('crystal') +do + local d=fixture();local r=load(d,[[local p=mod.content.phone_contacts + p:patch('PHONE_MOM',{callee='IN',caller='OUT',callerTime=7}) + ]]) + T.eq(#r.errors,0,'caller and callee aliases are accepted') + T.eq(d.map_scripts.phone[1].call,'IN','callee alias replaces previous outgoing key') + T.eq(d.map_scripts.phone[1].receive,'OUT','caller alias replaces previous incoming key') + local p=r.loader.exports.phone.api.content.phone_contacts + T.eq(p:get('PHONE_MOM').callee,'IN','public callee alias reflects the effective native key') + T.eq(p:get('PHONE_MOM').caller,'OUT','public caller alias reflects the effective native key') + finish(r) +end +do + local d=fixture();local r=load(d,[[local p=mod.content.phone_contacts + p:register('FIRST',{name='FIRST',index=255}) + p:register('SECOND',{name='SECOND'}) + assert(p:get('SECOND').index==4) + p:remove('SECOND') + p:register('THIRD',{name='THIRD'}) + assert(p:get('THIRD').index==5) + p:patch('PHONE_JOEY',{name='TRANSLATED'}) + ]]) + T.eq(#r.errors,0,'explicit high index and removed additions retain reservations') + T.eq(C.phoneName(d,255),'FIRST','native byte upper bound can be used explicitly') + T.eq(C.phoneName(d,4),nil,'added then removed contact leaves no callable row') + T.eq(C.phoneName(d,5),'THIRD','next addition cannot steal removed contact index') + T.eq(d.map_scripts.phone[2].class,1,'display override retains canonical trainer identity') + T.eq(d.map_scripts.phone[2].trainer,1,'display override retains trainer member') + local api=r.loader.exports.phone.api.content.phone_contacts + T.check(not pcall(function() api:patch('PHONE_MOM',{name='LATE'}) end),'frozen content rejects late mutation') + T.eq(C.phoneName(d,1),'NATIVE MOM','late mutation cannot change native data') + d.map_scripts.phone[2].name='EXTERNAL AFTER LOAD' + finish(r) + local clean=load(d) + T.eq(C.phoneName(d,2),'EXTERNAL AFTER LOAD','reload preserves an externally edited native contact') + T.eq(d.map_scripts.phone[255],nil,'reload retires unchanged added high-index contact') + finish(clean) +end +do + local d=fixture();d.map_scripts=nil + local r=load(d,"mod.content.phone_contacts:register('NEW',{name='NEW'})") + T.eq(#r.errors,0,'a named silent contact needs no native script pool') + T.eq(C.phoneName(d,4),'NEW','new native book respects explicit metadata floor') + finish(r) + local clean=load(d) + T.eq(d.map_scripts,nil,'reload removes an exclusively owned empty pool namespace') + finish(clean) +end +for _,source in ipairs({ + "p:register('BAD',{name='BAD',index=3})", + "p:register('BAD',{name='BAD',index=2})", + "p:patch('PHONE_MOM',{index=7})", + "p:register('BAD',{name='BAD',callerTime=7})", + "p:register('BAD',{name='BAD',callerTime=7,callerKey='MISSING'})", + "p:register('BAD',{class='YOUNGSTER',member='MISSING'})", + "p:register('BAD',{name='BAD',map='MISSING'})", + "p:register('BAD',{name='BAD',callerTime=0/0})", + "local x={};x.x=x;p:register('BAD',x)", + "p:register('BAD',{name='BAD',index=256})", + "p:register('BAD',{name='BAD',index=0})", + "p:register('BAD',{name='BAD',unknownField=true})", + "p:register('BAD',{class='YOUNGSTER',member='JOEY1',number=1})", + "p:register('BAD',{name='BAD',callee='IN',calleeKey='OUT'})", + "p:patch('PHONE_MOM',{name='FAILED'});error('entry failure')", +}) do + local d=fixture();local r=load(d,'local p=mod.content.phone_contacts;'..source) + T.check(#r.errors>0,'invalid entry is rejected and attributed: '..source) + T.eq(C.phoneName(d,1),'NATIVE MOM','failed entry rolls back earlier phone operations') + T.eq(d.map_scripts.phone[4],nil,'failed entry cannot leak a native numeric contact') + finish(r) +end +for _,version in ipairs({'red','firered','emerald','heartgold'}) do + V.set(version);local d=fixture();local originalRow=d.map_scripts.phone[1] + local r=load(d,[[local p=mod.content.phone_contacts;assert(not p:supports());assert(p:get('PHONE_MOM')==nil);p:register('OTHER',{name='NO WRITE'})]]) + T.eq(#r.errors,0,version..' unsupported target reports through capability handle') + T.eq(d.map_scripts.phone[1],originalRow,version..' other generations retain native-looking data unchanged') + T.eq(d.map_scripts.phone[4],nil,version..' cannot acquire a Gen2 phone book') + finish(r) +end +V.set(original) +T.finish('semantic native phone contacts, actual VM/phone book, rollback and isolation') From 344531498c94e245d9c7a35f82f4f7c03c8b885a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:43:27 +0100 Subject: [PATCH 29/67] Keep explicit phone script keys authoritative during read edit write --- src/mods/PhoneContent.lua | 5 ++--- tests/engine/gen2_phone_content.lua | 9 ++++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/mods/PhoneContent.lua b/src/mods/PhoneContent.lua index 71878786c..b1a6372d5 100644 --- a/src/mods/PhoneContent.lua +++ b/src/mods/PhoneContent.lua @@ -193,9 +193,8 @@ function M.api(loader,mod,native,generation) for _,direction in ipairs({'callee','caller'}) do if payload[direction]~=nil then local key=direction..'Key' - assert(payload[key]==nil or payload[key]==payload[direction], - 'phone_contacts: conflicting script aliases') - payload[key]=payload[direction];payload[direction]=nil + if payload[key]==nil then payload[key]=payload[direction] end + payload[direction]=nil end end payload.index=indexFor(registry,assert(mounted[loader]),id,value) diff --git a/tests/engine/gen2_phone_content.lua b/tests/engine/gen2_phone_content.lua index 6e90948da..58450ec76 100644 --- a/tests/engine/gen2_phone_content.lua +++ b/tests/engine/gen2_phone_content.lua @@ -82,6 +82,14 @@ for _,version in ipairs({'gold','silver','crystal'}) do end end V.set('crystal') +do + local d=fixture();local r=load(d,[[local p=mod.content.phone_contacts + local row=p:get('PHONE_MOM');row.calleeKey='IN';p:override('PHONE_MOM',row) + ]]) + T.eq(#r.errors,0,'read/edit/write accepts an explicit script key alongside the prior alias') + T.eq(d.map_scripts.phone[1].call,'IN','explicit calleeKey owns the effective outgoing script') + finish(r) +end do local d=fixture();local r=load(d,[[local p=mod.content.phone_contacts p:patch('PHONE_MOM',{callee='IN',caller='OUT',callerTime=7}) @@ -144,7 +152,6 @@ for _,source in ipairs({ "p:register('BAD',{name='BAD',index=0})", "p:register('BAD',{name='BAD',unknownField=true})", "p:register('BAD',{class='YOUNGSTER',member='JOEY1',number=1})", - "p:register('BAD',{name='BAD',callee='IN',calleeKey='OUT'})", "p:patch('PHONE_MOM',{name='FAILED'});error('entry failure')", }) do local d=fixture();local r=load(d,'local p=mod.content.phone_contacts;'..source) From 884d963e76ac01ccb7d484d206466225c581c9a4 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:44:12 +0100 Subject: [PATCH 30/67] Name the native phone registry in its public API coverage test --- tests/engine/gen2_phone_content.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/engine/gen2_phone_content.lua b/tests/engine/gen2_phone_content.lua index 58450ec76..344faeb6c 100644 --- a/tests/engine/gen2_phone_content.lua +++ b/tests/engine/gen2_phone_content.lua @@ -34,7 +34,7 @@ for _,version in ipairs({'gold','silver','crystal'}) do local d=fixture();local native=d.map_scripts.phone[2] local r=load(d,[[ assert(mod.content.phone_contacts:supports()) - local p=mod.content.phone_contacts + local p=mod.content["phone_contacts"] assert(p:get('PHONE_MOM').number==1) assert(p:get('PHONE_MOM').map=='PALLET_TOWN') assert(p:get('PHONE_MOM').callee=='OUT' and p:get('PHONE_MOM').caller=='IN') From af26258273695acf80b5ca64017d345954638d0c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:39:00 +0100 Subject: [PATCH 31/67] Share ordered native phone slots and permanent reservations with Pokegear --- docs/native-gen2-phone-book.md | 35 +++++++ src/script/Gen2Commands.lua | 37 +++----- src/ui/PokegearMenu.lua | 44 ++++++--- src/world/Gen2PhoneBook.lua | 116 +++++++++++++++++++++++ tests/engine/gen2_phone_book.lua | 140 ++++++++++++++++++++++++++++ tests/engine/gen2_phone_content.lua | 4 +- 6 files changed, 337 insertions(+), 39 deletions(-) create mode 100644 docs/native-gen2-phone-book.md create mode 100644 src/world/Gen2PhoneBook.lua create mode 100644 tests/engine/gen2_phone_book.lua diff --git a/docs/native-gen2-phone-book.md b/docs/native-gen2-phone-book.md new file mode 100644 index 000000000..1a34bacaa --- /dev/null +++ b/docs/native-gen2-phone-book.md @@ -0,0 +1,35 @@ +# Native ordered phone book + +The native script verbs and Pokegear now share ten ordered phone slots. +The existing `save.g2Phone` registration set stays available; the additive +`g2PhoneSlots` array retains insertion order and script-deletion holes. +Older sets receive deterministic initial order. External set edits are +adopted without resurrecting deletions or renumbering contact IDs. Reads do +not discard overflow entries from historical oversized sets. + +Missing permanent numbers reserve slots, so eight ordinary contacts cannot +lock out Mom and Elm. Their imported non-trainer identities are authoritative; +explicit metadata and relocated rows take precedence over legacy retail +positions. The number being added never reserves against itself. Duplicate, +zero, unknown imported and full-book additions are refused. Native +checkcellnum retains the cartridge's empty-byte membership behavior. + +Script deletion leaves a hole. Pokegear DELETE compacts the actual slots, +while the incoming selector walks registration order. The native card shows +ten slots, uses explicit blank rows instead of invented Mom/Elm entries, +refuses calls on blanks and scrolls slots nine and ten into view. + +Validation:277 focused synthetic checks use actual Gen2ScriptVM/ScriptRunner +add/check/delete/ask opcodes, Pokegear input and drawing with only prompt, +audio and graphics IO substituted. Gold/Silver/Crystal exercise reservations, +ordering, external edits, slot deletion, empty rows, native ask result codes, +relocated permanent identities and visible slot-ten dispatch. This establishes +model/VM/UI behavior, not a hardware phone call or original story acceptance. +The existing content-bridge tests also check ten-slot native consumption. + +The public legacy Phone facade, phone.contact_list display hook, full special +call/rematch contracts, new authored phone scripts/forward references, and +fresh-import/GPU/story acceptance remain open. Full Gen1 parity is unproven. + +Original implementation by Roxas2712; existing upstream/third-party rights and +credits remain applicable. See NOTICE.md and LICENSE-ROXAS2712.md. diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..8a1f08387 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2128,8 +2128,8 @@ function Gen2Commands.availableCallers(data, save, mapDef, tod) local out = {} local group = mapDef and tonumber(mapDef.group) local number = mapDef and tonumber(mapDef.number) - for id, registered in pairs(phoneList(save)) do - if registered and type(id) == "number" then + for _, id in ipairs(require("src.world.Gen2PhoneBook").contacts(save)) do + if id ~= 0 then local entry = phoneContact(data, id) local mask = entry and tonumber(entry.receiveTime) or 0 -- `and [hl]` on the mask, then the map compare @@ -2139,7 +2139,6 @@ function Gen2Commands.availableCallers(data, save, mapDef, tod) end end end - table.sort(out) return out end @@ -2720,39 +2719,25 @@ end -- Phone_FindOpenSlot (36:$402D) fails when none of them is free. That cap is -- the whole point of the ".PhoneFull" branch every phone trainer carries -- an -- uncapped list can never reach it. -local GEN2_PHONE_SLOTS = 10 - -local function phoneListCount(save) - local n = 0 - for _, registered in pairs(phoneList(save)) do - if registered then n = n + 1 end - end - return n -end - -- AddPhoneNumber (36:$4000): carry means "not added" -- either the id is --- already in the list or there is no free slot. -local function addPhoneNumber(save, id) - local list = phoneList(save) - if list[id] then return false end - if phoneListCount(save) >= GEN2_PHONE_SLOTS then return false end - list[id] = true - return true +-- already in the list or there is no unreserved slot. Keep the same ordered +-- native book for the script verbs and Pokegear; the set remains compatible. +local function addPhoneNumber(ctx, id) + return require("src.world.Gen2PhoneBook").add( + ctx.data or (ctx.game and ctx.game.data), ctx.save, id) end function Commands.g2_cellnum(ctx, id, add) if type(id) ~= "number" then return end if add then - ctx.lastCheck = addPhoneNumber(ctx.save, id) + ctx.lastCheck = addPhoneNumber(ctx, id) else - local list = phoneList(ctx.save) - ctx.lastCheck = list[id] == true - list[id] = nil + ctx.lastCheck = require("src.world.Gen2PhoneBook").remove(ctx.save, id) end end function Commands.g2_check_cellnum(ctx, id) - ctx.lastCheck = phoneList(ctx.save)[id] == true + ctx.lastCheck = require("src.world.Gen2PhoneBook").has(ctx.save, id) ctx.g2Var = ctx.lastCheck and 1 or 0 end @@ -2783,7 +2768,7 @@ function Commands.g2_ask_cellnum(ctx, id, textId) ctx.g2Var = 2 return end - if addPhoneNumber(ctx.save, id) then + if addPhoneNumber(ctx, id) then ctx.g2Var = 0 else ctx.g2Var = 1 diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index f0da924a2..490c87acb 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -389,15 +389,30 @@ end -- `db PHONE_MOM, PHONE_ELM, -1`): those two can never be deleted. local GEN2_PERMANENT_NUMBERS = { [1] = true, [4] = true } -function PokegearMenu.canDeleteContact(entry) +function PokegearMenu.canDeleteContact(entry, data) local id = tonumber(entry and entry.id) - return id ~= nil and not GEN2_PERMANENT_NUMBERS[id] + if not id or id < 1 then return false end + if entry.permanent ~= nil then return not entry.permanent end + if data then return require("src.world.Gen2PhoneBook").canDelete(data, id) end + return not GEN2_PERMANENT_NUMBERS[id] end function PokegearMenu:contacts() local data = self.game.data local save = self.game.save + if require("src.core.GameVersion").isGen2() then + local Book = require("src.world.Gen2PhoneBook") + local Calls = require("src.script.Gen2Commands") + local permanent, out = Book.permanent(data), {} + for slot, id in ipairs(Book.contacts(save)) do + local name = Calls.phoneName(data,id) + out[slot] = { id=name and id or 0, slot=slot, name=name or "----------", + permanent=permanent[id] == true } + end + return out + end + local list = {} local registered = save and save.g2Phone if type(registered) == "table" then @@ -528,7 +543,7 @@ function PokegearMenu:update(dt) -- stay filled -- and the ROM's DELETE refuses only for the two -- PermanentNumbers, MOM and PROF.ELM. local entry = list[self.contact] - local canDelete = entry and PokegearMenu.canDeleteContact(entry) + local canDelete = entry and PokegearMenu.canDeleteContact(entry, self.game.data) if canDelete and (input:wasPressed("down") or input:wasPressed("up")) then self.phoneAction = self.phoneAction == "delete" and "call" or "delete" Sound.play(self.game.data, "Tink") @@ -539,9 +554,13 @@ function PokegearMenu:update(dt) self.phoneAction = nil self.callText = nil if entry and deleting then - require("src.script.Gen2Commands").phoneList(self.game.save)[entry.id] = nil + if require("src.core.GameVersion").isGen2() then + require("src.world.Gen2PhoneBook").deleteAt(self.game.save, entry.slot) + else + require("src.script.Gen2Commands").phoneList(self.game.save)[entry.id] = nil + end if self.contact > 1 then self.contact = self.contact - 1 end - elseif entry then + elseif entry and entry.id ~= 0 then self:startPhoneCall(entry) end elseif input:wasPressed("b") then @@ -558,7 +577,8 @@ function PokegearMenu:update(dt) self.contact = self.contact > 1 and self.contact - 1 or #list Sound.play(self.game.data, "Tink") elseif input:wasPressed("a") then - if #list > 0 then + local entry = list[self.contact] + if entry and entry.id ~= 0 then self.phoneSubmenu = true self.phoneAction = "call" Sound.play(self.game.data, "Press_AB") @@ -767,22 +787,22 @@ function PokegearMenu:drawPhone() self:drawStrip() local list = self:contacts() - if type(list) ~= "table" or #list == 0 then + if (type(list) ~= "table" or #list == 0) and not require("src.core.GameVersion").isGen2() then list = { { name = "MOM" }, { name = "PROF.ELM" } } end if self.contact < 1 then self.contact = 1 end if self.contact > #list then self.contact = #list end local selectedName = nil - -- One row per contact, starting at tile row 4 (inside the cream plate) + -- Keep the selected native slot visible, including slots nine and ten. + local top = math.max(0, self.contact - 8) for i, entry in ipairs(list) do local name = tostring( (type(entry) == "table" and (entry.name or entry.label)) or entry or "?" ):upper() - local ty = 3 + i -- MOM at row 4, next at 5, ... + local ty = 3 + i - top if ty > 11 then break end - - if i == self.contact then + if ty >= 4 and i == self.contact then selectedName = name -- THE CURSOR IS A GLYPH, NOT THE LETTER ">". -- @@ -806,7 +826,7 @@ function PokegearMenu:drawPhone() Font.draw(Strings(name), 18, ty * 8) if Font.endTint then Font.endTint() end love.graphics.setColor(1, 1, 1, 1) - else + elseif ty >= 4 then self:text(name, 3, ty) end end diff --git a/src/world/Gen2PhoneBook.lua b/src/world/Gen2PhoneBook.lua new file mode 100644 index 000000000..7f7949d65 --- /dev/null +++ b/src/world/Gen2PhoneBook.lua @@ -0,0 +1,116 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native ten-byte order beside the existing numeric registration set. Reads +-- retain legacy overflow entries; no save or contact ID is renumbered. +local M = { SIZE=10 } +local function valid(id) + return type(id)=='number' and id%1==0 and id>=1 and id<=255 +end +local function registered(save) + if type(save.g2Phone)~='table' then save.g2Phone={} end + return save.g2Phone +end +function M.slots(save) + local set=registered(save) + local slots=type(save.g2PhoneSlots)=='table' and save.g2PhoneSlots or {} + save.g2PhoneSlots=slots + local seen={} + for i=1,M.SIZE do + local id=slots[i] + if valid(id) and set[id] and not seen[id] then seen[id]=true + else slots[i]=0 end + end + local pending={} + for id,on in pairs(set) do + if on and valid(id) and not seen[id] then pending[#pending+1]=id end + end + table.sort(pending) + local cursor=1 + for i=1,M.SIZE do + if slots[i]==0 and pending[cursor] then slots[i]=pending[cursor];cursor=cursor+1 end + end + return slots +end +function M.contacts(save) + local slots,out=M.slots(save),{} + for i=1,M.SIZE do out[i]=slots[i] end + return out +end +function M.has(save,id) + if id==0 then + for _,value in ipairs(M.slots(save)) do if value==0 then return true end end + return false + end + return valid(id) and registered(save)[id] and true or false +end +function M.permanent(data) + local out={} + local pool=data and data.map_scripts and data.map_scripts.phone + local constants=data and data.constants or {} + local order=constants.phoneContactOrder or {} + local version=require('src.core.GameVersion').get() + local retail=version=='gold' or version=='silver' or version=='crystal' + for id,row in pairs(pool or {}) do + if valid(id) and type(row)=='table' and not (row.class and row.class~=0) then + local number=row.contactNumber + if number==1 or number==4 or number==nil and + (order[id+1]=='PHONE_MOM' or order[id+1]=='PHONE_ELM' + or retail and constants.phoneContactOrder==nil and (id==1 or id==4)) then out[id]=true end + end + end + -- Before import, the retail constants still reserve their two slots. A + -- custom cartridge receives no guessed retail identities. + if pool==nil and retail and constants.phoneContactOrder==nil then out[1],out[4]=true,true end + return out +end +function M.remainingSlots(data,save,id) + local missing=0 + for permanent in pairs(M.permanent(data)) do + if permanent~=id and not M.has(save,permanent) then missing=missing+1 end + end + return math.max(0,M.SIZE-missing) +end +function M.openSlot(data,save,id) + local slots=M.slots(save) + for i=1,M.remainingSlots(data,save,id) do if slots[i]==0 then return i end end +end +function M.add(data,save,id) + if not valid(id) then return false,'unknown' end + local pool=data and data.map_scripts and data.map_scripts.phone + if pool and type(pool[id])~='table' then return false,'unknown' end + if M.has(save,id) then return false,'already' end + local slot=M.openSlot(data,save,id) + if not slot then return false,'full' end + registered(save)[id]=true;save.g2PhoneSlots[slot]=id + return true +end +function M.remove(save,id) + local slots=M.slots(save) + for i=1,M.SIZE do + if slots[i]==id then + slots[i]=0 + if id~=0 then registered(save)[id]=nil end + return true + end + end + -- An old oversized flat set remains recoverable, including deletion of an + -- entry that could not fit into the ten hardware slots yet. + if valid(id) and registered(save)[id] then registered(save)[id]=nil;return true end + return false +end +function M.deleteAt(save,slot) + if type(slot)~='number' or slot%1~=0 or slot<1 or slot>M.SIZE then return false end + local slots=M.slots(save);local id=slots[slot] + if id==0 then return false end + registered(save)[id]=nil;slots[slot]=0 + local write=1 + for read=1,M.SIZE do + if slots[read]~=0 then slots[write]=slots[read];write=write+1 end + end + for i=write,M.SIZE do slots[i]=0 end + return true +end +function M.canDelete(data,id) + local pool=data and data.map_scripts and data.map_scripts.phone + return valid(id) and pool and type(pool[id])=='table' and not M.permanent(data)[id] or false +end +return M diff --git a/tests/engine/gen2_phone_book.lua b/tests/engine/gen2_phone_book.lua new file mode 100644 index 000000000..f030ebce0 --- /dev/null +++ b/tests/engine/gen2_phone_book.lua @@ -0,0 +1,140 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +local Book=require('src.world.Gen2PhoneBook') +local Calls=require('src.script.Gen2Commands') +local Gear=require('src.ui.PokegearMenu') +local function data() + local d=T.fixtures.fresh();d.map_scripts={phone={},scripts={STUB={{'end'}}}} + for id=1,24 do d.map_scripts.phone[id]={name='CONTACT'..id,class=1,trainer=1, + receive='STUB',receiveTime=7,call='STUB',callTime=7,group=1,number=1} end + d.map_scripts.phone[1]={name='MOM',contactNumber=1} + d.map_scripts.phone[4]={name='ELM',contactNumber=4} + return d +end +local function same(actual,expected,label) + T.eq(#actual,#expected,label..' length') + for i,id in ipairs(expected) do T.eq(actual[i],id,label..' slot'..i) end +end +for _,version in ipairs({'gold','silver','crystal'}) do + V.set(version);local d,save=data(),{} + local label=version..': ' + T.eq(Book.remainingSlots(d,save,2),8,label..' absent permanent numbers reserve two slots') + T.eq(Book.remainingSlots(d,save,1),9,label..' a permanent number does not reserve against itself') + T.eq(Book.has(save,0),true,label..' native empty-byte membership semantics retained') + local trainerIds={2,3,5,6,7,8,9,10} + for _,id in ipairs(trainerIds) do T.eq(Book.add(d,save,id),true,label..' ordinary number admitted within unreserved run') end + T.eq(Book.add(d,save,11),false,label..' ninth trainer cannot consume missing permanent slots') + T.eq(Book.add(d,save,1),true,label..' Mom can occupy her reserved slot') + T.eq(Book.add(d,save,4),true,label..' Elm can occupy his reserved slot') + same(Book.contacts(save),{2,3,5,6,7,8,9,10,1,4},label..' insertion order reaches all ten slots') + T.eq(Book.has(save,0),false,label..' full native array contains no empty byte') + local ok,why=Book.add(d,save,2);T.eq(ok,false,label..' duplicate refused');T.eq(why,'already',label..' duplicate reason retained') + T.eq(Book.add(d,save,0),false,label..' zero never becomes a registered contact') + T.eq(Book.add(d,save,255),false,label..' absent contact cannot become callable') + T.eq(Book.canDelete(d,1),false,label..' Mom is permanent in the native UI') + T.eq(Book.canDelete(d,4),false,label..' Elm is permanent in the native UI') + T.eq(Book.canDelete(d,2),true,label..' trainer number is deletable') + T.eq(Book.remove(save,5),true,label..' script deletion succeeds') + T.eq(Book.contacts(save)[3],0,label..' script deletion leaves a physical hole') + T.eq(Book.contacts(save)[4],6,label..' script deletion does not reorder later slots') + T.eq(Book.add(d,save,11),true,label..' next addition occupies the first hole') + T.eq(Book.contacts(save)[3],11,label..' reusing a hole keeps remaining order') + T.eq(Book.deleteAt(save,2),true,label..' Pokegear deletion succeeds') + same(Book.contacts(save),{2,11,6,7,8,9,10,1,4,0},label..' only Pokegear deletion compacts') + T.eq(save.g2Phone[3],nil,label..' deletion updates the existing numeric set') + local detached=Book.contacts(save);detached[1]=24 + T.eq(Book.contacts(save)[1],2,label..' returned slot arrays are detached') + save.g2Phone[23]=true + T.eq(Book.contacts(save)[10],23,label..' old/native set writes are adopted without reordering') + save.g2Phone[11]=nil + T.eq(Book.contacts(save)[2],0,label..' external native deletion is not resurrected') + local selected=Calls.availableCallers(d,save,{group=2,number=2},'DAY') + same(selected,{2,6,7,8,9,10,23},label..' actual incoming selector walks physical registration order') + -- Actual imported-opcode compiler and native ScriptRunner, including the + -- askforphonenumber result branches. Only the yes/no prompt is an IO seam. + local fresh={};local game={data=d,save=fresh,overworld={}} + local serial=0 + local function run(rows) + serial=serial+1;local key='BOOK'..serial + d.map_scripts.scripts[key]=rows + local lowered=assert(require('src.script.Gen2ScriptVM').compile(d,key)) + local runner=require('src.script.ScriptRunner').new(game,game.overworld);runner:run(lowered) + T.eq(runner:isRunning(),false,label..' native book script returns') + return runner.ctx + end + local ctx=run({{'addcellnum',7},{'checkcellnum',7},{'end'}}) + T.eq(ctx.g2Var,1,label..' actual native add/check writes the book') + T.eq(Book.contacts(fresh)[1],7,label..' VM and UI share native slot ownership') + ctx=run({{'delcellnum',7},{'checkcellnum',7},{'end'}}) + T.eq(ctx.g2Var,0,label..' actual native delete clears membership') + ctx=run({{'checkcellnum',0},{'end'}}) + T.eq(ctx.g2Var,1,label..' actual native checkcellnum sees an empty slot') + local Commands=require('src.script.Commands');local ask=Commands.ask + local accepted=true;Commands.ask=function(c)c.lastCheck=accepted end + ctx=run({{'askforphonenumber',8},{'end'}}) + T.eq(ctx.g2Var,0,label..' actual accepted ask returns CONTACT_GOT') + ctx=run({{'askforphonenumber',8},{'end'}}) + T.eq(ctx.g2Var,1,label..' actual duplicate ask returns CONTACTS_FULL') + accepted=false;ctx=run({{'askforphonenumber',9},{'end'}}) + T.eq(ctx.g2Var,2,label..' actual refused ask returns CONTACT_REFUSED') + T.eq(fresh.g2Phone[9],nil,label..' refusal does not mutate native membership') + Commands.ask=ask +end +V.set('crystal') +do + local d=data();d.map_scripts.phone[1]={class=1,name='TRAINER1'} + d.map_scripts.phone[4]={class=1,name='TRAINER4'} + d.map_scripts.phone[12]={name='AUTHORED MOM',contactNumber=1} + d.map_scripts.phone[14]={name='AUTHORED ELM',contactNumber=4} + T.eq(Book.canDelete(d,1),true,'native trainer identity wins over the old Mom slot') + T.eq(Book.canDelete(d,4),true,'native trainer identity wins over the old Elm slot') + T.eq(Book.canDelete(d,12),false,'explicit relocated Mom identity is permanent') + T.eq(Book.canDelete(d,14),false,'explicit relocated Elm identity is permanent') + d.constants.phoneContactOrder={};d.map_scripts.phone[1]={name='CUSTOM'} + T.eq(Book.canDelete(d,1),true,'authored empty identity metadata does not receive retail guesses') +end +do + local d=data();local save={g2Phone={[7]=true,[2]=true,[5]=true}} + same(Book.contacts(save),{2,5,7,0,0,0,0,0,0,0},'legacy set receives deterministic initial slot order') + save.g2PhoneSlots={7,2,2,0,0,0,0,0,0,0} + same(Book.contacts(save),{7,2,5,0,0,0,0,0,0,0},'stored order survives and duplicate slots are repaired') + local gear=setmetatable({game={data=d,save=save},contact=1},{__index=Gear}) + local rows=gear:contacts();T.eq(#rows,10,'actual native card exposes ten physical slots') + T.eq(rows[1].id,7,'actual native card does not sort away registration order') + T.eq(rows[4].id,0,'empty native row is not callable') + T.eq(rows[4].name,'----------','empty native row is displayed explicitly') + local empty=setmetatable({game={data=d,save={}}},{__index=Gear}):contacts() + T.eq(empty[1].id,0,'empty book cannot invent a Mom registration') + T.eq(empty[4].id,0,'empty book cannot invent an Elm registration') + local key;gear.cards={{id='PHONE'}};gear.index=1;gear.blink=0 + gear.game.input={wasPressed=function(_,value)return key==value end} + gear.game.stack={pop=function()end} + local Sound=require('src.core.Sound');local sound=Sound.play;Sound.play=function()end + gear.contact=4;key='a';gear:update(0) + T.eq(gear.phoneSubmenu,nil,'real native A input cannot call an empty slot') + gear.contact=2;key='a';gear:update(0);key='down';gear:update(0);key='a';gear:update(0) + T.eq(save.g2Phone[2],nil,'real native DELETE input removes the selected physical slot') + same(Book.contacts(save),{7,5,0,0,0,0,0,0,0,0},'real Pokegear delete compacts the shared book') + Sound.play=sound +end +do + local d=data();local save={g2Phone={},g2PhoneSlots={2,3,5,6,7,8,9,10,1,4}} + for _,id in ipairs(save.g2PhoneSlots)do save.g2Phone[id]=true end + local gear=setmetatable({game={data=d,save=save},contact=10,gfx={}},{__index=Gear}) + local drawn={};gear.drawStrip=function()end;gear.textbox=function()end + gear.printBoxText=function()end;gear.drawModeArrow=function()end + gear.text=function(_,name,_,y)drawn[name]=y end + local Font=require('src.render.Font');local old={Font.draw,Font.drawCode,Font.beginTint,Font.endTint} + local graphics=love.graphics;love.graphics={setColor=function()end,rectangle=function()end} + Font.draw=function(name,_,y)drawn[name]=y/8 end + Font.drawCode=function()end;Font.beginTint=function()end;Font.endTint=function()end + gear:drawPhone() + T.eq(drawn.ELM,11,'actual native drawing keeps selected slot ten visible') + T.eq(drawn.MOM,10,'actual native drawing retains slot nine beside it') + T.eq(drawn.CONTACT2,nil,'scrolling does not draw hidden slot one') + Font.draw,Font.drawCode,Font.beginTint,Font.endTint=unpack(old);love.graphics=graphics +end +V.set(original) +T.finish('native ordered phone slots, permanent reservations, actual VM and Pokegear') diff --git a/tests/engine/gen2_phone_content.lua b/tests/engine/gen2_phone_content.lua index 344faeb6c..044b6896d 100644 --- a/tests/engine/gen2_phone_content.lua +++ b/tests/engine/gen2_phone_content.lua @@ -62,7 +62,9 @@ for _,version in ipairs({'gold','silver','crystal'}) do if index then local game={data=d,save={g2Phone={[index]=true}},overworld={}} local gear=setmetatable({game=game},{__index=Gear}) - T.eq(#gear:contacts(),1,label..' actual native saved phone book sees addition') + T.eq(#gear:contacts(),10,label..' actual native phone book preserves ten physical slots') + T.eq(gear:contacts()[1].id,index,label..' registered addition occupies the first native slot') + T.eq(gear:contacts()[2].id,0,label..' unused native slot cannot invent a callable contact') for _,direction in ipairs({'call','receive'}) do local rows=direction=='call' and C.phoneCallScript(d,index) or C.phoneReceiveScript(d,index) T.check(rows~=nil,label..' '..direction..' compiles through native VM') From 97ea982bc1c06282dcc863103daaa5b0a204c7d3 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:44:42 +0100 Subject: [PATCH 32/67] Document offered contribution permission for the native phone book --- docs/native-gen2-phone-book.md | 2 +- src/world/Gen2PhoneBook.lua | 2 +- tests/engine/gen2_phone_book.lua | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/native-gen2-phone-book.md b/docs/native-gen2-phone-book.md index 1a34bacaa..825fb775b 100644 --- a/docs/native-gen2-phone-book.md +++ b/docs/native-gen2-phone-book.md @@ -32,4 +32,4 @@ call/rematch contracts, new authored phone scripts/forward references, and fresh-import/GPU/story acceptance remain open. Full Gen1 parity is unproven. Original implementation by Roxas2712; existing upstream/third-party rights and -credits remain applicable. See NOTICE.md and LICENSE-ROXAS2712.md. +credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/src/world/Gen2PhoneBook.lua b/src/world/Gen2PhoneBook.lua index 7f7949d65..db0ba6d25 100644 --- a/src/world/Gen2PhoneBook.lua +++ b/src/world/Gen2PhoneBook.lua @@ -1,4 +1,4 @@ --- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Copyright (c) 2026 Roxas2712. See the offered PR contribution permission in docs/native-gen2-phone-book.md. -- Native ten-byte order beside the existing numeric registration set. Reads -- retain legacy overflow entries; no save or contact ID is renumbered. local M = { SIZE=10 } diff --git a/tests/engine/gen2_phone_book.lua b/tests/engine/gen2_phone_book.lua index f030ebce0..06f472afd 100644 --- a/tests/engine/gen2_phone_book.lua +++ b/tests/engine/gen2_phone_book.lua @@ -1,4 +1,4 @@ --- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Copyright (c) 2026 Roxas2712. See the offered PR contribution permission in docs/native-gen2-phone-book.md. package.path='./?.lua;./?/init.lua;'..package.path local T=require('tests.modkit') local V=require('src.core.GameVersion');local original=V.get() From 8fea1351c63bb248963603157169e92b2f39245c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:57:49 +0100 Subject: [PATCH 33/67] Expose native phone list display hook and preserve deletion identity --- docs/native-gen2-phone-book.md | 4 +- docs/native-gen2-phone-list-hook.md | 33 ++++++++ src/ui/PokegearMenu.lua | 30 ++++++- tests/engine/gen2_phone_list_hook.lua | 117 ++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 docs/native-gen2-phone-list-hook.md create mode 100644 tests/engine/gen2_phone_list_hook.lua diff --git a/docs/native-gen2-phone-book.md b/docs/native-gen2-phone-book.md index 825fb775b..e5d1868ca 100644 --- a/docs/native-gen2-phone-book.md +++ b/docs/native-gen2-phone-book.md @@ -27,8 +27,8 @@ relocated permanent identities and visible slot-ten dispatch. This establishes model/VM/UI behavior, not a hardware phone call or original story acceptance. The existing content-bridge tests also check ten-slot native consumption. -The public legacy Phone facade, phone.contact_list display hook, full special -call/rematch contracts, new authored phone scripts/forward references, and +The display hook is covered separately in [native-gen2-phone-list-hook.md](native-gen2-phone-list-hook.md). +The public legacy Phone facade, full special call/rematch contracts, new authored phone scripts/forward references, and fresh-import/GPU/story acceptance remain open. Full Gen1 parity is unproven. Original implementation by Roxas2712; existing upstream/third-party rights and diff --git a/docs/native-gen2-phone-list-hook.md b/docs/native-gen2-phone-list-hook.md new file mode 100644 index 000000000..da8fbf286 --- /dev/null +++ b/docs/native-gen2-phone-list-hook.md @@ -0,0 +1,33 @@ +# Native phone list display hook + +Gold, Silver and Crystal now dispatch `phone.contact_list(save, list)` when +the native Pokegear reads its contacts. The signature follows the read-only +Gen1 reference at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, +`src/core/gen2/Phone.lua`: the save remains an opaque native handle and the +list is a detached array of ten numeric contact IDs, with zero for blanks. +Native API 1/2 share this signature. Other generations do not dispatch this +native Pokegear hook. + +Mods can reorder or hide display rows and show contacts registered through +`phone_contacts`. A return value of the wrong type or length retains the +native display. Invalid, fractional, non-finite and unknown contact IDs +become blanks without shifting slots. Mutating the display list does not +register contacts, reorder the saved phone book or filter incoming callers. + +Each displayed contact retains its actual physical slot. The native DELETE +action therefore removes the selected contact even after display reordering; +an injected non-member cannot delete an unrelated saved slot. Unsubscribing, +failed-entry rollback and loader teardown restore the native presentation. + +Validation:focused checks use the actual Loader, content registry, +Pokegear contacts/input and incoming selector across all three editions and +two native API levels. They cover invalid returns, detached arrays, +saved-book isolation, reordered deletion, injected non-members, rollback and +other-generation dispatch isolation. This is synthetic behavioral +acceptance; no new hardware phone-call or original-story claim is made. + +The complete legacy Phone facade, special-call/rematch contracts, authored +phone scripts/forward references and full story acceptance remain open. + +Original contributions by Roxas2712; existing reference, upstream and +third-party rights and credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index 490c87acb..011f6f2af 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -5,6 +5,7 @@ local Theme = require("src.ui.Theme") local Screens = require("src.ui.Screens") local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") +local Runtime = require("src.mods.Runtime") local PokegearMenu = {} PokegearMenu.__index = PokegearMenu @@ -404,10 +405,31 @@ function PokegearMenu:contacts() if require("src.core.GameVersion").isGen2() then local Book = require("src.world.Gen2PhoneBook") local Calls = require("src.script.Gen2Commands") + local slots = Book.contacts(save) + if Runtime.wantsHook("phone.contact_list") then + -- Save is an opaque native handle; the display list is detached. The + -- incoming selector continues to use the unhooked physical book. + local shown = Runtime.call("phone.contact_list", function(_, list) return list end, + save, Book.contacts(save)) + if type(shown) == "table" and #shown == Book.SIZE then + slots = {} + for i = 1, Book.SIZE do + local value = shown[i] + local id = (type(value) == "number" or type(value) == "string") and tonumber(value) + slots[i] = id and id % 1 == 0 and id >= 1 and id <= 255 + and Calls.phoneContact(data, id) and id or 0 + end + end + end + local physical = Book.contacts(save) local permanent, out = Book.permanent(data), {} - for slot, id in ipairs(Book.contacts(save)) do + for displaySlot, id in ipairs(slots) do local name = Calls.phoneName(data,id) - out[slot] = { id=name and id or 0, slot=slot, name=name or "----------", + local slot = id == 0 and displaySlot or nil + if id ~= 0 then + for i, nativeId in ipairs(physical) do if nativeId == id then slot = i; break end end + end + out[displaySlot] = { id=name and id or 0, slot=slot, name=name or "----------", permanent=permanent[id] == true } end return out @@ -555,7 +577,9 @@ function PokegearMenu:update(dt) self.callText = nil if entry and deleting then if require("src.core.GameVersion").isGen2() then - require("src.world.Gen2PhoneBook").deleteAt(self.game.save, entry.slot) + local Book = require("src.world.Gen2PhoneBook") + if entry.slot then Book.deleteAt(self.game.save, entry.slot) + else Book.remove(self.game.save, entry.id) end else require("src.script.Gen2Commands").phoneList(self.game.save)[entry.id] = nil end diff --git a/tests/engine/gen2_phone_list_hook.lua b/tests/engine/gen2_phone_list_hook.lua new file mode 100644 index 000000000..b1f0c959b --- /dev/null +++ b/tests/engine/gen2_phone_list_hook.lua @@ -0,0 +1,117 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-phone-list-hook.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local Gear=require('src.ui.PokegearMenu');local Book=require('src.world.Gen2PhoneBook') +local Calls=require('src.script.Gen2Commands') +local function fixture() + local d=T.fixtures.fresh();d.constants.phoneContactOrder={'PHONE_00','PHONE_MOM','U2','U3','PHONE_ELM','U5','U6','PHONE_A','U8','PHONE_B'} + d.map_scripts={phoneInfo={size=10},scripts={STUB={{'end'}}},phone={ + [1]={name='MOM',contactNumber=1},[4]={name='ELM',contactNumber=4}, + [7]={name='A',receive='STUB',receiveTime=7,group=1,number=1}, + [9]={name='B',receive='STUB',receiveTime=7,group=1,number=1}}} + return d +end +local function load(d,source,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots()) do d[root]=nil end + local files={} + if source then + files['mods/phone_hook/manifest.json']=Json.encode({id='phone_hook',name='Phone list',version='1.0.0', + api=mode and mode[1] or 2,content_format=mode and mode[2] or 'native',entry='main.lua',affects_link=false}) + files['mods/phone_hook/main.lua']='local mod=...;mod.exports.api=mod;'..source + end + return T.sdk.loadMods(source and {'mods/phone_hook'} or {},{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +for _,version in ipairs({'gold','silver','crystal'}) do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}}) do + local d=fixture();local r=load(d,[[ + local added=mod.content.phone_contacts:register('PHONE_NEW',{name='AUTHORED NEW'}) + mod.exports.index=added.index + mod.exports.stop=mod.hooks:wrap("phone.contact_list",function(next,save,list) + mod.exports.calls=(mod.exports.calls or 0)+1;mod.exports.save=save;mod.exports.received=list + local rows=next(save,list);rows[1]=9;rows[2]=0;rows[3]='7';rows[10]=added.index + return rows + end) + ]],mode) + local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' real public hook registration succeeds') + local save={g2Phone={[7]=true,[9]=true},g2PhoneSlots={7,9,0,0,0,0,0,0,0,0}} + local gear=setmetatable({game={data=d,save=save}},{__index=Gear}) + local rows=gear:contacts();local e=r.loader.exports.phone_hook + T.eq(e.calls,1,label..' native card dispatches the hook exactly once') + T.eq(e.save,save,label..' opaque native save handle retains reference signature') + T.eq(#rows,10,label..' presentation retains ten slots') + T.eq(rows[1].id,9,label..' display hook can reorder native contacts') + T.eq(rows[1].slot,2,label..' reordered row retains its actual physical slot') + T.eq(rows[2].id,0,label..' display hook can hide a row') + T.eq(rows[3].id,7,label..' numeric contact spellings resolve through native records') + T.eq(rows[3].slot,1,label..' reordered selected ID does not acquire a different native slot') + T.eq(rows[10].name,'AUTHORED NEW',label..' hook sees actual native content registration') + T.eq(rows[10].slot,nil,label..' an injected display row cannot borrow another physical slot') + T.eq(save.g2Phone[e.index],nil,label..' showing a contact does not register it') + T.eq(Book.contacts(save)[1],7,label..' saved registration order remains native') + local incoming=Calls.availableCallers(d,save,{group=2,number=1},'DAY') + T.eq(incoming[1],7,label..' incoming caller selection ignores display reorder') + T.eq(incoming[2],9,label..' hidden display contact remains eligible for native incoming calls') + T.eq(e.calls,1,label..' incoming call selection does not dispatch the display hook') + e.received[1]=255;rows[1].name='READ MUTATION' + T.eq(Book.contacts(save)[1],7,label..' received display arrays cannot mutate the native book') + T.eq(d.map_scripts.phone[9].name,'B',label..' returned UI rows cannot mutate native contacts') + local key;gear.cards={{id='PHONE'}};gear.index=1;gear.blink=0;gear.contact=1 + gear.game.input={wasPressed=function(_,value)return key==value end};gear.game.stack={pop=function()end} + local Sound=require('src.core.Sound');local old=Sound.play;Sound.play=function()end + key='a';gear:update(0);key='down';gear:update(0);key='a';gear:update(0) + T.eq(save.g2Phone[9],nil,label..' actual DELETE targets the reordered contact ID') + T.eq(save.g2Phone[7],true,label..' display position cannot delete the unrelated physical contact') + gear.contact=1;key='a';gear:update(0);key='down';gear:update(0);key='a';gear:update(0) + T.eq(save.g2Phone[7],true,label..' deleting an injected non-member never consumes another native slot') + Sound.play=old + e.stop();rows=gear:contacts() + T.eq(rows[1].id,7,label..' unsubscribe restores the actual native presentation') + T.eq(rows[2].id,0,label..' unsubscribe does not recreate the deleted contact') + finish(r) + end +end +V.set('crystal') +for _,expression in ipairs({'nil','false','"bad"','{9}','{9,7,0,0,0,0,0,0,0,0,0}'}) do + local d=fixture();local r=load(d,'mod.hooks:wrap("phone.contact_list",function()return '..expression..' end)') + T.eq(#r.errors,0,'invalid display return does not prevent entry loading') + local save={g2Phone={[7]=true}};local rows=setmetatable({game={data=d,save=save}},{__index=Gear}):contacts() + T.eq(rows[1].id,7,'wrong return type/length keeps the real native display: '..expression) + T.eq(#rows,10,'wrong return type/length keeps native slot count') + finish(r) +end +do + local d=fixture();local r=load(d,[[mod.hooks:wrap("phone.contact_list",function() + return {999,-1,1.5,0/0,math.huge,{},true,'7','bad',9} + end)]]) + local save={g2Phone={[7]=true,[9]=true}} + local rows=setmetatable({game={data=d,save=save}},{__index=Gear}):contacts() + for i=1,7 do T.eq(rows[i].id,0,'invalid callback contact becomes a blank at slot'..i) end + T.eq(rows[8].id,7,'valid numeric string still resolves') + T.eq(rows[9].id,0,'invalid text becomes a blank') + T.eq(rows[10].id,9,'valid last native contact is retained') + T.eq(save.g2Phone[7],true,'sanitization preserves native registration') + finish(r) +end +do + local d=fixture();local r=load(d,[[mod.hooks:wrap("phone.contact_list",function()return {9,0,0,0,0,0,0,0,0,0}end);error('entry failure')]]) + T.check(#r.errors>0,'failed entry is reported') + local rows=setmetatable({game={data=d,save={g2Phone={[7]=true}}}},{__index=Gear}):contacts() + T.eq(rows[1].id,7,'failed-entry rollback removes its phone hook') + finish(r) +end +for _,version in ipairs({'red','firered','emerald','heartgold'}) do + V.set(version);local d=fixture();d.field.phone={contacts={{id=99,name='LEGACY'}}} + local r=load(d,[[mod.exports.calls=0 + mod.hooks:wrap("phone.contact_list",function(next,...)mod.exports.calls=mod.exports.calls+1;return next(...)end)]]) + T.eq(#r.errors,0,version..' hook registration remains available without a native call site') + local rows=setmetatable({game={data=d,save={}}},{__index=Gear}):contacts() + T.eq(rows[1].name,'LEGACY',version..' native-looking phone records do not replace authored presentation') + T.eq(r.loader.exports.phone_hook.calls,0,version..' cannot dispatch a Gen2-only display hook') + finish(r) +end +V.set(original) +T.finish('native phone.contact_list hook, actual card/delete, saved-book isolation and rollback') From a2e3034f88dee3c1169e12a194d8729d85b0535f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:00:38 +0100 Subject: [PATCH 34/67] Preserve native phone caller byte selection and RNG consumption --- docs/native-gen2-phone-call-selection.md | 24 +++++++ src/script/Gen2Commands.lua | 14 +++- tests/engine/gen2_phone_call_selection.lua | 78 ++++++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 docs/native-gen2-phone-call-selection.md create mode 100644 tests/engine/gen2_phone_call_selection.lua diff --git a/docs/native-gen2-phone-call-selection.md b/docs/native-gen2-phone-call-selection.md new file mode 100644 index 000000000..18b995104 --- /dev/null +++ b/docs/native-gen2-phone-call-selection.md @@ -0,0 +1,24 @@ +# Native incoming caller selection + +The native incoming-call path now consumes one unsigned random byte for +ChooseRandomCaller, swaps its nibbles, masks to five bits and reduces it by +the eligible caller count. Previously it drew a uniform Lua list index, +which changed the native byte-to-contact mapping. Candidates retain saved +phone-slot order, independently of the display hook. + +The timer expiry remains consumed before the coin flip; service and empty +book gates consume no caller-choice byte. A successful choice still compiles +and executes the selected contact's imported receive program and resets the +receive delay. The entrance gate leaves both the timer and RNG untouched. + +The algorithm and gate order were compared with the read-only reference +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, +`src/core/gen2/Phone.lua` (ChooseRandomCaller and CheckPhoneCall). +Synthetic tests exhaust all256 byte values for each book size1..10 against +an independent bitwise oracle and exercise actual native call selection, +Gen2ScriptVM and ScriptRunner in Gold/Silver/Crystal. This does not establish +complete radio/phone parity, a hardware call or original-story acceptance. + +Original contributions by Roxas2712; existing reference, upstream and +third-party rights and credits remain applicable. See NOTICE.md and +LICENSE-ROXAS2712.md. diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index 8a1f08387..60e494371 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2122,7 +2122,7 @@ function Gen2Commands.phoneCallDelay(save) return GEN2_CALL_DELAYS[math.min(cycles, #GEN2_CALL_DELAYS - 1) + 1] end --- The sample GetAvailableCallers builds, in PHONE_* order. +-- The sample GetAvailableCallers builds, in saved phone-slot order. function Gen2Commands.availableCallers(data, save, mapDef, tod) local bit = GEN2_TOD_BIT[tod] or 2 local out = {} @@ -2142,6 +2142,16 @@ function Gen2Commands.availableCallers(data, save, mapDef, tod) return out end +-- ChooseRandomCaller swaps one Random byte's nibbles, masks to five bits, +-- then takes the remainder against the eligible count. Preserve that exact +-- mapping and byte consumption rather than drawing a uniform list index. +function Gen2Commands.chooseRandomCaller(callers, rng) + if not callers or #callers == 0 then return nil end + local byte = (rng and rng() or math.random(0, 255)) % 256 + local swapped = (byte % 16) * 16 + math.floor(byte / 16) + return callers[(swapped % 32) % #callers + 1] +end + -- Returns the caller id and its compiled receive script, or nil. `minutes` is -- the play clock in minutes, which stands in for the ROM's day/hour/minute -- countdown; `entrance` is CheckStandingOnEntrance. @@ -2189,7 +2199,7 @@ function Gen2Commands.rollIncomingCall(data, save, mapDef, tod, minutes, entranc if math.floor(palette / 16) % 16 ~= 0 then return nil end local callers = Gen2Commands.availableCallers(data, save, mapDef, tod) if #callers == 0 then return nil end - local id = callers[math.random(#callers)] + local id = Gen2Commands.chooseRandomCaller(callers) local script = Gen2Commands.phoneReceiveScript(data, id) if not script then return nil end -- InitCallReceiveDelay at the tail of Script_ReceivePhoneCall (36:$42B7): diff --git a/tests/engine/gen2_phone_call_selection.lua b/tests/engine/gen2_phone_call_selection.lua new file mode 100644 index 000000000..e89503baa --- /dev/null +++ b/tests/engine/gen2_phone_call_selection.lua @@ -0,0 +1,78 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Calls=require('src.script.Gen2Commands') +local V=require('src.core.GameVersion');local original=V.get() +local bit=require('bit') +local consumed=0 +local function unused()consumed=consumed+1;return 0 end +T.eq(Calls.chooseRandomCaller(nil,unused),nil,'missing candidate list has no caller') +T.eq(Calls.chooseRandomCaller({},unused),nil,'empty candidate list has no caller') +T.eq(consumed,0,'empty selection consumes no random byte') +for n=1,10 do + local callers={};for i=1,n do callers[i]=100+i end + for byte=0,255 do + local used=0 + local got=Calls.chooseRandomCaller(callers,function()used=used+1;return byte end) + -- Independent bytewise SWAP / AND $1f / division remainder oracle. + local swapped=bit.band(bit.bor(bit.lshift(byte,4),bit.rshift(byte,4)),255) + local expected=callers[bit.band(swapped,31)%n+1] + T.eq(got,expected,'native byte mapping n='..n..' byte='..byte) + T.eq(used,1,'native choice consumes exactly one byte') + end +end +local old=math.random +for _,version in ipairs({'gold','silver','crystal'}) do + V.set(version) + local data={map_scripts={scripts={},phone={}}} + local ids={9,7,11} + for i,id in ipairs(ids) do + local label='NATIVE_RECEIVE_'..version..'_'..id + data.map_scripts.phone[id]={receive=label,receiveTime=2,group=2,number=1} + data.map_scripts.scripts[label]={{'setval',80+i},{'end'}} + end + local function save() + return {g2LastCallMinute=0,g2Phone={[9]=true,[7]=true,[11]=true}, + g2PhoneSlots={9,7,11,0,0,0,0,0,0,0}} + end + local map={group=1,number=1,phoneService=true} + local function randomBytes(bytes) + local used=0 + math.random=function(a,b) + T.eq(a,0,'phone RNG requests an unsigned byte lower bound') + T.eq(b,255,'phone RNG requests an unsigned byte upper bound') + used=used+1;assert(bytes[used]~=nil,'unexpected phone RNG draw') + return bytes[used] + end + return function()return used end + end + -- Byte $10 swaps to $01, choosing slot two, not sorted numeric ID order. + local draws=randomBytes({0,16});local s=save() + local id,rows=Calls.rollIncomingCall(data,s,map,'DAY',30,false) + T.eq(id,7,version..' actual incoming path selects the native saved-slot sample') + T.eq(draws(),2,version..' gate and caller choice consume one byte each') + T.eq(s.g2CallCycles,0,version..' accepted call resets the receive cycle') + T.eq(s.g2LastCallMinute,30,version..' accepted call restarts at the actual minute') + local runner=require('src.script.ScriptRunner').new({data=data,save=s},{}) + runner:run(rows) + T.eq(runner.ctx.g2Var,82,version..' selected native receive program actually executes') + draws=randomBytes({128});s=save() + T.eq(Calls.rollIncomingCall(data,s,map,'DAY',30,false),nil,version..' failed coin flip suppresses choice') + T.eq(draws(),1,version..' failed coin flip consumes no caller-choice byte') + T.eq(s.g2CallCycles,1,version..' failed coin flip still consumes timer expiry') + draws=randomBytes({0});s=save() + T.eq(Calls.rollIncomingCall(data,s,{phoneService=false},'DAY',30,false),nil,version..' missing service suppresses choice') + T.eq(draws(),1,version..' missing service consumes no caller-choice byte') + draws=randomBytes({0});s=save();s.g2Phone={};s.g2PhoneSlots={} + T.eq(Calls.rollIncomingCall(data,s,map,'DAY',30,false),nil,version..' empty native book suppresses choice') + T.eq(draws(),1,version..' empty native book consumes no caller-choice byte') + draws=randomBytes({});s=save() + T.eq(Calls.rollIncomingCall(data,s,map,'DAY',30,true),nil,version..' entrance blocks before timer and RNG') + T.eq(draws(),0,version..' entrance consumes no random bytes') + T.eq(s.g2LastCallMinute,0,version..' entrance preserves pending timer') + draws=randomBytes({});s=save() + T.eq(Calls.rollIncomingCall(data,s,map,'DAY',1,false),nil,version..' unexpired timer suppresses gate and selection') + T.eq(draws(),0,version..' unexpired timer consumes no random bytes') +end +math.random=old;V.set(original) +T.finish('native phone caller byte selection, saved order, timer gates and actual receive programs') From cef8bebd1af77eb815e89d425b925f52ce566804 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:03:10 +0100 Subject: [PATCH 35/67] Link upstream caller selection contribution permission --- docs/native-gen2-phone-call-selection.md | 3 +-- tests/engine/gen2_phone_call_selection.lua | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/native-gen2-phone-call-selection.md b/docs/native-gen2-phone-call-selection.md index 18b995104..a380ac381 100644 --- a/docs/native-gen2-phone-call-selection.md +++ b/docs/native-gen2-phone-call-selection.md @@ -20,5 +20,4 @@ Gen2ScriptVM and ScriptRunner in Gold/Silver/Crystal. This does not establish complete radio/phone parity, a hardware call or original-story acceptance. Original contributions by Roxas2712; existing reference, upstream and -third-party rights and credits remain applicable. See NOTICE.md and -LICENSE-ROXAS2712.md. +third-party rights and credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/tests/engine/gen2_phone_call_selection.lua b/tests/engine/gen2_phone_call_selection.lua index e89503baa..56834198f 100644 --- a/tests/engine/gen2_phone_call_selection.lua +++ b/tests/engine/gen2_phone_call_selection.lua @@ -1,4 +1,4 @@ --- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-phone-call-selection.md. package.path='./?.lua;./?/init.lua;'..package.path local T=require('tests.modkit') local Calls=require('src.script.Gen2Commands') From b77b27bb5ce20ab3c4b7fde11cfb8a3d65a3fe07 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:04:28 +0100 Subject: [PATCH 36/67] Exercise upstream native no-service header in caller selection regression --- tests/engine/gen2_phone_call_selection.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/engine/gen2_phone_call_selection.lua b/tests/engine/gen2_phone_call_selection.lua index 56834198f..fbf148c8a 100644 --- a/tests/engine/gen2_phone_call_selection.lua +++ b/tests/engine/gen2_phone_call_selection.lua @@ -61,7 +61,7 @@ for _,version in ipairs({'gold','silver','crystal'}) do T.eq(draws(),1,version..' failed coin flip consumes no caller-choice byte') T.eq(s.g2CallCycles,1,version..' failed coin flip still consumes timer expiry') draws=randomBytes({0});s=save() - T.eq(Calls.rollIncomingCall(data,s,{phoneService=false},'DAY',30,false),nil,version..' missing service suppresses choice') + T.eq(Calls.rollIncomingCall(data,s,{phoneService=false,mapPalette=0x10},'DAY',30,false),nil,version..' missing service suppresses choice') T.eq(draws(),1,version..' missing service consumes no caller-choice byte') draws=randomBytes({0});s=save();s.g2Phone={};s.g2PhoneSlots={} T.eq(Calls.rollIncomingCall(data,s,map,'DAY',30,false),nil,version..' empty native book suppresses choice') From 00f7faf5eba13134926817d81266a653a311a443 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:14:14 +0100 Subject: [PATCH 37/67] Emit native incoming call notification at actual random and special admission --- docs/native-gen2-phone-received-event.md | 38 ++++++ src/script/Gen2Commands.lua | 20 +++ src/world/OverworldController.lua | 2 + tests/engine/gen2_phone_received_event.lua | 147 +++++++++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 docs/native-gen2-phone-received-event.md create mode 100644 tests/engine/gen2_phone_received_event.lua diff --git a/docs/native-gen2-phone-received-event.md b/docs/native-gen2-phone-received-event.md new file mode 100644 index 000000000..5f745476d --- /dev/null +++ b/docs/native-gen2-phone-received-event.md @@ -0,0 +1,38 @@ +# Native incoming phone notification + +Gold, Silver and Crystal now emit `phone.call_received` after the actual +overworld controller queues an admitted incoming call. Both random calls and +queued special calls use this boundary. Rejected candidates, outgoing calls +and other generations do not emit it. The callback is an observation, not a +veto; returning false or editing its descriptor does not redirect the queued +receive program or change the saved caller. + +The notification follows the read-only reference's PhoneRing.script boundary +at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`: +`call`, `contact`, `name`, `className`, `special` and `scriptKey`. +Names come from the actual native contact/trainer roster. The detached call +descriptor has incoming direction, native numeric contact ID and actual +script key; canonical trainer class/member metadata is included when known. +This engine retains native extracted script keys rather than inventing +unavailable symbol labels. Special notifications use the queued wrapper key, +which can differ from the contact's ordinary receive script. + +The actual native controller dispatches this event only for Gen2. Native +API1/2 share delivery. Unsubscribe, entry rollback and teardown use the +existing event-owner journal. + +Focused synthetic checks run actual controller admission and queueScript, the real +incoming selector, Gen2ScriptVM and ScriptRunner in all three editions and +two native API levels. Only audio IO, RTC-facing time and RNG are +controlled. The controller's process-local Game binding is installed and +restored by the test; protected player instances are never used. Tests cover +random/special descriptors, actual queued program execution/completion, +observer mutations and false returns, rejected candidates, outgoing calls, +unsubscribe, rollback and other-generation isolation. + +This does not reproduce the complete PhoneRing dialogue/choreography, add the +legacy Phone facade, establish Mom shopping-call production, or prove full +story/hardware acceptance. Those remain separate work. + +Original contributions by Roxas2712; existing reference, upstream and +third-party rights and credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index 60e494371..e8b106b06 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2061,6 +2061,26 @@ function Gen2Commands.phoneName(data, id) return (names and names[entry.trainer]) or best.name, best.name end +-- Observation of an admitted native incoming call, never of a model-side +-- candidate. Keep the payload detached from the native row and queued IR. +function Gen2Commands.announceIncomingCall(data, id, special) + local Runtime = require("src.mods.Runtime") + if not Runtime.wants("phone.call_received") then return end + local row = phoneContact(data, id) or {} + local pool = data and data.map_scripts or {} + local specialRow = special and pool.specialCalls and pool.specialCalls[special] + local key = specialRow and specialRow.script or row.receive + local name, className = Gen2Commands.phoneName(data, id) + local constants = data and data.constants or {} + local class = (constants.trainerClassOrder or {})[row.class] + local member = class and (constants.trainerClassMembers or {})[class] + local call = {kind="call", direction="incoming", contact=id, + class=class, member=member and member[row.trainer], number=row.contactNumber, + script=key, scriptKey=key, special=special} + Runtime.emit("phone.call_received", {call=call, contact=id, + name=name, className=className, special=special, scriptKey=key}) +end + -- The row's second `dba` is the script PhoneCall (36:$4298) runs when the -- player rings the contact; the first is the one they run when they ring in. -- Both are walked into map_scripts.scripts by the extractor, so hand back the diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 05f6dc9d0..05a14d424 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3274,6 +3274,7 @@ function OverworldState:checkSpecialPhoneCall() Game.save.g2CurCaller = caller self:queueScript(script, { phoneCaller = caller, onDone = function() Game.save.g2CurCaller = nil end }) + Gen2Commands.announceIncomingCall(Game.data, caller, Game.save.g2SpecialCallActive) end -- CheckPhoneCall (36:$4074): the random incoming call, rolled on every step @@ -3309,6 +3310,7 @@ function OverworldState:checkIncomingPhoneCall() Game.save.g2CurCaller = nil pcall(function() require("src.core.Sound").play(Game.data, "Hang_Up") end) end }) + Gen2Commands.announceIncomingCall(Game.data, id) end function OverworldState:update(dt) diff --git a/tests/engine/gen2_phone_received_event.lua b/tests/engine/gen2_phone_received_event.lua new file mode 100644 index 000000000..d85e7aeab --- /dev/null +++ b/tests/engine/gen2_phone_received_event.lua @@ -0,0 +1,147 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-phone-received-event.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local Calls=require('src.script.Gen2Commands');local Runner=require('src.script.ScriptRunner') +local World=require('src.world.OverworldController') +local gameSlot,oldGame +for i=1,100 do + local name,value=debug.getupvalue(World.checkIncomingPhoneCall,i) + if name=='Game' then gameSlot=i;oldGame=value;break end + if not name then break end +end +assert(gameSlot,'actual native controller Game upvalue') +local function load(d,source,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local fs={['mods/phone_event/manifest.json']=Json.encode({id='phone_event',name='Phone event',version='1.0.0', + api=mode and mode[1] or 2,content_format=mode and mode[2] or 'native',entry='main.lua',affects_link=false}), + ['mods/phone_event/main.lua']='local mod=...;mod.exports.api=mod;'..source} + return T.sdk.loadMods({'mods/phone_event'},{data=d,fs=T.sdk.memfs(fs)}) +end +local function fixture() + local d=T.fixtures.fresh();d.constants.trainerClassOrder={'YOUNGSTER'} + d.constants.trainerClassMembers={YOUNGSTER={'JOEY1'}} + d.trainers={OPP_LOCAL={index=1,name='LOCAL CLASS',partyNames={'LOCAL JOEY'}}} + d.map_scripts={phone={[7]={class=1,trainer=1,call='OUT',callTime=7,receive='IN',receiveTime=7,group=2,number=1}, + [4]={name='LOCAL ELM',contactNumber=4,receive='ELM',receiveTime=0}}, + specialCalls={[1]={caller=4,script='SPECIAL',outside=true}}, + scripts={OUT={{'setval',74},{'end'}},IN={{'setval',71},{'end'}},ELM={{'setval',72},{'end'}}, + SPECIAL={{'setval',73},{'specialphonecall',0},{'end'}}}} + return d +end +local function gameFor(d) + local g={data=d,save={playTime=1800,g2LastCallMinute=0,g2Phone={[7]=true}}} + local w=setmetatable({map={id='HERE',def={group=1,number=1,phoneService=true,environment=1}, + warpAtCell=function()return nil end},player={cellX=0,cellY=0},scriptMoves={}, + timeOfDay=function()return 'DAY'end},{__index=World}) + w.runner=Runner.new(g,w);g.overworld=w + debug.setupvalue(World.checkIncomingPhoneCall,gameSlot,g) + return g,w +end +-- Upstream controller methods return no admission value. Observe their +-- actual queue growth without changing that native return protocol. +local function incoming(w) + local count=#(w.pendingScripts or {}) + w:checkIncomingPhoneCall() + return #(w.pendingScripts or {})>count +end +local function special(w) + local count=#(w.pendingScripts or {}) + w:checkSpecialPhoneCall() + return #(w.pendingScripts or {})>count +end +local Sound=require('src.core.Sound');local oldSound=Sound.play;Sound.play=function()end +local oldRandom=math.random;math.random=function()return 0 end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture();local r=load(d,[[ + mod.exports.seen={} + mod.exports.stop=mod.events:on("phone.call_received",function(e) + mod.exports.seen[#mod.exports.seen+1]={contact=e.contact,name=e.name,className=e.className, + special=e.special,scriptKey=e.scriptKey,call={kind=e.call.kind,contact=e.call.contact, + direction=e.call.direction,class=e.call.class,member=e.call.member,scriptKey=e.call.scriptKey}} + e.contact=255;e.call.contact=255;e.call.scriptKey='MUTATED';return false + end) + ]],mode) + local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' public event registration succeeds') + local e=r.loader.exports.phone_event;local g,w=gameFor(d) + T.eq(incoming(w),true,label..' actual native controller admits a random call') + T.eq(#e.seen,1,label..' admitted random call emits exactly once') + local seen=e.seen[1] + T.eq(seen.contact,7,label..' notification exposes the selected native contact ID') + T.eq(seen.name,'LOCAL JOEY',label..' caller name comes from the native trainer roster') + T.eq(seen.className,'LOCAL CLASS',label..' caller class uses native display text') + T.eq(seen.special,nil,label..' random call has no fabricated special ID') + T.eq(seen.scriptKey,'IN',label..' notification names the actual compiled native key') + T.eq(seen.call.contact,7,label..' descriptor and notification agree') + T.eq(seen.call.direction,'incoming',label..' descriptor retains incoming direction') + T.eq(seen.call.class,'YOUNGSTER',label..' descriptor class uses explicit canonical metadata') + T.eq(seen.call.member,'JOEY1',label..' descriptor member uses explicit canonical metadata') + T.eq(g.save.g2CurCaller,7,label..' observer mutation cannot change the real caller') + T.eq(w.pendingScripts[1].extra.phoneCaller,7,label..' observer mutation cannot redirect the queued call') + w.runner:run(w.pendingScripts[1].script,w.pendingScripts[1].extra) + T.eq(w.runner.ctx.g2Var,71,label..' actual selected receive program still executes') + T.eq(g.save.g2CurCaller,nil,label..' actual completion clears caller ownership') + T.eq(d.map_scripts.phone[7].receive,'IN',label..' observer mutation cannot rewrite imported contact') + g,w=gameFor(d);g.save.g2SpecialCall=1 + T.eq(special(w),true,label..' actual controller admits the queued outside special') + T.eq(#e.seen,2,label..' admitted special emits exactly once') + seen=e.seen[2] + T.eq(seen.contact,4,label..' special exposes its actual caller') + T.eq(seen.name,'LOCAL ELM',label..' non-trainer name comes from actual contact data') + T.eq(seen.className,nil,label..' non-trainer has no fabricated trainer class') + T.eq(seen.special,1,label..' special ID survives disarming of its pending queue') + T.eq(seen.scriptKey,'SPECIAL',label..' wrapper script overrides contact receive key') + T.eq(w.pendingScripts[1].extra.phoneCaller,4,label..' observer mutation cannot redirect the special') + w.runner:run(w.pendingScripts[1].script,w.pendingScripts[1].extra) + T.eq(w.runner.ctx.g2Var,73,label..' actual special receive program executes') + T.eq(g.save.g2SpecialCallActive,nil,label..' native specialphonecall NONE clears active ownership') + T.eq(g.save.g2CurCaller,nil,label..' special completion clears real caller ownership') + T.eq(special(w),false,label..' completed special cannot notify twice') + T.eq(#e.seen,2,label..' completed queue has no phantom event') + g,w=gameFor(d);w.transitioning=true + T.eq(incoming(w),false,label..' transition rejects a random candidate') + g.save.g2SpecialCall=1 + T.eq(special(w),false,label..' transition rejects a special candidate') + g,w=gameFor(d);g.save.g2SpecialCall=1;w.map.def.environment=3 + T.eq(special(w),false,label..' indoor map keeps an outside-only call pending') + T.eq(g.save.g2SpecialCall,1,label..' rejected special remains queued') + T.eq(#e.seen,2,label..' rejected candidates never notify') + g,w=gameFor(d) + local gear=setmetatable({game=g},{__index=require('src.ui.PokegearMenu')}) + T.eq(gear:runCallScript(7),true,label..' actual outgoing native call executes') + T.eq(#e.seen,2,label..' outgoing call cannot raise an incoming notification') + e.stop();g,w=gameFor(d) + T.eq(incoming(w),true,label..' unsubscribe preserves native incoming behavior') + T.eq(#e.seen,2,label..' unsubscribe stops event delivery') + Loader.teardown();r.release() + end +end +for _,version in ipairs({'red','firered','emerald','heartgold'})do + V.set(version);local d=fixture();local r=load(d,[[ + mod.exports.count=0 + mod.events:on("phone.call_received",function()mod.exports.count=mod.exports.count+1 end) + ]]) + T.eq(#r.errors,0,version..' native listener registration remains available without a native target') + local g,w=gameFor(d);g.save.g2SpecialCall=1 + T.eq(incoming(w),false,version..' native random call gate remains isolated') + T.eq(special(w),false,version..' native special call gate remains isolated') + T.eq(r.loader.exports.phone_event.count,0,version..' no unrelated generation event') + Loader.teardown();r.release() +end +do + V.set('crystal');local d=fixture();local r=load(d,[[ + mod.events:on("phone.call_received",function()error('rolled-back listener ran')end) + error('entry failure') + ]]) + T.check(#r.errors>0,'failed entry is reported') + T.eq(require('src.mods.Runtime').wants("phone.call_received"),false,'failed entry rolls back its actual event listener') + local g,w=gameFor(d) + T.eq(incoming(w),true,'failed listener cannot block the actual native incoming call') + Loader.teardown();r.release() +end +debug.setupvalue(World.checkIncomingPhoneCall,gameSlot,oldGame) +Sound.play=oldSound;math.random=oldRandom;V.set(original) +T.finish('native phone.call_received from actual random/special controller queues and receive programs') From 0fb2ad456a015dc9226b5414808ad8d3b6f1defc Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:16:55 +0100 Subject: [PATCH 38/67] Keep native trainer caller class after authored contact renaming --- docs/native-gen2-phone-received-event.md | 3 ++- src/script/Gen2Commands.lua | 2 +- tests/engine/gen2_phone_received_event.lua | 7 +++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/native-gen2-phone-received-event.md b/docs/native-gen2-phone-received-event.md index 5f745476d..e05e5cd82 100644 --- a/docs/native-gen2-phone-received-event.md +++ b/docs/native-gen2-phone-received-event.md @@ -10,7 +10,8 @@ receive program or change the saved caller. The notification follows the read-only reference's PhoneRing.script boundary at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`: `call`, `contact`, `name`, `className`, `special` and `scriptKey`. -Names come from the actual native contact/trainer roster. The detached call +Names come from the actual native contact/trainer roster or authored contact +name. Renaming a trainer contact preserves its actual trainer-class label. The detached call descriptor has incoming direction, native numeric contact ID and actual script key; canonical trainer class/member metadata is included when known. This engine retains native extracted script keys rather than inventing diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index e8b106b06..562f8a208 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2054,8 +2054,8 @@ end function Gen2Commands.phoneName(data, id) local entry = phoneContact(data, id) if not entry then return nil end - if entry.name then return entry.name, entry.role end local best = phoneTrainerDef(data, entry) + if entry.name then return entry.name, entry.role or (best and best.name) end if not best then return nil end local names = best.partyNames return (names and names[entry.trainer]) or best.name, best.name diff --git a/tests/engine/gen2_phone_received_event.lua b/tests/engine/gen2_phone_received_event.lua index d85e7aeab..72bddc0dd 100644 --- a/tests/engine/gen2_phone_received_event.lua +++ b/tests/engine/gen2_phone_received_event.lua @@ -55,8 +55,11 @@ local oldRandom=math.random;math.random=function()return 0 end for _,version in ipairs({'gold','silver','crystal'})do V.set(version) for _,mode in ipairs({{1,'native'},{2,'native'}})do - local d=fixture();local r=load(d,[[ + local d=fixture() + T.eq(Calls.phoneName(d,7),'LOCAL JOEY','unmodified contact still reads the actual native roster') + local r=load(d,[[ mod.exports.seen={} + mod.content.phone_contacts:patch('phone:7',{name='AUTHORED JOEY'}) mod.exports.stop=mod.events:on("phone.call_received",function(e) mod.exports.seen[#mod.exports.seen+1]={contact=e.contact,name=e.name,className=e.className, special=e.special,scriptKey=e.scriptKey,call={kind=e.call.kind,contact=e.call.contact, @@ -71,7 +74,7 @@ for _,version in ipairs({'gold','silver','crystal'})do T.eq(#e.seen,1,label..' admitted random call emits exactly once') local seen=e.seen[1] T.eq(seen.contact,7,label..' notification exposes the selected native contact ID') - T.eq(seen.name,'LOCAL JOEY',label..' caller name comes from the native trainer roster') + T.eq(seen.name,'AUTHORED JOEY',label..' caller name consumes the actual native content override') T.eq(seen.className,'LOCAL CLASS',label..' caller class uses native display text') T.eq(seen.special,nil,label..' random call has no fabricated special ID') T.eq(seen.scriptKey,'IN',label..' notification names the actual compiled native key') From 0594d15daa1fada437d8ad3481978664649b4792 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:35:34 +0100 Subject: [PATCH 39/67] Read native apricorn recipes and correct GSC Kurt selection transactions --- docs/native-gen2-apricorn-selection.md | 55 +++++++ src/import/RomExtractorGen2.lua | 28 ++++ src/import/RomImporter.lua | 1 + src/script/Gen2Commands.lua | 112 +++++++-------- src/world/Gen2Apricorns.lua | 44 ++++++ tests/engine/gen2_apricorn_native.lua | 190 +++++++++++++++++++++++++ 6 files changed, 368 insertions(+), 62 deletions(-) create mode 100644 docs/native-gen2-apricorn-selection.md create mode 100644 src/world/Gen2Apricorns.lua create mode 100644 tests/engine/gen2_apricorn_native.lua diff --git a/docs/native-gen2-apricorn-selection.md b/docs/native-gen2-apricorn-selection.md new file mode 100644 index 000000000..907b5b1a0 --- /dev/null +++ b/docs/native-gen2-apricorn-selection.md @@ -0,0 +1,55 @@ +# Native ApricornBalls and Kurt selection + +The GSC importer reads the actual `ApricornBalls` pairs in cartridge order, +retaining native apricorn/ball item IDs and row indices. A single `$ff` ends +the table. Missing symbols leave no invented recipe data; explicit empty or +malformed/truncated tables never fall back to retail rows. Reads are bounded +and cannot cross a cartridge bank boundary. The map-script output includes +recipe records and completeness metadata. Stock Gold/Silver/Crystal cache +markers require reimport; other edition markers are unchanged. + +Kurt's actual native special now consumes this imported order. The old fixed +item-ID order put several colours in the wrong positions. Historical stock +caches retain selection until reimport in native red/blue/yellow/green/white/ +black/pink order, without fabricating their missing ball mappings. + +Gold/Silver take one apricorn without opening a quantity menu. Crystal asks +for quantity, including a one-item stack; cancelling quantity returns to the +apricorn list with its cursor retained and consumes nothing. An explicit +CANCEL row and bounded scrolling retain access to the complete list. The +quantity is capped at99 and the native inventory is rechecked before removal, +so a changed bag cannot mint a paid job. Unreviewed hack layouts retain their +previous quantity-cancel protocol and are not given a guessed table import. + +The native script variable still receives the actual selected item byte. +The existing `g2KurtApricorns` field holds its confirmed quantity. Native +script branches, event/working flags, daily resets and ball choice remain +script-owned. This does not add a competing Kurt job model or rewrite a ROM +script's ball operand merely because its selection table contains a pair. + +Validation:198 synthetic checks use actual importer/map-script serialization, +Gen2ScriptVM, ScriptRunner, StateStack, Menu input and Bag operations. They +cover order/bounds, malformed data, cache readiness, Gold/Silver single-item +handover, Crystal confirmation/reselection/cancellation/scrolling and a real +`verbosegiveitemvar` collection path: a full ball stack preserves the event, +successful delivery grants the confirmed quantity and event clearing prevents +duplicate collection. Only text/audio IO is substituted. A private read-only +Crystal Rev0 table probe finds seven complete rows, agrees with an independent +byte decoder and leaves the original ROM unchanged. It is not a full import, +GPU, original-story or Gold/Silver actual-ROM acceptance claim. + +The complete `apricorns` registry and legacy Apricorns facade, authored recipe +events/ball-output binding, complete native daily/tree lifecycle and original +story/hardware acceptance remain open. Full Gen1 parity is unproven. + +Behavior was checked against the read-only Gen1 reference +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91` and primary disassembly sources: +[Gold selection/table](https://github.com/pret/pokegold/blob/master/engine/menus/menu_2.asm), +[Gold handover](https://github.com/pret/pokegold/blob/master/engine/events/specials.asm), +[Crystal selection/quantity](https://github.com/pret/pokecrystal/blob/master/engine/events/kurt.asm), +[Crystal recipe table](https://github.com/pret/pokecrystal/blob/master/data/items/apricorn_balls.asm). +These links identify behavior/provenance; no ROM or extracted table bytes are +included in the contribution. + +Original contributions by Roxas2712; existing reference, upstream and +third-party rights and credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 90b74029e..74eae7b9f 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -3317,6 +3317,33 @@ function RomExtractorGen2:gen2MovementTable() return out end +-- Kurt's actual selection-order and recipe pairs, not the bag's item order. +function RomExtractorGen2:gen2ApricornBalls() + if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end + local sym=self:symbol("ApricornBalls") + if not (sym and self.rom) then return nil end + local rows,seen={},{} + local info={source="ApricornBalls",complete=false,rowsRead=0} + -- Native db apricorn,ball pairs, terminated by a single $ff apricorn. + -- Never walk through a bank boundary or accept an unterminated prefix. + for slot=0,128 do + local at=sym.address+slot*2 + local ok,apricorn=pcall(function()return self.rom:byte(sym.bank,at)end) + if not ok then info.reason="truncated table";return {},info end + if apricorn==255 then info.complete=true;return rows,info end + if slot==128 then info.reason="unterminated table";return {},info end + local read,ball=pcall(function()return self.rom:byte(sym.bank,at+1)end) + if not read then info.reason="truncated pair";return {},info end + if apricorn==0 or ball==0 or ball==255 or seen[apricorn] then + info.reason="invalid or duplicate native item";return {},info + end + seen[apricorn]=true + rows[#rows+1]={index=slot+1,apricorn=string.format("ITEM_%03d",apricorn), + ball=string.format("ITEM_%03d",ball)} + info.rowsRead=#rows + end +end + -- Marts is a dw pointer table; each mart is `db count`, the item ids, `db -1`. -- The table ends where its own first entry starts. function RomExtractorGen2:gen2Marts() @@ -8892,6 +8919,7 @@ function RomExtractorGen2:extractMapScripts() self:beginStage("Gen2 map scripts out.stds = self:gen2QueueStdScripts(pool) out.marts = self:gen2Marts() + out.apricornBalls, out.apricornInfo = self:gen2ApricornBalls() out.spawns = self:gen2SpawnPoints(maps, keys) -- extractField runs after this stage and needs both of these: GSC has no -- Fly-warp list of its own, because Fly lands on the SPAWN POINT of the diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index cbd913e5a..bd4b8d88c 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1311,6 +1311,7 @@ local function markerFor(version) -- v8: field.gen2TradeAnim, GSC's own trade movie art (Gen2TradeArt). if version == "gold" or version == "silver" or version == "crystal" then revision = "gen2-field-encounters-v8:" + revision = revision .. "gen2-apricorn-table-v1:" end -- field.tradeArt: the cable-trade movie's tiles out of the ROM -- (Gen1TradeArt); without it the trade ran on drawn stand-ins. diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..adea141ad 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -3002,72 +3002,60 @@ end -- run of `ifequal` rows that decide which ball Kurt starts on. -- --------------------------------------------------------------------------- --- RED, BLU, BLK, WHT, PNK, GRN, YLW -- the same ids Kurt1 `checkitem`s before --- it bothers to ask -local APRICORNS = { 85, 89, 92, 93, 97, 99, 101 } - function Commands.g2_select_apricorn(ctx) - local game, runner, save = ctx.game, ctx.runner, ctx.save - local Bag = require("src.inventory.Bag") - local Menu = require("src.ui.Menu") - local picked, held, items = 0, 0, {} - -- SelectApricornForKurt zeroes wKurtApricornQuantity on entry, so a - -- cancelled pick cannot leave the previous count standing. - save.g2KurtApricorns = 0 - for _, id in ipairs(APRICORNS) do - local key = string.format("ITEM_%03d", id) - local qty = save.inventory and save.inventory[key] or 0 - if qty > 0 then - local def = game.data.items and game.data.items[key] - items[#items + 1] = { - label = string.format("%s x%d", (def and def.name) or key, qty), - onSelect = function() picked, held = id, qty runner:resume() end, - } + local game,runner,save=ctx.game,ctx.runner,ctx.save + local Bag=require("src.inventory.Bag") + local Menu=require("src.ui.Menu") + local Apricorns=require("src.world.Gen2Apricorns") + local version=require("src.core.GameVersion").get() + local stock=version=="gold" or version=="silver" or version=="crystal" + save.g2KurtApricorns=0 + local selected=1 + while true do + local picked,held,key=0,0,nil + local items={} + for _,row in ipairs(Apricorns.inBag(game.data,save))do + local id,quantity,item=row.item,row.quantity,row.apricorn + local def=game.data.items and game.data.items[item] + items[#items+1]={label=string.format("%s x%d",(def and def.name) or item,quantity), + onSelect=function()picked,held,key=id,quantity,item;runner:resume()end} end - end - if #items == 0 then - ctx.g2Var, ctx.lastCheck = 0, false - return - end - game.stack:push(Menu.new(game, items, { - onCancel = function() runner:resume() end, - })) - runner:yield() - if picked == 0 then - ctx.g2Var, ctx.lastCheck = 0, false - return - end - - -- "How many should I make?" -- Kurt_SelectQuantity (engine/events/kurt.asm) - -- loads wItemQuantity with how many of that apricorn you HAVE and lets you - -- scroll 1..that, then Kurt_GiveUpSelectedQuantityOfSelectedApricorn takes - -- exactly that many and the count goes into wKurtApricornQuantity -- - -- VAR_KURT_APRICORNS, which the `verbosegiveitemvar` on the collection - -- branch reads back to decide how many balls to hand over. - -- - -- Taking one apricorn and never writing that var is what left the var at 0 - -- and, with verbosegiveitemvar unlowered as well, Kurt holding a ball he - -- could never give. - local count = 1 - if held > 1 then - local rows = {} - for n = 1, held do - rows[#rows + 1] = { - label = string.format("x%d", n), - onSelect = function() count = n runner:resume() end, - } + if #items==0 then ctx.g2Var,ctx.lastCheck=0,false;return end + if stock then + items[#items+1]={label="CANCEL",onSelect=function()runner:resume()end} + end + local menu=Menu.new(game,items,{maxVisible=version=="crystal" and 4 or 8, + onCancel=function()runner:resume()end}) + menu.index=math.min(selected,#items);menu:clampScroll() + game.stack:push(menu);runner:yield() + if picked==0 then ctx.g2Var,ctx.lastCheck=0,false;return end + selected=menu.index + local count,cancelled=1,false + -- Gold/Silver's special always tosses exactly one. Crystal asks even + -- for a one-item stack and goes back to the list on quantity cancel. + if version=="crystal" or version~="gold" and version~="silver" and held>1 then + local choices={} + for n=1,held do + local quantity=n + choices[#choices+1]={label=string.format("x%d",n), + onSelect=function()count=quantity;runner:resume()end} + end + game.stack:push(Menu.new(game,choices,{maxVisible=5, + onCancel=function()cancelled=true;runner:resume()end})) + runner:yield() + end + if not cancelled or version~="crystal" then + count=math.max(1,math.min(count,held)) + local available=tonumber(save.inventory and save.inventory[key]) or 0 + if available~=available or available0 and id<255 and id or nil +end +function M.rows(data) + local pool=data and data.map_scripts + local source=pool and pool.apricornBalls + local out={} + if source~=nil then + if type(source)~='table' or pool.apricornInfo and pool.apricornInfo.complete==false then return out end + for _,row in ipairs(source)do + if type(row)~='table' or not item(row.apricorn) or not item(row.ball) then return {} end + out[#out+1]={index=#out+1,apricorn=row.apricorn,ball=row.ball} + end + return out + end + -- Historical caches have no recipe table. Keep selection available until + -- reimport; do not invent their missing ball mappings. + local version=Version.get() + local legacy=stock(version) and {85,89,101,99,93,92,97} or {85,89,92,93,97,99,101} + for i,id in ipairs(legacy)do out[i]={index=i,apricorn=string.format('ITEM_%03d',id)}end + return out +end +function M.inBag(data,save) + local out={} + local nativeStock=stock(Version.get()) + for _,row in ipairs(M.rows(data))do + local n=tonumber(save and save.inventory and save.inventory[row.apricorn]) + if n and n==n and n=1 then + local quantity=math.floor(n) + if nativeStock then quantity=math.min(99,quantity)end + out[#out+1]={index=row.index,apricorn=row.apricorn,ball=row.ball, + item=item(row.apricorn),quantity=quantity} + end + end + return out +end +return M diff --git a/tests/engine/gen2_apricorn_native.lua b/tests/engine/gen2_apricorn_native.lua new file mode 100644 index 000000000..3ea58a195 --- /dev/null +++ b/tests/engine/gen2_apricorn_native.lua @@ -0,0 +1,190 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local E=require('src.import.RomExtractorGen2');local Writer=require('src.import.LuaWriter') +local A=require('src.world.Gen2Apricorns');local VM=require('src.script.Gen2ScriptVM') +local Runner=require('src.script.ScriptRunner');local Stack=require('src.core.StateStack') +local Commands=require('src.script.Commands');local Flags=require('src.script.Gen2Flags') +local Importer=require('src.import.RomImporter') +local markerFor +for i=1,100 do + local name,value=debug.getupvalue(Importer.readyReport,i) + if name=='markerFor'then markerFor=value;break end + if not name then break end +end +assert(markerFor) +local function extractor(version,bytes,address,length) + address=address or 0x4000 + local raw=string.rep('\0',address)..string.char(unpack(bytes)) + if length and #raw0 and 3-count or nil,version..' actual bag toss removes exactly the confirmed quantity') + T.eq(g.save.inventory.ITEM_119,1,version..' selection cannot consume another apricorn') + T.eq(g.stack:top().name,'map',version..' actual Menu/Runner returns to its native owner') + g,r=gameFor(version,{ITEM_117=3});g.save.g2KurtApricorns=0 + press(g,'down');press(g,'a') + T.eq(r.ctx.g2Var,0,version..' explicit CANCEL returns false') + T.eq(g.save.inventory.ITEM_117,3,version..' CANCEL preserves all bag items') + T.eq(g.save.g2KurtApricorns,0,version..' CANCEL cannot retain a previous quantity') + g,r=gameFor(version,{}) + T.eq(r:isRunning(),false,version..' empty native bag does not open an empty menu') + T.eq(r.ctx.g2Var,0,version..' empty native bag returns false') +end +do + local g,r=gameFor('crystal',{ITEM_117=3,ITEM_119=1}) + press(g,'a');press(g,'b') + T.eq(r:isRunning(),true,'Crystal quantity cancel returns to the actual apricorn menu') + T.eq(g.stack:top().items[1].label,'AUTHORED APRICORN x3','quantity cancel restores native list') + T.eq(g.save.inventory.ITEM_117,3,'quantity cancel does not silently take one apricorn') + press(g,'down');press(g,'a') + T.eq(#g.stack:top().items,1,'Crystal asks for quantity even when only one is held') + press(g,'a') + T.eq(r.ctx.g2Var,119,'selection after quantity cancel can choose a different actual item') + T.eq(g.save.inventory.ITEM_117,3,'reselection preserves the original apricorn stack') + T.eq(g.save.inventory.ITEM_119,nil,'reselection consumes the confirmed other apricorn') + g,r=gameFor('crystal',{ITEM_117=2});press(g,'a');g.save.inventory.ITEM_117=nil;press(g,'a') + T.eq(r.ctx.g2Var,0,'concurrent native bag removal cannot create a paid Kurt job') + T.eq(g.save.g2KurtApricorns,0,'failed revalidation cannot mint a job quantity') + g,r=gameFor('crystal',{ITEM_117=3});press(g,'a');press(g,'down');press(g,'down');press(g,'a') + local event=Flags.eventFlag(604);g.save.flags={[event]=true};g.save.inventory.ITEM_202=97 + local function collect() + local runner=Runner.new(g,g.overworld);runner:run(assert(VM.compile(g.data,'COLLECT')));return runner + end + collect() + T.eq(g.save.inventory.ITEM_202,97,'actual verbosegiveitemvar refuses the whole overflowing ball stack') + T.eq(g.save.flags[event],true,'failed native ball delivery preserves the recipe event') + g.save.inventory.ITEM_202=96;collect() + T.eq(g.save.inventory.ITEM_202,99,'actual native delivery grants the confirmed three-ball quantity') + T.eq(g.save.flags[event],false,'successful native script clears its real event') + collect();T.eq(g.save.inventory.ITEM_202,99,'native event guard prevents a duplicate ball collection') + g,r=gameFor('crystal',{ITEM_117=3,ITEM_119=1}) + press(g,'down');press(g,'a');press(g,'b') + T.eq(g.stack:top().index,2,'Crystal quantity cancel retains the selected apricorn cursor') + press(g,'b') + T.eq(r.ctx.g2Var,0,'cancelling the restored list cancels the native transaction') + T.eq(g.save.inventory.ITEM_119,1,'cancelling restored list cannot remove the selected apricorn') + g,r=gameFor('crystal',{ITEM_117=200});press(g,'a') + T.eq(#g.stack:top().items,99,'native Crystal quantity caps at99 for an oversized historical stack') + T.eq(g.stack:top().maxVisible,5,'quantity input uses bounded scrolling instead of extending offscreen') + press(g,'up');press(g,'a') + T.eq(g.save.g2KurtApricorns,99,'actual Menu wrap can confirm the native maximum quantity') + T.eq(g.save.inventory.ITEM_117,101,'capped native handover removes no unconfirmed excess') + local rows,inventory={},{} + for i=1,7 do + local key=string.format('ITEM_%03d',110+i);rows[i]={index=i,apricorn=key,ball='ITEM_202'};inventory[key]=1 + end + g,r=gameFor('crystal',inventory,rows) + T.eq(#g.stack:top().items,8,'full native list retains seven recipes and CANCEL') + T.eq(g.stack:top().maxVisible,4,'Crystal recipe list uses four visible scrolling rows') + press(g,'up') + T.eq(g.stack:top().index,8,'actual recipe cursor wraps to CANCEL') + T.eq(g.stack:top().scroll,4,'actual recipe scroll keeps wrapped CANCEL visible') + press(g,'a');T.eq(r.ctx.g2Var,0,'visible wrapped CANCEL exits the native special') +end +for _,version in ipairs({'prism','polishedcrystal'})do + local g,r=gameFor(version,{ITEM_085=2},{{index=1,apricorn='ITEM_085',ball='ITEM_202'}}) + press(g,'a');press(g,'b') + T.eq(r.ctx.g2Var,85,version..' unreviewed hack retains its legacy quantity-cancel protocol') + T.eq(g.save.g2KurtApricorns,1,version..' hack quantity-cancel behavior is not silently converted to Crystal') + T.eq(g.save.inventory.ITEM_085,1,version..' hack legacy selection still consumes its original single item') +end +Commands.show_text=oldText;require('src.core.Sound').play=oldSound;V.set(original) +T.finish('native ApricornBalls importer, actual Kurt menus, cartridge quantities and native ball delivery') From c27f836bb9beaf808b38d55592cb29725d80962e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:04:42 +0100 Subject: [PATCH 40/67] Keep historical apricorn fallback in actual cartridge item order --- docs/native-gen2-apricorn-selection.md | 11 ++++++----- src/world/Gen2Apricorns.lua | 3 +-- tests/engine/gen2_apricorn_native.lua | 4 +++- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/native-gen2-apricorn-selection.md b/docs/native-gen2-apricorn-selection.md index 907b5b1a0..9fc793b5c 100644 --- a/docs/native-gen2-apricorn-selection.md +++ b/docs/native-gen2-apricorn-selection.md @@ -8,10 +8,11 @@ and cannot cross a cartridge bank boundary. The map-script output includes recipe records and completeness metadata. Stock Gold/Silver/Crystal cache markers require reimport; other edition markers are unchanged. -Kurt's actual native special now consumes this imported order. The old fixed -item-ID order put several colours in the wrong positions. Historical stock -caches retain selection until reimport in native red/blue/yellow/green/white/ -black/pink order, without fabricating their missing ball mappings. +Kurt's actual native special now consumes the imported order, including +cartridge-specific table changes. Historical stock caches retain the original +native item order85/89/92/93/97/99/101 until reimport, without fabricating their +missing ball mappings. The earlier colour-based fallback reordering was +incorrect and is corrected here; the ROM-table import itself was unaffected. Gold/Silver take one apricorn without opening a quantity menu. Crystal asks for quantity, including a one-item stack; cancelling quantity returns to the @@ -27,7 +28,7 @@ script branches, event/working flags, daily resets and ball choice remain script-owned. This does not add a competing Kurt job model or rewrite a ROM script's ball operand merely because its selection table contains a pair. -Validation:198 synthetic checks use actual importer/map-script serialization, +Validation:216 synthetic checks use actual importer/map-script serialization, Gen2ScriptVM, ScriptRunner, StateStack, Menu input and Bag operations. They cover order/bounds, malformed data, cache readiness, Gold/Silver single-item handover, Crystal confirmation/reselection/cancellation/scrolling and a real diff --git a/src/world/Gen2Apricorns.lua b/src/world/Gen2Apricorns.lua index 54e7090d9..bc7268d4c 100644 --- a/src/world/Gen2Apricorns.lua +++ b/src/world/Gen2Apricorns.lua @@ -22,8 +22,7 @@ function M.rows(data) end -- Historical caches have no recipe table. Keep selection available until -- reimport; do not invent their missing ball mappings. - local version=Version.get() - local legacy=stock(version) and {85,89,101,99,93,92,97} or {85,89,92,93,97,99,101} + local legacy={85,89,92,93,97,99,101} for i,id in ipairs(legacy)do out[i]={index=i,apricorn=string.format('ITEM_%03d',id)}end return out end diff --git a/tests/engine/gen2_apricorn_native.lua b/tests/engine/gen2_apricorn_native.lua index 3ea58a195..5a15f1da0 100644 --- a/tests/engine/gen2_apricorn_native.lua +++ b/tests/engine/gen2_apricorn_native.lua @@ -63,7 +63,9 @@ for _,version in ipairs({'gold','silver','crystal'})do T.eq(bounds.complete,false,version..' pair cannot cross a cartridge bank boundary') T.eq(#bad,0,version..' bank-boundary failure has no invented pair') local fallback=A.rows({}) - T.eq(fallback[3].apricorn,'ITEM_101',version..' historical stock cache retains yellow-before-green native order') + for i,id in ipairs({85,89,92,93,97,99,101})do + T.eq(fallback[i].apricorn,string.format('ITEM_%03d',id),version..' historical fallback retains native cartridge item order at slot'..i) + end T.eq(fallback[3].ball,nil,version..' historical fallback does not invent missing ball mappings') local marker=markerFor(version) T.check(marker:find('gen2%-apricorn%-table%-v1:')~=nil,version..' stock native cache records the recipe import format') From 491d25aa2a885ec105f99b7f96293bbf7b65b3a5 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:31:36 +0100 Subject: [PATCH 41/67] Bind native GSC apricorn recipes to Kurt scripts and delivery events --- docs/native-gen2-apricorn-selection.md | 43 ++- src/import/RomExtractorGen2.lua | 16 + src/import/RomImporter.lua | 2 +- src/mods/ApricornContent.lua | 198 +++++++++++++ src/mods/Loader.lua | 29 +- src/mods/Schemas.lua | 7 + src/mods/UnifiedIds.lua | 85 ++++++ src/script/Gen2Commands.lua | 7 + src/script/Gen2ScriptVM.lua | 1 + src/world/Gen2ApricornPrograms.lua | 188 ++++++++++++ tests/engine/gen2_apricorn_content.lua | 276 ++++++++++++++++++ .../engine/gen2_apricorn_programs_import.lua | 59 ++++ 12 files changed, 906 insertions(+), 5 deletions(-) create mode 100644 src/mods/ApricornContent.lua create mode 100644 src/mods/UnifiedIds.lua create mode 100644 src/world/Gen2ApricornPrograms.lua create mode 100644 tests/engine/gen2_apricorn_content.lua create mode 100644 tests/engine/gen2_apricorn_programs_import.lua diff --git a/docs/native-gen2-apricorn-selection.md b/docs/native-gen2-apricorn-selection.md index 9fc793b5c..58d3b969e 100644 --- a/docs/native-gen2-apricorn-selection.md +++ b/docs/native-gen2-apricorn-selection.md @@ -39,9 +39,46 @@ Crystal Rev0 table probe finds seven complete rows, agrees with an independent byte decoder and leaves the original ROM unchanged. It is not a full import, GPU, original-story or Gold/Silver actual-ROM acceptance claim. -The complete `apricorns` registry and legacy Apricorns facade, authored recipe -events/ball-output binding, complete native daily/tree lifecycle and original -story/hardware acceptance remain open. Full Gen1 parity is unproven. +The `apricorns` registry now projects authored recipe records into the actual +decoded native Kurt programs. The importer queues four named roots and their +reachable scripts; incomplete or home-bank bindings are unavailable rather +than guessed. An additional stock-GSC cache revision requires that import. +The adapter derives each base recipe's event from its native selection and +collection branches, rather than assuming an event/colour order. For example: + +```lua +mod.content.apricorns:patch("RED_APRICORN", {ball="ULTRA_BALL"}) +``` + +All fields are `apricorn`, `ball`, `event` and `index`, as in the reference +registry. Items must resolve to real indexed GSC item bytes. Additional recipes +can use previously indexed native items and new word-sized events, excluding +the `$ffff` indirect-event sentinel. The selector still returns the real item +byte; it is never replaced with a fabricated seven-colour ordinal. Actual +native membership and event-dispatch branches, including overlapping roots, +consume the resulting table. Working/day flags, quantity, dialogue and +full-bag refusal stay in native scripts. Removed/rekeyed recipes stop new +jobs while unclaimed original pending events retain their original output. + +The observational `apricorn.converted` event is emitted only after a native +gift succeeds and its real event is cleared. API1/2 callbacks retain native +item IDs. The fork separately tests its API3 semantic callback boundary. It cannot veto or redirect +the gift. Recipe record views use semantic IDs through upstream API1/2 native content. The fork also tests its API2/3 +unified content modes; those additional APIs are not imported by this PR. Later item registrations resolve before +projection; invalid owners and affected consumers roll back before any native +script mutation. Unload restores unchanged owned rows/scripts, preserving +external edits. A new script table invalidates the VM's compiled-root cache. + +Synthetic integration checks cover actual Loader, VM, Menu, daily resets, +bag operations, observers, invalid/forward references, removal, unload and +generation isolation. A private read-only Crystal Rev0 probe also decodes +31 reachable native programs (264 instructions, zero desyncs), registers an +eighth recipe and executes the native selection, map reentry, working branch, +daily reset, full-bag refusal, three-ball delivery and exactly-once completion. +Only external text/audio IO is substituted; the original ROM is unchanged. +These checks do not establish full GPU/story or Gold/Silver actual-ROM +acceptance. The legacy Apricorns facade, full native daily/tree lifecycle and +remaining Gen1 feature parity remain open. Behavior was checked against the read-only Gen1 reference `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91` and primary disassembly sources: diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 74eae7b9f..ae6a3b668 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -3344,6 +3344,21 @@ function RomExtractorGen2:gen2ApricornBalls() end end +function RomExtractorGen2:gen2QueueApricornPrograms(pool) + if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end + local symbols={ask="Kurt1.AskApricorn",pending="Kurt1.GotLureBall", + gave="Kurt1.GaveKurtApricorns",cancel="Kurt1.Cancel"} + local found={} + for role,name in pairs(symbols)do + local at=self:symbol(name) + if not at or at.address<0x4000 or not self:gen2InRom(at.bank,at.address) then return nil end + found[role]=at + end + local out={} + for role,at in pairs(found)do out[role]=self:gen2QueueScript(at.bank,at.address,pool)end + return out +end + -- Marts is a dw pointer table; each mart is `db count`, the item ids, `db -1`. -- The table ends where its own first entry starts. function RomExtractorGen2:gen2Marts() @@ -8920,6 +8935,7 @@ function RomExtractorGen2:extractMapScripts() self:beginStage("Gen2 map scripts out.stds = self:gen2QueueStdScripts(pool) out.marts = self:gen2Marts() out.apricornBalls, out.apricornInfo = self:gen2ApricornBalls() + out.apricornPrograms = self:gen2QueueApricornPrograms(pool) out.spawns = self:gen2SpawnPoints(maps, keys) -- extractField runs after this stage and needs both of these: GSC has no -- Fly-warp list of its own, because Fly lands on the SPAWN POINT of the diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index bd4b8d88c..9e9dbef47 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1311,7 +1311,7 @@ local function markerFor(version) -- v8: field.gen2TradeAnim, GSC's own trade movie art (Gen2TradeArt). if version == "gold" or version == "silver" or version == "crystal" then revision = "gen2-field-encounters-v8:" - revision = revision .. "gen2-apricorn-table-v1:" + revision = revision .. "gen2-apricorn-table-v1:gen2-apricorn-programs-v1:" end -- field.tradeArt: the cable-trade movie's tiles out of the ROM -- (Gen1TradeArt); without it the trade ran on drawn stand-ins. diff --git a/src/mods/ApricornContent.lua b/src/mods/ApricornContent.lua new file mode 100644 index 000000000..cf674e37b --- /dev/null +++ b/src/mods/ApricornContent.lua @@ -0,0 +1,198 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +local Merge=require('src.mods.Merge') +local Ids=require('src.mods.UnifiedIds') +local Programs=require('src.world.Gen2ApricornPrograms') +local M={} +local mounted=setmetatable({},{__mode='k'}) +local previous=setmetatable({},{__mode='k'}) +local copy=Merge.deepCopy +local function equal(a,b) + if a==b then return true end + if type(a)~='table' or type(b)~='table'then return false end + for k,v in pairs(a)do if not equal(v,b[k])then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +local function finite(v,seen) + if type(v)=='number'then assert(v==v and math.abs(v)(ranks[owner] or 0)then owner=other end + problems[#problems+1]={owner=owner,message='apricorns: duplicate '..label..' in '..old..' and '..id} + end + for id,value in registry:each()do + local ok,r=pcall(row,loader,id,value,false) + if not ok then problems[#problems+1]={owner=registry.owners[id],message=tostring(r)} + else + if items[r.apricorn]then conflict(id,items[r.apricorn],'native apricorn item')else items[r.apricorn]=id end + if events[r.event]then conflict(id,events[r.event],'recipe event')else events[r.event]=id end + end + end + return problems +end +function M.prepare(loader,data) + local history=data and previous[data] + if history and data.map_scripts==history.pool then + local pool=history.pool + if pool.apricornBalls==history.rows and equal(history.rows,history.rowSnapshot)then + pool.apricornBalls=history.beforeRows + end + if pool.scripts==history.scripts then + for key,value in pairs(history.changed)do + if pool.scripts[key]==value and equal(value,history.snapshots[key])then + pool.scripts[key]=history.beforeScripts[key] + end + end + local same=true + for key,value in pairs(pool.scripts)do if history.beforeScripts[key]~=value then same=false;break end end + if same then + for key,value in pairs(history.beforeScripts)do if pool.scripts[key]~=value then same=false;break end end + end + if same then pool.scripts=history.beforeScripts end + end + end + if data then previous[data]=nil end + local desc,why + if loader:_generation()==2 then desc,why=Programs.describe(data)end + local state={data=data,description=desc,why=why,base={}} + if desc then + local ids=Ids.new(loader) + for _,r in ipairs(desc.base)do + local id=ids:read('items',r.apricorn) or r.apricorn + if state.base[id]then id=r.apricorn end + state.base[id]=copy(r) + end + end + mounted[loader]=state + loader.content.apricorns.base=function()return state.base end +end +function M.apply(loader,data) + if loader:_generation()~=2 or not data then return end + local registry=loader.content.apricorns + if not next(registry.ops) and not require('src.mods.Runtime').wants('apricorn.converted')then return end + local state=assert(mounted[loader]);local desc=state.description + if not desc and not next(registry.ops)then return end + assert(desc,state.why) + local pool=data.map_scripts + assert(pool and pool.scripts==desc.scripts,'apricorns: native Kurt programs changed during mod load') + local recipes,normalized=ordered(loader) + -- A removed/rekeyed native recipe stops accepting new work, but an old + -- event can still deliver its original job unless an active rule claims it. + local scripts,touched=Programs.project(desc,recipes,desc.base) + local history={pool=pool,beforeRows=pool.apricornBalls,rows=copy(recipes), + rowSnapshot=copy(recipes),beforeScripts=pool.scripts,scripts=scripts,changed={},snapshots={}} + for key in pairs(touched)do history.changed[key]=scripts[key];history.snapshots[key]=copy(scripts[key])end + -- Keep the folded view in actual native keys after deferred references + -- resolve. The generic post-merge reference audit reads the same view as + -- the native projection; the owner journals remain intact for rollback. + for id,value in pairs(normalized)do registry.cache[id]={value=value}end + -- All validation and IR construction precedes the first native mutation. + pool.apricornBalls=history.rows;pool.scripts=scripts + previous[data]=history +end +function M.api(loader,mod,native,generation) + local available=generation==2 + local reason=not available and 'unsupported: apricorns has no native target in this dataset' or nil + local registry=loader.content.apricorns + local function key(id) + if registry:get(id)~=nil or registry.ops[id]then return id end + local ok,item=pcall(nativeItem,loader,id) + if ok then + for other,value in registry:each()do if value.apricorn==item then return other end end + end + return id + end + local function view(value) + if not value then return nil end + local ids=Ids.new(loader);value=copy(value) + value.apricorn=ids:read('items',value.apricorn) + value.ball=ids:read('items',value.ball) + return value + end + local out={available=available,supports=function()return available,reason end} + function out:get(id)if not available then return nil,reason end;return view(native:get(key(id)))end + function out:has(id)return self:get(id)~=nil end + function out:each() + if not available then return function()end end + local nextRow=native:each() + return function()local id,value=nextRow();return id,view(value)end + end + for _,verb in ipairs({'register','override','patch','remove'})do + out[verb]=function(_,id,value) + if not available then return nil,reason end + assert(type(id)=='string' and id~='','apricorns: id is required');id=key(id) + local state=assert(mounted[loader]);assert(state.description,state.why) + if verb=='remove'then native:remove(id);return end + finite(value);assert(type(value)=='table' and value~=Merge.DELETE,'apricorns: expected recipe record') + for k in pairs(value)do assert(registry.spec.fields[k],'apricorns: unknown field '..tostring(k))end + local payload=copy(value) + local effective=verb=='patch' and Merge.deepMerge(copy(registry:get(id) or{}),payload,'record') or payload + effective=row(loader,id,effective,true) + ordered(loader,id,effective,true) + native[verb](native,id,effective) + return out:get(id) + end + end + return out +end +return M diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..6e87ec547 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -717,7 +717,7 @@ function Loader:_contentApi(mod, registry, deprecation) if apiLevel >= 2 then error(err, 0) end Logger.warn("[%s] %s", modId, err) end - return { + local api = { register = function(_, id, value) note() validate("register", id, value) @@ -750,6 +750,10 @@ function Loader:_contentApi(mod, registry, deprecation) return registry:each() end, } + if registry.name == "apricorns" then + return require("src.mods.ApricornContent").api(loader,mod,api,loader:_generation()) + end + return api end -- mod.commands is sugar over the commands registry; the engine's own verbs @@ -1326,6 +1330,7 @@ function Loader:load(data) end end end + require("src.mods.ApricornContent").prepare(self, data) -- vanilla content is registrations too, and they land before discovery so -- a mod's register collides with the engine's and has to say override require("src.mods.Builtins").install(self.content, data) @@ -1490,6 +1495,27 @@ function Loader:load(data) -- the commands registry is final once every entry chunk has run, so -- each map_scripts contribution's rows can be judged before they merge self:_validateScripts() + -- Resolve recipe/item forward references against all surviving entries, + -- before any native script projection or data merge. Repeat after owner + -- rollback so another recipe cannot retain an item supplied by a failed mod. + for _=1,#self.order+1 do + local problems=require("src.mods.ApricornContent").validate(self) + local failed=false + for _,problem in ipairs(problems)do + local mod=problem.owner and self.mods[problem.owner] + if mod and not mod.failed then self:_fail(mod,"failed",problem.message);failed=true end + end + if not failed then break end + self:_enforceDependencies() + for i=#self.loaded,1,-1 do + local mod=self.loaded[i] + if mod.failed then self:_rollback(mod.manifest.id);table.remove(self.loaded,i)end + end + for i=#self.order,1,-1 do + local mod=self.mods[self.order[i]] + if mod and mod.failed then table.remove(self.order,i)end + end + end -- merge: fold every touched id from its pristine base value and write it -- home, creating the Data namespace when the base modules never shipped -- one. A registry nobody wrote to -- engine included -- is skipped, so @@ -1551,6 +1577,7 @@ function Loader:load(data) stampAudioOwners(data, name, registry) end end + require("src.mods.ApricornContent").apply(self, data) -- dangling f.id references are attributed to the id's last writer; -- api 1 mods keep the warning-only compat path if data then diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 9014835cc..d5d216eab 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -1259,6 +1259,13 @@ R.text_pointers = { -- ------- persistence +R.apricorns = { + semantics="record", generations="2", + fields={apricorn=f.id("items"),ball=f.id("items"),event=f.int(0,65534),index=f.int(1,65535)}, + example='mod.content.apricorns:patch("RED_APRICORN", {ball="ULTRA_BALL"})', + notes="Native Gen2 recipe records bind real Kurt item selection, event flags and ball delivery. A current native Kurt program import and indexed GSC item IDs are required. New recipes preserve the selected native item byte; generated native branches retain original dialogue/day flags/refusal behavior.", +} + -- compose, keyed by the owning mod id: the runner walks each owner's chain -- in semver order against the versions recorded in the save R.migrations = { diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua new file mode 100644 index 000000000..50866a636 --- /dev/null +++ b/src/mods/UnifiedIds.lua @@ -0,0 +1,85 @@ +-- Resolve portable names through this boot's own data, never a hard-coded +-- National Dex/item-number table (ROM hacks can assign entirely different IDs). +local Ids = {} +local function slug(name) + if type(name) ~= "string" then return nil end + name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M") + :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2") + return name ~= "" and name or nil +end +local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" } +local function canonical(key, row, kind) + if kind == "pokemon" or kind == "moves" or kind == "items" then + if type(key) ~= "string" or key:match("^SPECIES_%d+$") + or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then + return aliases[slug(row.name)] or slug(row.name) + end + return aliases[key] or key + end + -- Trainer classes, individual trainers and map IDs are game-specific. + return type(key) == "number" and tostring(key) or key +end + +function Ids.new(loader) + local indexes = {} + local function index(kind) + if indexes[kind] then return indexes[kind] end + local out = { public = {}, native = {} } + indexes[kind] = out + local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind)) + -- Preserve identity/name metadata before the loader merges alias keys + -- into separate result tables. Presentation-name patches cannot rename IDs. + loader._unifiedIdBase = loader._unifiedIdBase or {} + local base = loader._unifiedIdBase[kind] + if not base then + base = {} + for key, row in pairs(registry.base and registry.base() or {}) do + if type(row) == "table" then + base[key] = { identity = row, id = canonical(key, row, kind) } + end + end + loader._unifiedIdBase[kind] = base + end + local groups = {} + for key, row in registry:each() do + if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then + -- Aliases pointing at one pristine record form one identity even + -- after an earlier native mod patched only one of its keys. + local identity = base[key] and base[key].identity or row + local group = groups[identity] + if not group then + local id = base[key] and base[key].id or canonical(key, row, kind) + if id then + group = { id = id, key = key, keys = {} } + groups[identity] = group + local previous = out.public[id] + -- false is an ambiguity marker, never last-writer-wins. + if previous ~= nil then out.public[id] = false else out.public[id] = group end + end + end + if group then + group.keys[#group.keys + 1] = key + out.native[key] = group + if type(key) == "number" then group.key = key end + end + end + end + return out + end + return { + resolve = function(_, kind, id) + local found = index(kind).public[id] + if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end + if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end + return found + end, + read = function(_, kind, id) + if id == nil or id == false or id == 0 or id == "" then return nil end + local found = index(kind).native[id] + return found and found.id or tostring(id) + end, + entries = function(_, kind) return index(kind).public end, + } +end +return Ids diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index adea141ad..a70b7cfa7 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -896,6 +896,13 @@ function Gen2Commands.scriptVarValue(ctx, var) return 1 end +function Commands.g2_apricorn_converted(ctx,apricorn,ball,event) + local Runtime=require("src.mods.Runtime") + if Runtime.wants("apricorn.converted")then + Runtime.emit("apricorn.converted",{apricorn=apricorn,ball=ball,event=event}) + end +end + -- Script_loadmem (25:$7495): write an immediate byte to a WRAM address. The -- same address-keyed mirror readmem/writemem use -- keyed by the raw -- address number exactly as they key it, so a script that pokes a byte here diff --git a/src/script/Gen2ScriptVM.lua b/src/script/Gen2ScriptVM.lua index 0d5c51cfe..d30338cc8 100644 --- a/src/script/Gen2ScriptVM.lua +++ b/src/script/Gen2ScriptVM.lua @@ -477,6 +477,7 @@ L.setevent = function(ir, s) else emit(s,{"set_flag",eventFlag(ir[2])}) end end L.clearevent = function(ir, s) emit(s, { "clear_flag", eventFlag(ir[2]) }) end +L.apricornconverted = function(ir,s)emit(s,{"g2_apricorn_converted",ir[2],ir[3],ir[4]})end L.checkflag = function(ir, s) emit(s, { "check_flag", engineFlag(ir[2]) }) end L.setflag = function(ir, s) emit(s, { "set_flag", engineFlag(ir[2]) }) end L.clearflag = function(ir, s) emit(s, { "clear_flag", engineFlag(ir[2]) }) end diff --git a/src/world/Gen2ApricornPrograms.lua b/src/world/Gen2ApricornPrograms.lua new file mode 100644 index 000000000..b80a71476 --- /dev/null +++ b/src/world/Gen2ApricornPrograms.lua @@ -0,0 +1,188 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +-- Project recipe records into decoded native Kurt programs. Dialogue, +-- working/day flags, quantity and bag-refusal branches remain native IR. +local Merge=require('src.mods.Merge') +local M={} +local copy=Merge.deepCopy +local function number(value) + return type(value)=='number' and value%1==0 and value>=0 and value<=65534 +end +local function item(key) + local n=type(key)=='string' and tonumber(key:match('^ITEM_(%d%d%d)$')) + return n and n>0 and n<255 and n or nil +end +M.item=item +local function selected(row) + return type(row)=='table' and row[1]=='special' and + (row[3]=='SelectApricornForKurt' or row[3]=='Special_SelectApricornForKurt' or row[3]=='select_apricorn') +end +local function eventBranch(scripts,label,gave) + local rows=scripts[label] + if type(rows)~='table' or #rows~=2 then return nil end + if type(rows[1])~='table' or type(rows[2])~='table' then return nil end + if rows[1][1]~='setevent' or not number(rows[1][2]) or + rows[2][1]~='sjump' or rows[2][2]~=gave then return nil end + return rows[1][2] +end +local function chain(rows,start,kind,known,count,ask) + local seen,targets={},{} + for n=0,count-1 do + local a,b=rows[start+n*2],rows[start+n*2+1] + if type(a)~='table' or type(b)~='table' or a[1]~=kind or + not known[a[2]] or seen[a[2]] or b[1]~='iftrue' or + type(b[2])~='string' or ask and b[2]~=ask then return nil end + seen[a[2]]=true;targets[a[2]]=b[2] + end + return targets +end +function M.describe(data) + local pool=data and data.map_scripts + local roles=pool and pool.apricornPrograms + local scripts=pool and pool.scripts + local recipes=pool and pool.apricornBalls + if type(roles)~='table' or type(scripts)~='table' or type(recipes)~='table' or #recipes==0 then + return nil,'apricorns: native Kurt programs require a current ROM import' + end + for _,role in ipairs({'ask','pending','gave','cancel'})do + if type(roles[role])~='string' or type(scripts[roles[role]])~='table' then + return nil,'apricorns: missing native Kurt program '..role + end + end + local ask=scripts[roles.ask];local zero + for i,row in ipairs(ask)do + if selected(row)then + if zero then return nil,'apricorns: ambiguous native selection' end + local nextRow=ask[i+1] + if type(nextRow)=='table' and nextRow[1]=='ifequal' and nextRow[2]==0 and nextRow[3]==roles.cancel then zero=i+1 end + end + end + if not zero then return nil,'apricorns: native selection/cancel signature differs' end + local cases,at={},zero+1 + while type(ask[at])=='table' and ask[at][1]=='ifequal' do + local row=ask[at];local id=row[2] + local event=eventBranch(scripts,row[3],roles.gave) + if type(id)~='number' or id%1~=0 or id<1 or id>254 or cases[id] or not event then + return nil,'apricorns: invalid native selection branch' + end + cases[id]=event;at=at+1 + end + if #ask~=at+1 or type(ask[at])~='table' or type(ask[at+1])~='table' or + ask[at][1]~='setevent' or not number(ask[at][2]) or + ask[at+1][1]~='sjump' or ask[at+1][2]~=roles.gave then + return nil,'apricorns: native default selection signature differs' + end + local base,items,events,default={},{},{},nil + for i,row in ipairs(recipes)do + if type(row)~='table'then return nil,'apricorns: invalid native recipe record' end + local id=item(row.apricorn) + if not id or not item(row.ball) or items[id] then return nil,'apricorns: invalid native recipe table' end + local event=cases[id] + if not event then + if default then return nil,'apricorns: ambiguous native default recipe' end + default=id;event=ask[at][2] + end + if events[event] then return nil,'apricorns: duplicate native recipe event' end + items[id]=true;events[event]=true + base[i]={apricorn=row.apricorn,ball=row.ball,event=event,index=i} + end + if not default then return nil,'apricorns: no native default recipe' end + for id in pairs(cases)do if not items[id] then return nil,'apricorns: native selection is outside recipe table' end end + local pending=scripts[roles.pending];local targets + for i=1,#pending do + local found=chain(pending,i,'checkevent',events,#base) + if found then + if targets then return nil,'apricorns: ambiguous native collection chain' end + targets=found + end + end + if not targets then return nil,'apricorns: native collection chain differs' end + local template,giftAt,clearAt + for _,row in ipairs(base)do + local rows=scripts[targets[row.event]] + if type(rows)~='table' then return nil,'apricorns: native delivery program is absent' end + local gift,clear + for i,op in ipairs(rows)do + if type(op)~='table'then return nil,'apricorns: invalid native delivery row' end + if op[1]=='verbosegiveitem' or op[1]=='verbosegiveitemvar' then + if gift or op[2]~=item(row.ball) then return nil,'apricorns: native ball output differs from recipe' end + gift=i + elseif op[1]=='clearevent' and op[2]==row.event then + if clear then return nil,'apricorns: ambiguous native event clear' end + clear=i + end + end + if not gift or not clear or clear<=gift then return nil,'apricorns: native delivery/event signature differs' end + if not template then template,giftAt,clearAt=rows,gift,clear end + end + return {roles=copy(roles),base=base,items=items,events=events,template=template, + giftAt=giftAt,clearAt=clearAt,zero=zero,scripts=scripts,ask=ask} +end +function M.project(description,recipes,retired) + local d=description;local scripts=d.scripts;local out={} + for key,value in pairs(scripts)do out[key]=value end + local choices,deliveries,generated={},{},{} + local used,generation={},1 + for label in pairs(scripts)do + if type(label)=='string'then + local n=tonumber(label:match('^engine:apricorn:(%d+):')) + if n then used[n]=true end + end + end + while used[generation]do generation=generation+1 end + local function key(kind,index) + local id='engine:apricorn:'..generation..':'..kind..':'..index + assert(scripts[id]==nil,'apricorns: generated script label collision') + return id + end + for i,row in ipairs(recipes)do + assert(item(row.apricorn) and item(row.ball) and number(row.event),'apricorns: invalid projected recipe') + local choose=key('choose',i) + choices[#choices+1]={'ifequal',item(row.apricorn),choose} + generated[choose]={{'setevent',row.event},{'sjump',d.roles.gave}} + end + local deliveryRules,events={},{} + for _,list in ipairs({recipes,retired or {}})do + for _,row in ipairs(list)do + if not events[row.event]then deliveryRules[#deliveryRules+1]=row;events[row.event]=true end + end + end + for i,row in ipairs(deliveryRules)do + local deliver=key('deliver',i) + deliveries[#deliveries+1]={'checkevent',row.event};deliveries[#deliveries+1]={'iftrue',deliver} + local body=copy(d.template);body[d.giftAt][2]=item(row.ball);body[d.clearAt][2]=row.event + table.insert(body,d.clearAt+1,{'apricornconverted',row.apricorn,row.ball,row.event}) + generated[deliver]=body + end + choices[#choices+1]={'sjump',d.roles.cancel} + local touched,memberCount,pendingCount={ },0,0 + for label,rows in pairs(scripts)do + if type(rows)=='table'then + local result,i,changed={},1,false + while i<=#rows do + local op=rows[i] + if chain(rows,i,'checkitem',d.items,#d.base,d.roles.ask) then + for _,row in ipairs(recipes)do + result[#result+1]={'checkitem',item(row.apricorn)} + result[#result+1]={'iftrue',d.roles.ask} + end + i=i+#d.base*2;changed=true;memberCount=memberCount+1 + elseif chain(rows,i,'checkevent',d.events,#d.base) then + for _,row in ipairs(deliveries)do result[#result+1]=copy(row)end + i=i+#d.base*2;changed=true;pendingCount=pendingCount+1 + else result[#result+1]=op;i=i+1 end + end + -- Ask is an actual queued root. Its original prefix includes the real + -- selected item and native zero/cancel branch; never substitute an ID. + if label==d.roles.ask then + result={};for n=1,d.zero do result[n]=copy(rows[n])end + for _,row in ipairs(choices)do result[#result+1]=copy(row)end + changed=true + end + if changed then out[label]=copy(result);touched[label]=true end + end + end + assert(memberCount>0 and pendingCount>0,'apricorns: native item/event consumers are absent') + for label,rows in pairs(generated)do out[label]=rows;touched[label]=true end + return out,touched +end +return M diff --git a/tests/engine/gen2_apricorn_content.lua b/tests/engine/gen2_apricorn_content.lua new file mode 100644 index 000000000..fb8b4f904 --- /dev/null +++ b/tests/engine/gen2_apricorn_content.lua @@ -0,0 +1,276 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +-- Authored IR, not cartridge scripts or dialogue. Exercise the real loader, +-- native VM, menus, flags, inventory and observer boundaries together. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner') +local Stack=require('src.core.StateStack');local Flags=require('src.script.Gen2Flags') +local P=require('src.world.Gen2ApricornPrograms');local Merge=require('src.mods.Merge') +local Commands=require('src.script.Commands');local Sound=require('src.core.Sound') +local oldText,oldSound=Commands.show_text,Sound.play +Commands.show_text=function()end;Sound.play=function()end -- external IO only +local function working(version)return version=='crystal' and 80 or 79 end +local function fixture(version) + local d=T.fixtures.fresh() + for i=1,7 do + local key=string.format('ITEM_%03d',110+i) + d.items[key]={id=key,name='APRICORN '..i,pocket='ITEM'} + end + d.items.ITEM_173={id='ITEM_173',name='BERRY',pocket='ITEM'} + for i=201,202 do + local key=string.format('ITEM_%03d',i) + d.items[key]={id=key,name='BALL '..i,pocket='BALL',ball=true} + end + local ask={{'special',86,'SelectApricornForKurt'},{'ifequal',0,'CANCEL'}} + local scripts={ASK=ask,GAVE={{'setflag',working(version)},{'end'}},CANCEL={{'setval',0},{'end'}}, + WAIT={{'setval',98},{'end'}},FULL={{'setval',99},{'end'}},DONE={{'end'}}} + local pending,rows={},{} + for i=1,7 do + local key=string.format('ITEM_%03d',110+i) + rows[i]={apricorn=key,ball='ITEM_201',index=i} + if i>1 then ask[#ask+1]={'ifequal',110+i,'CHOOSE'..i}end + scripts['CHOOSE'..i]={{'setevent',599+i},{'sjump','GAVE'}} + pending[#pending+1]={'checkevent',599+i};pending[#pending+1]={'iftrue','DELIVER'..i} + scripts['DELIVER'..i]={{'checkflag',working(version)},{'iftrue','WAIT'}, + version=='crystal' and {'verbosegiveitemvar',201,22} or {'verbosegiveitem',201,1}, + {'iffalse','FULL'},{'clearevent',599+i},{'sjump','DONE'}} + end + ask[#ask+1]={'setevent',600};ask[#ask+1]={'sjump','GAVE'} + for i=1,7 do pending[#pending+1]={'checkitem',110+i};pending[#pending+1]={'iftrue','ASK'}end + pending[#pending+1]={'end'} + scripts.PENDING=pending;scripts.OVERLAPPING=Merge.deepCopy(pending) + d.map_scripts={scripts=scripts,apricornBalls=rows,apricornInfo={complete=true}, + apricornPrograms={ask='ASK',pending='PENDING',gave='GAVE',cancel='CANCEL'}} + return d +end +local function load(d,sources,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local files,paths={},{} + for _,entry in ipairs(sources or{})do + local id,source=entry[1],entry[2];local path='mods/'..id + files[path..'/manifest.json']=Json.encode({id=id,name=id,version='1.0.0',api=mode and mode[1] or 2, + content_format=mode and mode[2] or 'native',entry='main.lua',affects_link=false}) + files[path..'/main.lua']='local mod=...;mod.exports.api=mod;'..source;paths[#paths+1]=path + end + return T.sdk.loadMods(paths,{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +local function game(d,inventory) + local stack=setmetatable({},{__index=Stack});stack:init();stack:push({name='map'}) + return {data=d,save={inventory=inventory or{},flags={}},stack=stack,overworld={}} +end +local function run(g,key) + local runner=Runner.new(g,g.overworld);runner:run(assert(VM.compile(g.data,key)));return runner +end +local function press(g,key) + g.input={wasPressed=function(_,k)return k==key end};g.stack:update(0) +end +local recipe=[[{apricorn='BERRY',ball='BALL_202',event=5000,index=8}]] +local source=[[ + local p=mod.content["apricorns"] + assert(p:supports()) + assert(p:get('APRICORN_1').apricorn=='APRICORN_1') + local v=p:get('APRICORN_1');v.ball='LEAK' + assert(p:get('APRICORN_1').ball=='BALL_201') + local n=0;for id,row in p:each()do n=n+1;row.event=65534 end;assert(n==7) + p:register('BERRY_RECIPE',]]..recipe..[[) + p:patch('APRICORN_1',{ball='BALL_202',event=5100,index=1}) + mod.exports.seen={} + mod.exports.stop=mod.events:on("apricorn.converted",function(e) + mod.exports.seen[#mod.exports.seen+1]={apricorn=e.apricorn,ball=e.ball,event=e.event} + e.apricorn='LEAK';e.ball='LEAK';e.event=123;return false + end) +]] +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture(version);local originalScripts=d.map_scripts.scripts + local originalRows=d.map_scripts.apricornBalls + local oldCompiled=assert(VM.compile(d,'PENDING')) + local r=load(d,{{'recipes',source}},mode);local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' native registry and event registration load') + if #r.errors==0 then + local e=r.loader.exports.recipes + T.eq(#d.map_scripts.apricornBalls,8,label..' actual selector receives all eight native recipes') + T.eq(d.map_scripts.apricornBalls[8].apricorn,'ITEM_173',label..' semantic input resolves to a real native item byte') + T.check(d.map_scripts.scripts~=originalScripts,label..' projection changes VM cache identity') + T.check(VM.compile(d,'PENDING')~=oldCompiled,label..' an already compiled root is recompiled') + T.eq(originalScripts.ASK[3][2],112,label..' original native branches are not overwritten') + local g=game(d,{ITEM_173=3}) + local runner=run(g,'OVERLAPPING') + T.eq(g.stack:top().items[1].label,'BERRY x3',label..' overlapping native membership root accepts the eighth item') + press(g,'a') + if version=='crystal'then press(g,'down');press(g,'down');press(g,'a')end + local quantity=version=='crystal' and 3 or 1 + T.eq(runner:isRunning(),false,label..' actual native selection finishes') + T.eq(runner.ctx.g2Var,173,label..' VM retains actual item byte without fake ordinal remapping') + T.eq(g.save.g2KurtApricorns,quantity,label..' native handover quantity survives projection') + T.eq(g.save.flags[Flags.eventFlag(5000)],true,label..' native selection sets the authored word-sized event') + T.eq(g.save.flags[Flags.scriptFlag(working(version))],true,label..' native GAVE working flag is preserved') + run(g,'PENDING') + T.eq(g.save.inventory.ITEM_202,nil,label..' working native branch defers delivery') + T.eq(#e.seen,0,label..' waiting job cannot emit a converted event') + require('src.script.Gen2Daily').onNewDay(g.save) + T.eq(g.save.flags[Flags.scriptFlag(working(version))],nil,label..' actual daily reset clears native work') + g.save.inventory.ITEM_202=99;run(g,'PENDING') + T.eq(g.save.flags[Flags.eventFlag(5000)],true,label..' actual bag refusal retains the job event') + T.eq(#e.seen,0,label..' refused native gift cannot emit a converted event') + g.save.inventory.ITEM_202=nil;run(g,'PENDING') + T.eq(g.save.inventory.ITEM_202,quantity,label..' actual VM gives the authored ball in confirmed quantity') + T.eq(g.save.inventory.ITEM_201,nil,label..' template ball is not accidentally awarded') + T.eq(g.save.flags[Flags.eventFlag(5000)],false,label..' real native clear precedes completion') + T.eq(#e.seen,1,label..' successful native delivery emits exactly once') + local observed=e.seen[1] + T.eq(observed.apricorn,mode[1]==3 and 'BERRY' or 'ITEM_173',label..' observer receives the API-specific item identity') + T.eq(observed.ball,mode[1]==3 and 'BALL_202' or 'ITEM_202',label..' observer receives the actual delivered ball') + T.eq(observed.event,5000,label..' observer receives the actual cleared event') + run(g,'PENDING');T.eq(#e.seen,1,label..' cleared event prevents duplicate completion') + g=game(d,{ITEM_173=3});runner=run(g,'PENDING');press(g,'a') + if version=='crystal'then + press(g,'b');T.eq(g.save.inventory.ITEM_173,3,label..' quantity cancellation consumes nothing') + press(g,'b') + else -- Gold/Silver have already confirmed their one-item transaction + T.eq(g.save.inventory.ITEM_173,2,label..' Gold/Silver remain one-at-a-time') + end + if version=='crystal'then + T.eq(g.save.flags[Flags.eventFlag(5000)],nil,label..' cancellation cannot create a pending job') + end + local api=e.api.content.apricorns + T.check(not pcall(function()api:patch('APRICORN_1',{event=5300})end),label..' frozen content refuses late writes') + end + finish(r) + local clean=load(d) + T.eq(#clean.errors,0,label..' unloading reloads cleanly') + T.eq(d.map_scripts.scripts,originalScripts,label..' disabling restores original native script table') + T.eq(d.map_scripts.apricornBalls,originalRows,label..' disabling restores original native recipe table') + finish(clean) + end +end +V.set('crystal') +for _,bad in ipairs({ + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=65535,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=-1,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=0/0,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=5000,index=math.huge})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=601,index=8})", + "p:register('BAD',{apricorn='APRICORN_2',ball='BALL_202',event=5000,index=8})", + "p:register('BAD',{apricorn='MISSING',ball='BALL_202',event=5000,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='MISSING',event=5000,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=5000,index=8,typo=true})", + "p:patch('APRICORN_1',{apricorn=mod.DELETE})", + "local x={};x.apricorn=x;p:register('BAD',x)", + "p:register('BAD',"..recipe..");error('entry failure')", +})do + local d=fixture('crystal');local scripts,rows=d.map_scripts.scripts,d.map_scripts.apricornBalls + local r=load(d,{{'broken',"local p=mod.content["apricorns"];p:patch('APRICORN_1',{event=5100});"..bad}}) + T.check(#r.errors>0,'invalid authored recipe is attributed to its owner: '..bad) + T.eq(d.map_scripts.scripts,scripts,'failed owner cannot leak projected native scripts') + T.eq(d.map_scripts.apricornBalls,rows,'failed owner rolls back prior valid recipe patches') + finish(r) +end +do + local d=fixture('crystal') + local r=load(d,{{'a_recipe',[[mod.content.apricorns:register('FORWARD', + {apricorn='LATE_FRUIT',ball='BALL_202',event=5200,index=8})]]}, + {'z_item',[[mod.content.items:register('ITEM_174',{id='ITEM_174',name='LATE FRUIT',price=0,pocket='ITEM'})]]}}) + T.eq(#r.errors,0,'semantic native item references may be provided by a later mod') + T.eq(d.map_scripts.apricornBalls[8] and d.map_scripts.apricornBalls[8].apricorn,'ITEM_174', + 'forward item reference resolves before actual native projection') + local g=game(d,{ITEM_174=1});local runner=run(g,'PENDING');press(g,'a');press(g,'a') + T.eq(runner.ctx.g2Var,174,'later native item executes through actual selection byte') + T.eq(g.save.flags[Flags.eventFlag(5200)],true,'forward recipe enters its actual pending event') + finish(r) +end +do + local d=fixture('crystal');local scripts,rows=d.map_scripts.scripts,d.map_scripts.apricornBalls + local r=load(d,{{'a_recipe',[[mod.content.apricorns:register('FORWARD', + {apricorn='LATE_FRUIT',ball='BALL_202',event=5200,index=8})]]}, + {'z_item',[[mod.content.items:register('ITEM_174',{id='ITEM_174',name='LATE FRUIT',price=0,pocket='ITEM'});error('supplier failed')]]}}) + T.check(#r.errors>=2,'failed item supplier also attributes unresolved recipe to its owner') + T.eq(d.items.ITEM_174,nil,'failed supplier leaves no actual native item behind') + T.eq(d.map_scripts.scripts,scripts,'failed forward recipe leaves native script identity intact') + T.eq(d.map_scripts.apricornBalls,rows,'failed forward recipe leaves native table intact') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,{{'no_byte',[[ + mod.content.items:register('AUTHORED_FRUIT',{id='AUTHORED_FRUIT',name='Authored fruit',price=0,pocket='ITEM'}) + mod.content.apricorns:register('UNINDEXED', + {apricorn='AUTHORED_FRUIT',ball='BALL_202',event=5200,index=8}) + ]]}}) + T.check(#r.errors>0,'an arbitrary semantic item cannot fabricate a native ROM byte') + T.eq(d.items.AUTHORED_FRUIT,nil,'unindexed recipe failure rolls back its item registration') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,{{'a_first',[[ + mod.content.apricorns:register('FIRST',{apricorn='BERRY',ball='BALL_202',event=5200,index=8}) + ]]},{'z_conflict',[[ + mod.content.apricorns:register('SECOND',{apricorn='ITEM_173',ball='ITEM_201',event=5300,index=9}) + ]]}}) + T.check(#r.errors>0,'semantic/native aliases cannot register the same byte twice') + T.eq(d.map_scripts.apricornBalls[8] and d.map_scripts.apricornBalls[8].event,5200, + 'later conflicting owner rolls back without discarding the earlier valid owner') + T.eq(#d.map_scripts.apricornBalls,8,'duplicate alias never enters native menu') + finish(r) +end +do + local d=fixture('crystal');local scripts,rows=d.map_scripts.scripts,d.map_scripts.apricornBalls + local r=load(d,{{'a_supplier',[[ + mod.content.items:register('ITEM_174',{id='ITEM_174',name='LATE FRUIT',price=0}) + mod.content.apricorns:register('BAD',{apricorn='BERRY',ball='MISSING',event=5200,index=8}) + ]]},{'b_consumer',[[mod.content.apricorns:register('FORWARD', + {apricorn='LATE_FRUIT',ball='BALL_202',event=5300,index=9})]]}}) + T.check(#r.errors>=2,'late recipe failure revalidates consumers after its item supplier rolls back') + T.eq(d.items.ITEM_174,nil,'late validation rolls back supplier item content') + T.eq(d.map_scripts.scripts,scripts,'cascading forward-reference failure leaves no projected script table') + T.eq(d.map_scripts.apricornBalls,rows,'cascading failure leaves pristine native rows') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,{{'removed',[[mod.content.apricorns:remove('APRICORN_1') + mod.exports.count=0;mod.events:on("apricorn.converted",function()mod.exports.count=mod.exports.count+1 end)]]}}) + T.eq(#r.errors,0,'removal projects native membership and pending branches') + T.eq(#d.map_scripts.apricornBalls,6,'removed recipe cannot be newly selected') + local g=game(d,{ITEM_111=2});run(g,'PENDING') + T.eq(g.stack:top().name,'map','removed item no longer opens the native selector') + g.save.flags[Flags.eventFlag(600)]=true;g.save.g2KurtApricorns=2;run(g,'PENDING') + T.eq(g.save.inventory.ITEM_201,2,'removed recipe still delivers an already pending native job') + T.eq(r.loader.exports.removed.count,1,'retired pending job completes only after native gift') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,{{'recipes',source}}) + local scripts=d.map_scripts.scripts;local label + for key in pairs(scripts)do if key:match('^engine:apricorn:')then label=key;break end end + scripts[label][1]={'setval',77};local external=scripts[label] + d.map_scripts.apricornBalls[1].ball='ITEM_201' + finish(r);local clean=load(d) + T.eq(d.map_scripts.scripts[label],external,'reload preserves an externally edited generated label') + T.eq(d.map_scripts.apricornBalls[1].ball,'ITEM_201','reload preserves externally edited native rows') + finish(clean) +end +for _,version in ipairs({'red','firered','emerald','heartgold'})do + V.set(version);local d=fixture('crystal');local scripts,rows=d.map_scripts.scripts,d.map_scripts.apricornBalls + local r=load(d,{{'isolation',[[local p=mod.content["apricorns"];assert(not p:supports()) + assert(p:get('APRICORN_1')==nil);p:register('NO_WRITE',]]..recipe..[[) + assert(not p:supports())]]}}) + T.eq(#r.errors,0,version..' unsupported handle is readable and reports unavailable') + T.eq(d.map_scripts.scripts,scripts,version..' cannot mutate a lookalike native script pool') + T.eq(d.map_scripts.apricornBalls,rows,version..' cannot acquire Gen2 native recipes') + finish(r) +end +V.set('crystal') +do + local d=fixture('crystal');d.map_scripts.apricornPrograms=nil + local r=load(d,{{'oldcache',"mod.content.apricorns:register('NEW',"..recipe..")"}}) + T.check(#r.errors>0,'old cache reports missing native Kurt bindings to the writing owner') + finish(r) + r=load(d,{{'observer',"mod.events:on("apricorn.converted",function()end)"}}) + T.eq(#r.errors,0,'an observer alone cannot prevent an older cache from booting') + finish(r) +end +Commands.show_text=oldText;Sound.play=oldSound;V.set(original) +T.finish('native apricorn registry, actual Kurt VM transactions, completion observers and reload isolation') diff --git a/tests/engine/gen2_apricorn_programs_import.lua b/tests/engine/gen2_apricorn_programs_import.lua new file mode 100644 index 000000000..4154e8726 --- /dev/null +++ b/tests/engine/gen2_apricorn_programs_import.lua @@ -0,0 +1,59 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local E=require('src.import.RomExtractorGen2') +local V=require('src.core.GameVersion');local original=V.get() +local Importer=require('src.import.RomImporter');local P=require('src.world.Gen2ApricornPrograms') +local markerFor +for i=1,100 do local name,value=debug.getupvalue(Importer.readyReport,i) + if name=='markerFor'then markerFor=value;break end;if not name then break end +end +assert(markerFor) +local names={'Kurt1.AskApricorn','Kurt1.GotLureBall','Kurt1.GaveKurtApricorns','Kurt1.Cancel'} +local function extractor(version,missing,outside) + local symbols={} + for i,name in ipairs(names)do if i~=missing then + symbols[name]={1,outside==i and 0x8000 or outside==-i and 0x100 or 0x4000+i} + end end + return E.new(string.rep('\0',0x8000),version,{symbols=symbols}) +end +local function pool()return{scripts={},queue={}}end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local q=pool();local e=extractor(version) + local roles=e:gen2QueueApricornPrograms(q) + T.eq(#q.queue,4,version..' all native Kurt entry points enter the real decoder queue') + for _,role in ipairs({'ask','pending','gave','cancel'})do + T.check(type(roles[role])=='string',version..' '..role..' retains the actual native script key') + end + e:gen2QueueApricornPrograms(q) + T.eq(#q.queue,4,version..' repeated roots do not duplicate native decode work') + for i=1,4 do + q=pool();T.eq(extractor(version,i):gen2QueueApricornPrograms(q),nil,version..' missing role cannot advertise partial bindings') + T.eq(#q.queue,0,version..' missing role is discovered before any queue mutation') + q=pool();T.eq(extractor(version,nil,i):gen2QueueApricornPrograms(q),nil,version..' invalid bank address is rejected') + T.eq(#q.queue,0,version..' invalid role cannot add a partial native queue') + q=pool();T.eq(extractor(version,nil,-i):gen2QueueApricornPrograms(q),nil,version..' home-bank engine code is not native script bytecode') + T.eq(#q.queue,0,version..' home-bank rejection precedes queue mutation') + end + local marker=markerFor(version) + T.check(marker:find('gen2%-apricorn%-programs%-v1:')~=nil,version..' cache includes decoded Kurt binding revision') + love.filesystem.write(V.cachePrefix(version)..'rom-cache.complete',marker:gsub('gen2%-apricorn%-programs%-v1:','')) + local report=Importer.readyReport(version) + T.eq(report.ok,false,version..' old recipe-only cache cannot bypass native program reimport') + T.check(report.why:find('another version',1,true)~=nil,version..' cache revision is checked before generated-file readiness') +end +for _,version in ipairs({'prism','polishedcrystal','red','emerald'})do + local q=pool();T.eq(extractor(version):gen2QueueApricornPrograms(q),nil,version..' foreign layout has no guessed Kurt bindings') + T.eq(#q.queue,0,version..' foreign decoder queue is unchanged') + T.check(not markerFor(version):find('gen2%-apricorn%-programs%-v1:'),version..' unrelated import marker is unchanged') +end +for _,ask in ipairs({{},{{'special',86,'SelectApricornForKurt'},{'ifequal',0,'CANCEL'},false}, + {{'special',86,'SelectApricornForKurt'},{'ifequal',0,'CANCEL'},false,{'sjump','GAVE'}}})do + local d={map_scripts={apricornPrograms={ask='ASK',pending='PENDING',gave='GAVE',cancel='CANCEL'}, + scripts={ASK=ask,PENDING={},GAVE={},CANCEL={}},apricornBalls={{apricorn='ITEM_111',ball='ITEM_201'}}}} + local ok,description,why=pcall(P.describe,d) + T.eq(ok,true,'malformed native selection reports a binding error without crashing mod-free load') + T.eq(description,nil,'malformed native selection cannot produce a writable native projection') + T.check(type(why)=='string','malformed selection explains the unavailable native target') +end +V.set(original) +T.finish('native Kurt program import queue, completeness, cache revisions and malformed signatures') From d9933b8e51820ccbef7cec0d3f38c9c1156229ed Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:35:05 +0100 Subject: [PATCH 42/67] Preserve nested test-source quoting in native integration cases --- tests/engine/gen2_apricorn_content.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/engine/gen2_apricorn_content.lua b/tests/engine/gen2_apricorn_content.lua index fb8b4f904..c3783f601 100644 --- a/tests/engine/gen2_apricorn_content.lua +++ b/tests/engine/gen2_apricorn_content.lua @@ -164,7 +164,7 @@ for _,bad in ipairs({ "p:register('BAD',"..recipe..");error('entry failure')", })do local d=fixture('crystal');local scripts,rows=d.map_scripts.scripts,d.map_scripts.apricornBalls - local r=load(d,{{'broken',"local p=mod.content["apricorns"];p:patch('APRICORN_1',{event=5100});"..bad}}) + local r=load(d,{{'broken',"local p=mod.content.apricorns;p:patch('APRICORN_1',{event=5100});"..bad}}) T.check(#r.errors>0,'invalid authored recipe is attributed to its owner: '..bad) T.eq(d.map_scripts.scripts,scripts,'failed owner cannot leak projected native scripts') T.eq(d.map_scripts.apricornBalls,rows,'failed owner rolls back prior valid recipe patches') @@ -268,7 +268,7 @@ do local r=load(d,{{'oldcache',"mod.content.apricorns:register('NEW',"..recipe..")"}}) T.check(#r.errors>0,'old cache reports missing native Kurt bindings to the writing owner') finish(r) - r=load(d,{{'observer',"mod.events:on("apricorn.converted",function()end)"}}) + r=load(d,{{'observer',"mod.events:on('apricorn.converted',function()end)"}}) T.eq(#r.errors,0,'an observer alone cannot prevent an older cache from booting') finish(r) end From e5886cb49b7762c1db1e9de2d3baf2e05b036b8f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:38:34 +0100 Subject: [PATCH 43/67] Retire unchanged recipe records individually during native mod reload --- src/mods/ApricornContent.lua | 46 ++++++++++++++++++++++---- tests/engine/gen2_apricorn_content.lua | 12 +++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/mods/ApricornContent.lua b/src/mods/ApricornContent.lua index cf674e37b..bd55a63be 100644 --- a/src/mods/ApricornContent.lua +++ b/src/mods/ApricornContent.lua @@ -66,8 +66,8 @@ local function ordered(loader,overrideId,overrideRow,defer) if a.value.index~=b.value.index then return a.value.index Date: Mon, 26 Oct 2026 06:00:25 +0100 Subject: [PATCH 44/67] Bind native GSC landmarks to map markers, names and edition boundaries --- docs/native-gen2-landmarks.md | 70 ++++++++ src/import/RomExtractorGen2.lua | 13 +- src/import/RomImporter.lua | 2 +- src/mods/LandmarkContent.lua | 134 ++++++++++++++ src/mods/Loader.lua | 8 +- src/mods/Schemas.lua | 7 + src/ui/PokegearMenu.lua | 3 +- src/ui/TownMap.lua | 13 +- src/world/Gen2Landmarks.lua | 76 ++++++++ tests/engine/gen2_landmark_content.lua | 232 +++++++++++++++++++++++++ 10 files changed, 545 insertions(+), 13 deletions(-) create mode 100644 docs/native-gen2-landmarks.md create mode 100644 src/mods/LandmarkContent.lua create mode 100644 src/world/Gen2Landmarks.lua create mode 100644 tests/engine/gen2_landmark_content.lua diff --git a/docs/native-gen2-landmarks.md b/docs/native-gen2-landmarks.md new file mode 100644 index 000000000..66898dff0 --- /dev/null +++ b/docs/native-gen2-landmarks.md @@ -0,0 +1,70 @@ +# Native GSC landmark records + +`mod.content.landmarks` projects semantic records into the native numeric +`field.townMap.landmarks` table. Pokegear, TownMap and the native +`getcurlandmarkname` / `getlandmarkname` script commands read that same table. +It does not create a competing map or name service. + +```lua +mod.content.landmarks:patch("LANDMARK_ROUTE_29", {name="CUSTOM ROAD", x=80}) +mod.content.landmarks:register("LANDMARK_EXTRA", { + name="CUSTOM COVE", x=100, y=104, index=200, +}) +``` + +The shape matches the reference: optional `id`, required `name`, `x`, `y` +and `index`. The native map-header index is a byte, including zero. +Coordinates retain the cartridge's hardware OAM units. They are not tile +cells: the existing TownMap marker-centre and Pokegear sprite-origin +corrections still apply once at drawing time. Changing a display name does +not rename the landmark identity. + +The import records constant identities and the actual native row indices. +Gold/Silver have95 rows; Crystal has96, including byte95 for the ship. The +previous fixed95-row reader omitted Crystal's last row. Crystal's Battle +Tower also shifts the first Kanto byte from46 to47. Pokegear no longer uses +Crystal's literal boundary for Gold/Silver; both map readers consume +the authored native Pallet landmark index, retaining imported-map-header +fallbacks for older caches. An explicit manifest identity order overrides +the stock order. Unreviewed hack layouts do not acquire guessed stock names. +Stock caches require the `gen2-landmark-identities-v1` revision and reimport. + +Valid native names survive a failed/absent map-picture decode without +inventing map artwork. Missing coordinate data is not replaced with guessed +positions. A new indexed authored name can also serve native scripts in a +dataset with no town-map art. + +Upstream API1/2 native content uses detached record views. The fork separately +tests API2/3 unified content and its radio helper; those layers are not +imported by this standalone PR. +Failed entries roll back before native projection; content freezes after +loading. Numeric/string native aliases cannot resurrect a removed slot. +Index collisions follow the reference's deterministic rules: the native +identity wins its original slot, otherwise the lexically earlier ID wins. +The reference order remains a fallback for unclaimed original slots after +moving a record. Native map-header bytes are not implicitly rewritten. +Unload restores unchanged owned slots and removes exclusively created +namespaces, preserving external edits. Other generations report this native +target unavailable instead of mutating a similar-looking table. + +Validation exercises the real mod loader, ROM reader/text decoder, native VM, +TownMap controller and Pokegear draw commands. Image loading/drawing uses an +authored IO fixture; the independent existing town-map pixel decoder suite +still runs. A private read-only Crystal Rev0 probe reads96 actual records, +compares every raw coordinate pair with an independent byte reader and +executes the ship's authored name through the real VM. The original ROM is +unchanged. This is not fresh GPU, full original-story, Windows hardware, +actual Gold/Silver ROM or complete Gen1 parity acceptance. + +Reference behavior: read-only Gen1 source +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, `Schemas.landmarks` and +`core/gen2/Nests.lua`. Constant identities and order were checked against +[pokegold landmark constants](https://github.com/pret/pokegold/blob/master/constants/landmark_constants.asm) +and [pokecrystal landmark constants](https://github.com/pret/pokecrystal/blob/master/constants/landmark_constants.asm). +Only compatibility identifiers/order and original adapter logic are included; +no cartridge coordinates, dialogue, graphics or private mod content is shipped. +Original contributions by Roxas2712; retain existing engine/third-party rights +and credits. The [specific permission for Cedric / UNDERdecoded, with visible +credit](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md) +applies to offered contributions; it does not license cartridge data or +other authors' work. diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 90b74029e..f79895141 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -11418,7 +11418,10 @@ function RomExtractorGen2:gen2Landmarks() if not (sym and self.rom) then return nil end local charmap = self:readSourceTable("charmap") local out = {} - for id = 0, GEN2_LANDMARK_COUNT - 1 do + local order,why=require("src.world.Gen2Landmarks").order(self.version,self.manifest) + if why then return nil end + local count=#order>0 and #order or self:layout("townMapLandmarkCount",GEN2_LANDMARK_COUNT) + for id = 0, math.min(256,count) - 1 do pcall(function() local row = sym.address + id * GEN2_LANDMARK_BYTES local name = self:decodeGen2TextAt(sym.bank, @@ -11426,6 +11429,7 @@ function RomExtractorGen2:gen2Landmarks() -- $1F is the landmark line break (TownMap_ConvertLineBreakCharacters) name = name:gsub("{BYTE:1F}", "\n") out[id] = { + id = order[id+1], index = id, x = self.rom:byte(sym.bank, row), y = self.rom:byte(sym.bank, row + 1), name = name ~= "" and name or nil, @@ -11436,12 +11440,15 @@ function RomExtractorGen2:gen2Landmarks() end function RomExtractorGen2:gen2TownMap() + local landmarks = self:gen2Landmarks() local johto = self:gen2TownMapImage("JohtoMap", "ui/town_map_johto.png") - if not johto then return nil end + -- Native name scripts do not depend on a successfully decoded map picture. + if not johto and not landmarks then return nil end local out = { johto = johto, kanto = self:gen2TownMapImage("KantoMap", "ui/town_map_kanto.png"), - landmarks = self:gen2Landmarks(), + landmarks = landmarks, + landmarkOrder = require("src.world.Gen2Landmarks").order(self.version,self.manifest), } -- Player "you are here" icon: ChrisSpriteGFX facing-down (4 tiles = 16x16) -- tinted with PAL_OW_RED so it is not a greyscale silhouette on the map. diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index cbd913e5a..4903915fe 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1310,7 +1310,7 @@ local function markerFor(version) -- Only these three caches need rebuilding; other markers remain stable. -- v8: field.gen2TradeAnim, GSC's own trade movie art (Gen2TradeArt). if version == "gold" or version == "silver" or version == "crystal" then - revision = "gen2-field-encounters-v8:" + revision = "gen2-field-encounters-v8:gen2-landmark-identities-v1:" end -- field.tradeArt: the cable-trade movie's tiles out of the ROM -- (Gen1TradeArt); without it the trade ran on drawn stand-ins. diff --git a/src/mods/LandmarkContent.lua b/src/mods/LandmarkContent.lua new file mode 100644 index 000000000..1b45322bb --- /dev/null +++ b/src/mods/LandmarkContent.lua @@ -0,0 +1,134 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-landmarks.md. +-- Semantic landmark records project into the native town-map number space. +local Merge=require('src.mods.Merge') +local Native=require('src.world.Gen2Landmarks') +local Schemas=require('src.mods.Schemas') +local M={} +local copy=Merge.deepCopy +local previous=setmetatable({},{__mode='k'}) +local mounted=setmetatable({},{__mode='k'}) +local function equal(a,b) + if a==b then return true end + if type(a)~='table' or type(b)~='table'then return false end + for k,v in pairs(a)do if not equal(v,b[k])then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +local function finite(v,seen) + if type(v)=='number'then assert(v==v and math.abs(v)=0 and n<=255 and n or nil +end +local function record(registry,id,value) + assert(type(value)=='table' and value~=Merge.DELETE,'landmarks: expected record') + finite(value) + for key in pairs(value)do assert(registry.spec.fields[key],'landmarks: unknown field '..tostring(key))end + local ok,why=Schemas.check(registry.spec,'landmarks',id,value,'register');assert(ok,why) + return copy(value) +end +function M.prepare(loader,data) + local history=data and previous[data] + if history and data.field==history.field and history.field.townMap==history.town and + history.town.landmarks==history.after then + local rows=history.after + for key,entry in pairs(history.changed)do + if rows[key]==entry.value and equal(rows[key],entry.snapshot)then rows[key]=entry.before end + end + local pristine=true + for key,value in pairs(rows)do if history.before[key]~=value then pristine=false;break end end + if pristine then + for key,value in pairs(history.before)do if rows[key]~=value then pristine=false;break end end + end + if pristine then + if history.createdRows then history.town.landmarks=nil else history.town.landmarks=history.before end + end + if history.createdTown and not next(history.town)then history.field.townMap=nil end + if history.createdField and not next(history.field)then data.field=nil end + end + if data then previous[data]=nil end + local state={base={},order=Native.dataOrder(data)} + if loader:_generation()==2 then + local town=Native.town(data);local rows=town and town.landmarks or{} + local indices={} + for key in pairs(rows)do local n=index(key);if n then indices[n]=true end end + local ordered={};for n in pairs(indices)do ordered[#ordered+1]=n end;table.sort(ordered) + for _,n in ipairs(ordered)do + local native=rows[n] or rows[tostring(n)] + if type(native)=='table'then + local id=native.landmarkId or native.id or state.order[n+1] or 'landmark:'..n + if type(id)~='string' or id=='' or state.base[id]then id='landmark:'..n end + state.base[id]={id=native.id,name=native.name,x=native.x,y=native.y,index=n} + end + end + end + mounted[loader]=state + loader.content.landmarks.base=function()return state.base end +end +function M.apply(loader,data) + local registry=loader.content.landmarks + if loader:_generation()~=2 or not data or not next(registry.ops)then return end + local state=assert(mounted[loader]);local values,chosen={},{} + for id,value in registry:each()do + value=registry.ops[id] and record(registry,id,value) or copy(value) + values[id]=value + local n=index(value.index) + assert(n,'landmarks: native index must be a byte') + local old=chosen[n] + -- Match the reference's index arbitration: a native identity keeps its + -- original slot; newcomers use lexical ID order, independent of pairs(). + if not old or state.order[n+1]==id or state.order[n+1]~=old and id= 2 then error(err, 0) end Logger.warn("[%s] %s", modId, err) end - return { + local api = { register = function(_, id, value) note() validate("register", id, value) @@ -750,6 +750,10 @@ function Loader:_contentApi(mod, registry, deprecation) return registry:each() end, } + if registry.name == "landmarks" then + return require("src.mods.LandmarkContent").api(loader,mod,api,loader:_generation()) + end + return api end -- mod.commands is sugar over the commands registry; the engine's own verbs @@ -1326,6 +1330,7 @@ function Loader:load(data) end end end + require("src.mods.LandmarkContent").prepare(self, data) -- vanilla content is registrations too, and they land before discovery so -- a mod's register collides with the engine's and has to say override require("src.mods.Builtins").install(self.content, data) @@ -1551,6 +1556,7 @@ function Loader:load(data) stampAudioOwners(data, name, registry) end end + require("src.mods.LandmarkContent").apply(self, data) -- dangling f.id references are attributed to the id's last writer; -- api 1 mods keep the warning-only compat path if data then diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 9014835cc..101347247 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -1259,6 +1259,13 @@ R.text_pointers = { -- ------- persistence +R.landmarks = { + semantics="record",generations="2", + fields={id=f.opt(f.str),name=f.str,x=f.int(0),y=f.int(0),index=f.int(0,255)}, + example='mod.content.landmarks:patch("LANDMARK_ROUTE_29", {x=80})', + notes="Native Gen2 town-map records and script landmark names. Coordinates retain native OAM byte units, not tile cells. The index is the map-header byte. Native identities win index collisions; otherwise lexical ID order is deterministic. Imported identity metadata is independent of display names.", +} + -- compose, keyed by the owning mod id: the runner walks each owner's chain -- in semver order against the versions recorded in the save R.migrations = { diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index f0da924a2..23045f7fa 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -125,7 +125,6 @@ local function visibleCards(save) return list end -local KANTO_LANDMARK_FIRST = 0x2F local images = {} local function loadImage(path) @@ -837,7 +836,7 @@ function PokegearMenu:drawMap() local landmark = def and def.landmark local entry = landmark and town.landmarks and (town.landmarks[landmark] or town.landmarks[tostring(landmark)]) - local path = (landmark and landmark >= KANTO_LANDMARK_FIRST and town.kanto) + local path = (landmark and landmark >= require("src.world.Gen2Landmarks").kantoStart(game.data) and town.kanto) or town.johto local image = path and loadImage(path) if image then diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index 63a5a8155..646745a93 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -37,18 +37,19 @@ end -- Gen2 town map. field.townMap is a completely different shape from Gen1's: -- two full-screen pictures (johto/kanto) plus `landmarks`, a flat id -> {name, -- x, y} table whose coordinates are absolute screen pixels, and every map --- record carries the landmark id it belongs to. Landmarks from $2E up are --- Kanto: `DEF KANTO_LANDMARK EQU const_value` sits immediately BEFORE --- LANDMARK_PALLET_TOWN in constants/landmark_constants.asm, so Pallet is --- the first of them, not Route 1. A landmark short of it and Pallet read --- as Johto -- flown to off the Johto picture with no cursor drawn on it. -local GEN2_KANTO_FIRST = 0x2E +-- record carries the landmark id it belongs to. Gold/Silver's fallback +-- boundary is $2e; Crystal inserts the Battle Tower and starts Kanto at $2f. +-- Prefer the active cartridge's imported Pallet header below. local function gen2Locations(game) local tm = (game.data.field or {}).townMap if type(tm) ~= "table" or type(tm.landmarks) ~= "table" or not tm.johto then return nil end + -- Crystal inserts the Battle Tower landmark before Silver Cave. The + -- native Pallet header is the first Kanto landmark on the active cartridge; + -- the old Gold-only $2e boundary misclassified Crystal's Silver Cave. + local kantoFirst = require("src.world.Gen2Landmarks").kantoStart(game.data) local byLandmark, locs = {}, {} for id, e in pairs(tm.landmarks) do local x, y = tonumber(e and e.x), tonumber(e and e.y) diff --git a/src/world/Gen2Landmarks.lua b/src/world/Gen2Landmarks.lua new file mode 100644 index 000000000..2ce8d3b55 --- /dev/null +++ b/src/world/Gen2Landmarks.lua @@ -0,0 +1,76 @@ +-- Original adapter/selection logic by Roxas2712. See the PR contribution permission in docs/native-gen2-landmarks.md. +-- Constant identities/order only, not cartridge coordinates, names or art. +-- Orders: pret/pokegold and pret/pokecrystal constants/landmark_constants.asm. +local Version=require('src.core.GameVersion') +local M={} +local gold={} +for name in ([[ + SPECIAL NEW_BARK_TOWN ROUTE_29 CHERRYGROVE_CITY ROUTE_30 ROUTE_31 + VIOLET_CITY SPROUT_TOWER ROUTE_32 RUINS_OF_ALPH UNION_CAVE ROUTE_33 + AZALEA_TOWN SLOWPOKE_WELL ILEX_FOREST ROUTE_34 GOLDENROD_CITY RADIO_TOWER + ROUTE_35 NATIONAL_PARK ROUTE_36 ROUTE_37 ECRUTEAK_CITY TIN_TOWER BURNED_TOWER + ROUTE_38 ROUTE_39 OLIVINE_CITY LIGHTHOUSE ROUTE_40 WHIRL_ISLANDS ROUTE_41 + CIANWOOD_CITY ROUTE_42 MT_MORTAR MAHOGANY_TOWN ROUTE_43 LAKE_OF_RAGE + ROUTE_44 ICE_PATH BLACKTHORN_CITY DRAGONS_DEN ROUTE_45 DARK_CAVE ROUTE_46 + SILVER_CAVE PALLET_TOWN ROUTE_1 VIRIDIAN_CITY ROUTE_2 PEWTER_CITY ROUTE_3 + MT_MOON ROUTE_4 CERULEAN_CITY ROUTE_24 ROUTE_25 ROUTE_5 UNDERGROUND_PATH + ROUTE_6 VERMILION_CITY DIGLETTS_CAVE ROUTE_7 ROUTE_8 ROUTE_9 ROCK_TUNNEL + ROUTE_10 POWER_PLANT LAVENDER_TOWN LAV_RADIO_TOWER CELADON_CITY SAFFRON_CITY + ROUTE_11 ROUTE_12 ROUTE_13 ROUTE_14 ROUTE_15 ROUTE_16 ROUTE_17 ROUTE_18 + FUCHSIA_CITY ROUTE_19 ROUTE_20 SEAFOAM_ISLANDS CINNABAR_ISLAND ROUTE_21 + ROUTE_22 VICTORY_ROAD ROUTE_23 INDIGO_PLATEAU ROUTE_26 ROUTE_27 TOHJO_FALLS + ROUTE_28 FAST_SHIP +]]):gmatch('%S+')do gold[#gold+1]='LANDMARK_'..name end +local function validOrder(source) + if type(source)~='table' or #source==0 or #source>256 then return nil end + local result,seen={},{} + for i=1,#source do + local id=source[i] + if type(id)~='string' or id=='' or seen[id]then return nil end + seen[id]=true;result[i]=id + end + for key in pairs(source)do + if type(key)~='number' or key%1~=0 or key<1 or key>#source then return nil end + end + return result +end +function M.order(version,metadata) + local declared=metadata and (metadata.landmarkOrder or metadata.constants and metadata.constants.landmarkOrder) + if declared~=nil then + local order=validOrder(declared) + if not order then return {},'invalid landmark identity order' end + return order + end + if version~='gold' and version~='silver' and version~='crystal'then return{}end + local result={};for i,id in ipairs(gold)do result[i]=id end + if version=='crystal'then table.insert(result,30,'LANDMARK_BATTLE_TOWER')end + return result +end +function M.town(data)return data and data.field and data.field.townMap end +function M.dataOrder(data) + local town=M.town(data) + if town and town.landmarkOrder~=nil then return validOrder(town.landmarkOrder) or{}end + return M.order(Version.get(),data and data.constants and {landmarkOrder=data.constants.landmarkOrder}) +end +function M.index(data,id) + local town=M.town(data);local rows=town and town.landmarks + local found + for key,row in pairs(rows or{})do + if type(row)=='table' and (row.landmarkId or row.id)==id and tonumber(row.index or key)then + local n=tonumber(row.index or key) + if found and found~=n then return nil end + found=n + end + end + if found then return found,true end + for i,name in ipairs(M.dataOrder(data))do if name==id then return i-1,false end end +end +function M.kantoStart(data) + local index,native=M.index(data,'LANDMARK_PALLET_TOWN') + local town=M.town(data) + if index and (native or town and town.landmarkOrder~=nil or + data and data.constants and data.constants.landmarkOrder~=nil)then return index end + local pallet=data and data.maps and data.maps.PALLET_TOWN + return tonumber(pallet and pallet.landmark) or index or (Version.get()=='crystal' and 0x2f or 0x2e) +end +return M diff --git a/tests/engine/gen2_landmark_content.lua b/tests/engine/gen2_landmark_content.lua new file mode 100644 index 000000000..d7476c38d --- /dev/null +++ b/tests/engine/gen2_landmark_content.lua @@ -0,0 +1,232 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-landmarks.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local Native=require('src.world.Gen2Landmarks');local E=require('src.import.RomExtractorGen2') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner') +local Gear=require('src.ui.PokegearMenu');local TownMap=require('src.ui.TownMap') +local Importer=require('src.import.RomImporter') +local markerFor +for i=1,100 do local name,value=debug.getupvalue(Importer.readyReport,i) + if name=='markerFor'then markerFor=value;break end;if not name then break end +end +assert(markerFor) +local function extractor(version,order) + local records={} + for i=0,95 do records[#records+1]=string.char(i%160,(i+20)%144,0,0x50)end + local raw=string.rep('\0',0x4000)..table.concat(records) + raw=raw..string.rep('\0',0x5000-#raw)..string.char(0x80,0x80,0x50)..string.rep('\0',0x2ffd) + local e=E.new(raw,version,{symbols={Landmarks={1,0x4000}},landmarkOrder=order}) + function e:readSourceTable(key)assert(key=='charmap');return{['128']='A'}end -- authored source-table IO + return e +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local order=Native.order(version);local count=version=='crystal' and 96 or 95 + T.eq(#order,count,version..' exact native landmark count includes the final ship') + T.eq(order[30],version=='crystal' and 'LANDMARK_BATTLE_TOWER' or 'LANDMARK_ROUTE_40',version..' Duellturm insertion is edition-specific') + T.eq(order[#order],'LANDMARK_FAST_SHIP',version..' final identity remains the native ship') + local e=extractor(version);local rows=e:gen2Landmarks() + T.eq(rows[count-1].id,'LANDMARK_FAST_SHIP',version..' real ROM reader reaches final native row') + T.eq(rows[count-1].index,count-1,version..' importer records actual map-header byte') + T.eq(rows[count-1].name,'AA',version..' actual native text decoder supplies display name') + T.eq(rows[count-1].x,(count-1)%160,version..' raw OAM x coordinate is not converted to a tile cell') + T.eq(rows[count-1].y,(count+19)%144,version..' raw OAM y coordinate is unchanged') + T.eq(rows[count],nil,version..' stock import cannot read beyond its actual landmark count') + order[1]='LEAK';T.eq(Native.order(version)[1],'LANDMARK_SPECIAL',version..' identity views are detached') + local town=e:gen2TownMap() + T.eq(town.landmarks[count-1].id,'LANDMARK_FAST_SHIP',version..' missing picture does not discard valid script names') + T.eq(town.landmarkOrder[count],'LANDMARK_FAST_SHIP',version..' actual town-map output carries identity order') + T.eq(town.johto,nil,version..' missing picture cannot invent fallback map art') + local marker=markerFor(version) + T.check(marker:find('gen2%-landmark%-identities%-v1:')~=nil,version..' new native identity cache revision is present') + love.filesystem.write(V.cachePrefix(version)..'rom-cache.complete',marker:gsub('gen2%-landmark%-identities%-v1:','')) + T.eq(Importer.readyReport(version).ok,false,version..' old truncated identity-less cache requires reimport') +end +for _,version in ipairs({'prism','polishedcrystal','red'})do + T.eq(#Native.order(version),0,version..' unreviewed layout cannot acquire stock landmark identities') + T.eq(Native.order(version,{landmarkOrder={'AUTHORED_ZERO','AUTHORED_ONE'}})[2],'AUTHORED_ONE',version..' explicit manifest identities remain authoritative') +end +T.eq(extractor('crystal',{'DUPLICATE','DUPLICATE'}):gen2Landmarks(),nil,'invalid declared identities cannot silently fall back to retail count') +T.eq(extractor('crystal',{'ONLY'}):gen2Landmarks()[1],nil,'explicit custom count bounds actual ROM reads') +local function fixture(version) + local d=T.fixtures.fresh();local pallet=version=='crystal' and 47 or 46 + d.field.townMap={johto='qa-johto-'..version,kanto='qa-kanto-'..version,playerIcon='qa-player-'..version, + landmarkOrder=Native.order(version),landmarks={ + [0]={id='LANDMARK_SPECIAL',index=0,name='NOWHERE',x=0,y=0}, + [1]={id='LANDMARK_NEW_BARK_TOWN',index=1,name='LOCAL HOME',x=20,y=32}, + [2]={id='LANDMARK_ROUTE_29',index=2,name='LOCAL ROAD',x=40,y=48}, + [pallet]={id='LANDMARK_PALLET_TOWN',index=pallet,name='LOCAL PALLET',x=72,y=80}, + [pallet-1]={id='LANDMARK_SILVER_CAVE',index=pallet-1,name='LOCAL CAVE',x=88,y=96}}} + d.maps.HOME={id='HOME',landmark=1};d.maps.CAVE={id='CAVE',landmark=pallet-1} + d.maps.PALLET_TOWN={id='PALLET_TOWN',landmark=pallet};d.maps.EXTRA={id='EXTRA',landmark=200} + d.map_scripts={scripts={HERE={{'getcurlandmarkname',0},{'end'}},THERE={{'getlandmarkname',200,0},{'end'}}}} + return d +end +local function load(d,source,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local files,paths={},{} + if source then + files['mods/landmarks/manifest.json']=Json.encode({id='landmarks',name='Landmarks',version='1.0.0', + api=mode and mode[1] or 2,content_format=mode and mode[2] or 'native',entry='main.lua',affects_link=false}) + files['mods/landmarks/main.lua']='local mod=...;mod.exports.api=mod;'..source;paths={'mods/landmarks'} + end + return T.sdk.loadMods(paths,{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +local function game(d,map) + return {data=d,save={inventory={}},overworld={map={id=map,def=d.maps[map]}}, + stack={pop=function()end},input={wasPressed=function()return false end}} +end +local function run(g,label) + local r=Runner.new(g,g.overworld);r:run(assert(VM.compile(g.data,label)));return r +end +local oldImage,oldDraw=love.graphics.newImage,love.graphics.draw +local paints={} +love.graphics.newImage=function(path)return{path=path,getDimensions=function()return 160,144 end}end +love.graphics.draw=function(image,x,y)paints[#paints+1]={path=image.path,x=x,y=y}end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + do + local d=fixture(version);local before=d.field.townMap.landmarks;local clean=load(d) + T.eq(#clean.errors,0,version..' no-mod native boot stays valid') + T.eq(d.field.townMap.landmarks,before,version..' no-mod load leaves native target identity untouched') + finish(clean) + end + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture(version);local before=d.field.townMap.landmarks + local r=load(d,[[local p=mod.content["landmarks"] + assert(p:supports()) + assert(p:get('LANDMARK_ROUTE_29').name=='LOCAL ROAD') + local v=p:get('LANDMARK_ROUTE_29');v.name='LEAK' + for _,row in p:each()do row.x=999 end + assert(p:get('LANDMARK_ROUTE_29').x==40) + p:patch('LANDMARK_NEW_BARK_TOWN',{name='AUTHORED\nHOME',x=52,y=68}) + p:register('LANDMARK_EXTRA',{name='AUTHORED EXTRA',x=100,y=104,index=200}) + p:remove('LANDMARK_ROUTE_29') + ]],mode) + local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' public landmark records load through real mod API') + T.eq(d.field.townMap.landmarks[1].name,'AUTHORED\nHOME',label..' native numeric row receives authored text') + T.eq(d.field.townMap.landmarks[2],nil,label..' removed row cannot survive by fallback') + T.eq(d.field.townMap.landmarks[200].landmarkId,'LANDMARK_EXTRA',label..' new identity binds actual byte200') + T.eq(before[1].name,'LOCAL HOME',label..' source native row is not mutated') + local g=game(d,'HOME');run(g,'HERE') + T.eq(g.stringBuffers and g.stringBuffers[3],'AUTHORED HOME',label..' actual current-landmark VM reads authored native name') + run(g,'THERE');T.eq(g.stringBuffers and g.stringBuffers[3],'AUTHORED EXTRA',label..' actual named-landmark VM reads registered byte') + local screen=TownMap.new(g) + T.eq(screen.mode,'gen2',label..' real map controller consumes native table') + T.eq(screen.byMap.HOME.px,48,label..' actual map marker applies native OAM origin once') + T.eq(screen.byMap.HOME.py,56,label..' native marker y keeps raw coordinate units') + T.eq(screen.byMap.PALLET_TOWN.region,'kanto',label..' first actual Kanto byte selects Kanto') + T.eq(screen.byMap.CAVE.region,'johto',label..' preceding cave remains on Johto') + T.eq(screen.byMap.EXTRA.name,'AUTHORED EXTRA',label..' native map controller consumes added marker') + local text + local gear=setmetatable({game=g,drawStrip=function()end,drawModeArrow=function()end, + text=function(_,value)text=value end},{__index=Gear}) + paints={};gear:drawMap() + T.eq(text,'AUTHORED HOME',label..' actual Pokegear label reads the same authored name') + T.eq(paints[1].path,d.field.townMap.johto,label..' actual Pokegear paints the correct Johto image') + T.eq(paints[2].x,44,label..' actual Pokegear player sprite consumes authored OAM x') + T.eq(paints[2].y,52,label..' actual Pokegear player sprite consumes authored OAM y') + g.overworld.map={id='PALLET_TOWN',def=d.maps.PALLET_TOWN};paints={};gear:drawMap() + T.eq(paints[1].path,d.field.townMap.kanto,label..' Pokegear correctly paints Gold/Silver first Kanto town too') + local api=r.loader.exports.landmarks.api.content.landmarks + T.check(not pcall(function()api:patch('LANDMARK_EXTRA',{name='LATE'})end),label..' content freezes after load') + finish(r);local clean=load(d) + T.eq(d.field.townMap.landmarks,before,label..' disabling restores original numeric table identity') + T.eq(d.field.townMap.landmarks[200],nil,label..' disabling removes the authored byte') + finish(clean) + end +end +V.set('crystal') +for _,source in ipairs({ + "p:register('BAD',{name='BAD',x=1,y=1,index=256})", + "p:register('BAD',{name='BAD',x=-1,y=1,index=200})", + "p:register('BAD',{name='BAD',x=0/0,y=1,index=200})", + "p:register('BAD',{name='BAD',x=1,y=math.huge,index=200})", + "p:register('BAD',{name='BAD',x=1,y=1,index=200,typo=true})", + "p:patch('LANDMARK_NEW_BARK_TOWN',{name=mod.DELETE})", + "local x={};x.name=x;p:register('BAD',x)", + "error('entry failed')", +})do + local d=fixture('crystal');local before=d.field.townMap.landmarks + local r=load(d,"local p=mod.content.landmarks;p:patch('LANDMARK_NEW_BARK_TOWN',{name='FAIL'});"..source) + T.check(#r.errors>0,'invalid native record is attributed: '..source) + T.eq(d.field.townMap.landmarks,before,'failed entry rolls back prior valid landmark edits') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,[[local p=mod.content.landmarks + p:register('Z_NEW',{name='Z',x=10,y=20,index=200}) + p:register('A_NEW',{name='A',x=30,y=40,index=200}) + p:register('A_SHADOW',{name='SHADOW',x=99,y=99,index=1}) + ]]) + T.eq(#r.errors,0,'reference-compatible duplicate index claims are deterministic') + T.eq(d.field.townMap.landmarks[1].name,'LOCAL HOME','native identity wins its own slot') + T.eq(d.field.townMap.landmarks[200].name,'A','lexically earlier newcomer wins other slot') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,[[mod.content.landmarks:patch('LANDMARK_NEW_BARK_TOWN',{index=201})]]) + T.eq(#r.errors,0,'reference record may move to an unused native index') + T.eq(d.field.townMap.landmarks[201].landmarkId,'LANDMARK_NEW_BARK_TOWN','moved identity reaches new native index') + T.eq(d.field.townMap.landmarks[1].name,'LOCAL HOME','reference order remains fallback for unclaimed original slot') + finish(r) +end +do + local d=fixture('crystal');d.field.townMap.landmarks['2']=d.field.townMap.landmarks[2] + d.map_scripts.scripts.ZERO={{'getlandmarkname',0,0},{'end'}} + local before=d.field.townMap.landmarks;local r=load(d,[[local p=mod.content.landmarks + p:remove('LANDMARK_ROUTE_29');p:patch('LANDMARK_SPECIAL',{name='AUTHORED ZERO'}) + ]]) + T.eq(#r.errors,0,'numeric/string native aliases and byte zero remain supported') + T.eq(d.field.townMap.landmarks[2],nil,'removal drops numeric native slot') + T.eq(d.field.townMap.landmarks['2'],nil,'string fallback cannot resurrect removed landmark') + local g=game(d,'HOME');run(g,'ZERO') + T.eq(g.stringBuffers[3],'AUTHORED ZERO','actual native script names byte zero rather than treating it as absent') + finish(r);local clean=load(d) + T.eq(d.field.townMap.landmarks,before,'unload restores original numeric/string alias layout') + T.eq(before['2'],before[2],'restored aliases retain their original shared record') + finish(clean) +end +do + local d=fixture('crystal');local r=load(d,[[mod.content.landmarks:patch('LANDMARK_PALLET_TOWN',{index=48})]]) + T.eq(#r.errors,0,'moving the logical first Kanto landmark is a native byte edit') + T.eq(Native.kantoStart(d),48,'region boundary consumes authored landmark index metadata') + local g=game(d,'PALLET_TOWN');local screen=TownMap.new(g) + T.eq(screen.byMap.PALLET_TOWN.region,'johto','town map follows the same authored boundary') + paints={};setmetatable({game=g,drawStrip=function()end,drawModeArrow=function()end,text=function()end},{__index=Gear}):drawMap() + T.eq(paints[1].path,d.field.townMap.johto,'actual Pokegear background consumes authored boundary') + finish(r) +end +do + local d=fixture('crystal');local before=d.field.townMap.landmarks + local r=load(d,[[mod.content.landmarks:patch('LANDMARK_NEW_BARK_TOWN',{name='OWN'}) + mod.content.landmarks:register('EXTRA',{name='EXTRA',x=100,y=100,index=200})]]) + local row=d.field.townMap.landmarks[1];row.name='EXTERNAL' + finish(r);local clean=load(d) + T.eq(d.field.townMap.landmarks[1],row,'reload preserves externally edited native record identity') + T.eq(row.name,'EXTERNAL','reload preserves external text') + T.eq(d.field.townMap.landmarks[200],nil,'external edit does not retain unchanged owned addition') + T.eq(d.field.townMap.landmarks[2],before[2],'unrelated original record identity restores') + finish(clean) +end +do + local d=T.fixtures.fresh();d.field=nil + local r=load(d,[[mod.content.landmarks:register('NEW',{name='NEW',x=20,y=30,index=200})]]) + T.eq(#r.errors,0,'indexed authored names can create a native script target without map art') + T.eq(d.field.townMap.landmarks[200].name,'NEW','new native target is readable') + finish(r);local clean=load(d) + T.eq(d.field,nil,'unload retires exclusively owned rows, town and field namespaces') + finish(clean) +end +for _,version in ipairs({'red','firered','emerald','heartgold'})do + V.set(version);local d=fixture('crystal');local before=d.field.townMap.landmarks + local r=load(d,[[local p=mod.content.landmarks;assert(not p:supports());assert(p:get('LANDMARK_ROUTE_29')==nil) + p:register('NO_WRITE',{name='NO',x=20,y=30,index=200})]]) + T.eq(#r.errors,0,version..' absent native target is reported through public handle') + T.eq(d.field.townMap.landmarks,before,version..' cannot mutate lookalike Gen2 town map') + finish(r) +end +love.graphics.newImage=oldImage;love.graphics.draw=oldDraw;V.set(original) +T.finish('native landmark identities, real map/gear/name consumers, reload ownership and edition boundaries') From 40dfd6b930616713b70f05fde4435c52679c9d2d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:03:32 +0100 Subject: [PATCH 45/67] Use the native edition boundary in upstream map row classification --- src/ui/TownMap.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index 646745a93..4727a2725 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -63,7 +63,7 @@ local function gen2Locations(game) -- draws around -- taken raw, every nest sat 4px right and 12px -- below the town it belongs to. px = x - 4, py = y - 12, landmark = tonumber(id), - region = tonumber(id) >= GEN2_KANTO_FIRST and "kanto" or "johto", + region = tonumber(id) >= kantoFirst and "kanto" or "johto", } byLandmark[tonumber(id)] = loc locs[#locs + 1] = loc From 0cd5b7ab725ddc4fd672c4c8a57242ebc0ecc8ca Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:47:41 +0100 Subject: [PATCH 46/67] Run native GSC bedroom decorations through ROM tables, PC and room callbacks --- docs/native-gen2-decorations.md | 83 ++++++++++ src/import/RomExtractorGen2.lua | 134 +++++++++++++++- src/import/RomImporter.lua | 2 +- src/mods/DecorationContent.lua | 100 ++++++++++++ src/mods/Loader.lua | 9 +- src/mods/Schemas.lua | 7 + src/save_convert/Gen2Save.lua | 24 +++ src/script/Gen2Commands.lua | 27 ++++ src/script/Gen2ScriptVM.lua | 25 ++- src/script/Gen2Specials.lua | 14 +- src/ui/Gen2DecorationMenu.lua | 106 +++++++++++++ src/ui/PlayerPC.lua | 20 ++- src/world/Gen2Decorations.lua | 133 ++++++++++++++++ tests/engine/gen2_decoration_content.lua | 194 +++++++++++++++++++++++ tests/engine/gen2_decoration_import.lua | 113 +++++++++++++ 15 files changed, 974 insertions(+), 17 deletions(-) create mode 100644 docs/native-gen2-decorations.md create mode 100644 src/mods/DecorationContent.lua create mode 100644 src/ui/Gen2DecorationMenu.lua create mode 100644 src/world/Gen2Decorations.lua create mode 100644 tests/engine/gen2_decoration_content.lua create mode 100644 tests/engine/gen2_decoration_import.lua diff --git a/docs/native-gen2-decorations.md b/docs/native-gen2-decorations.md new file mode 100644 index 000000000..22c63ead3 --- /dev/null +++ b/docs/native-gen2-decorations.md @@ -0,0 +1,83 @@ +# Native GSC bedroom decorations + +The engine imports decoration attributes, display names, default placed bytes, +gift ordinals and room-description entry points from the user's current +Gold/Silver/Crystal cartridge. No retail attribute catalogue, dialogue or room +art is bundled. The virtual `decorations` registry writes the same DECO-byte +records that native room callbacks and the player's bedroom PC consume. + +```lua +local decorations = mod.content.decorations +local available, reason = decorations:supports() +if available then + decorations:patch("deco:2", { name = "CUSTOM" }) +end +``` + +Native API 1/2 use `deco:0` through `deco:255`. The number is +the cartridge's DECO byte, not a sorted list index. Records contain `type` +(1..6), `name`, optional `action`, `flag` (native event word) and `sprite` +(native object-sprite or map-block byte). Only the fourteen known +`SET_UP_`/`PUT_AWAY_` actions are accepted. Cancel and put-away rows do not grant +ownership. API views are detached; invalid/failed mod entries roll back and +reload retires owned writes individually while retaining external edits. +Removing a record removes both numeric and string aliases of its byte. + +The importer derives the attribute count from the adjacent native names-table +boundary and reads six bytes per row. Invalid/truncated attributes or names +publish no partial table. Species-backed names keep their native species ID, +so later species renames remain visible unless the decoration itself is +renamed. Native initialization is recognized only when the actual instruction +operands store valid bed/poster IDs to the corresponding WRAM symbols. Missing +initialization does not invent retail defaults. Existing placed state, +including an explicitly empty table, is retained. + +The house-PC option appears only with an imported native table. Owned records +form the native categories; their put-away headers and cancel choice follow +the records, with eight visible rows and the native right/left/cancel order +for ornaments. Feedback and side prompts enter the normal ROM text importer +and `TextBox` path. A replacement uses buffers three/four for old/new names. +State changes commit after feedback closes. Moving an ornament clears an +opposite duplicate; selecting the same ornament does not mutate the opposite +side. Ordinary item-storage PCs do not gain this option. + +`PlayersHousePC` waits until LOG OFF/B and returns the accumulated change bit. +The exact GSC `warp NONE` pair reloads the current map after the script ends, +without relocating the player to cell zero. Unknown map pairs are not reloads. +The existing native callback queue then applies placed object visibility, +variable sprites and room blocks. Description bytecode branches into the +actual imported poster/ornament/big-doll scripts rather than being skipped. +Block zero is valid for a nonzero DECO byte; carpet offsets wrap as native +bytes. An empty slot writes no block, leaving the newly loaded bare room. + +Raw SRAM conversion reads/writes the eight placed bytes using the selected +edition's WRAM-to-SRAM layout. The physical order ends with the big doll and +is distinct from the public slot list. Absent legacy decoration state leaves +the imported template intact; invalid placed bytes fail instead of wrapping. +Unreviewed hack layouts receive neither guessed retail tables nor guessed +save offsets. + +## Evidence and remaining scope + +ROM-free tests exercise actual importers, Loader, native script compiler and +runner, menus, TextBox, StateStack, ownership flags, callback commands and save +codec. The eight save offsets are independently checked against each shipped +GSC symbol manifest. Test cartridges and dialogue are authored synthetic data. +The callback/warp tests use controlled overworld presentation/queue boundaries; +they are not GPU-render or complete-game acceptance tests. + +A separate private read-only Crystal probe independently compares all 53 +attribute rows' numeric bytes, imports 45 gift ordinals and the native defaults, +executes the original house-PC/description scripts and room callbacks, and +round-trips all eight placed bytes. Original ROM hashes are checked unchanged. +The local font/audio/render harness is used; no visual acceptance is inferred. + +Full legacy `Decorations` facade parity, the complete gift/purchase lifecycle, +new graphical acceptance and a full unchanged VASC playthrough remain separate +open work. Importing the gift-ordinal table alone does not implement those +callers. The existing preview is not refreshed by this source change. + +Contract references: [Crystal decoration routines](https://github.com/pret/pokecrystal/blob/master/engine/overworld/decorations.asm), +[Gold bedroom script](https://github.com/pret/pokegold/blob/master/maps/PlayersHouse2F.asm). +Original fork additions: [Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md); +existing upstream and third-party rights remain unchanged. diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 90b74029e..c8f354e22 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -3317,6 +3317,133 @@ function RomExtractorGen2:gen2MovementTable() return out end +-- Six native bytes per attribute row: kind/name/action/event-word/sprite. +-- Names immediately follow attributes in stock GSC; that boundary supplies +-- the row count instead of a copied retail catalogue. +function RomExtractorGen2:gen2Decorations() + if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end + local info={complete=false,rowsRead=0,defaultsComplete=false,giftsComplete=false} + local attr,names=self:symbol("DecorationAttributes"),self:symbol("DecorationNames") + local function fail(why)info.reason=why;return {},info end + if not attr or not names then return fail("missing decoration table symbols")end + local count=(names.address-attr.address)/6 + if attr.bank~=names.bank or count%1~=0 or count<1 or count>256 or + attr.address<0x4000 or names.address>0x8000 then return fail("invalid decoration table boundary")end + local Native=require("src.world.Gen2Decorations") + local ok,rows=pcall(function() + local raw,maxName={},0 + for i=0,count-1 do + local r=self.rom:bytes(attr.bank,attr.address+i*6,6) + assert(r[1]>=1 and r[1]<=6,"invalid decoration kind") + assert(r[3]==0 or Native.actionName(r[3]),"invalid decoration action") + assert(r[4]+r[5]*256<65535,"indirect decoration event") + raw[i]=r;if r[1]<=3 then maxName=math.max(maxName,r[2])end + end + local charmap=self:readSourceTable("charmap") + local words,pc={},names.address + for i=0,maxName do + local bytes,ended={},false + for _=1,128 do + assert(pc<0x8000,"decoration name crosses bank") + local b=self.rom:byte(names.bank,pc);pc=pc+1 + if b==0x50 then ended=true;break end + bytes[#bytes+1]=b + end + assert(ended,"unterminated decoration name") + words[i]=self:gen2GlyphBytes(bytes,charmap) + end + local pokemon=self:symbol("PokemonNames") + local out={} + for i=0,count-1 do + local r=raw[i];local name=words[r[2]] + if r[1]>=4 then + assert(pokemon and r[2]>=1,"missing decoration species name") + local address=pokemon.address+(r[2]-1)*10 + assert(address>=0x4000 and address+10<=0x8000,"decoration species name crosses bank") + local bytes={} + for n=0,9 do local b=self.rom:byte(pokemon.bank,address+n);if b==0x50 then break end;bytes[#bytes+1]=b end + name=self:gen2GlyphBytes(bytes,charmap) + info.species=info.species or{};info.sourceNames=info.sourceNames or{} + info.species[i]=string.format("SPECIES_%03d",r[2]);info.sourceNames[i]=name + end + assert(type(name)=="string" and name~="","missing decoration name") + out[i]={type=r[1],name=name,action=Native.actionName(r[3]),flag=r[4]+r[5]*256,sprite=r[6]} + end + return out + end) + if not ok then return fail(tostring(rows))end + local init=self:symbol("InitDecorations") + if init then + local valid,bytes=pcall(self.rom.bytes,self.rom,init.bank,init.address,11) + if valid and bytes[1]==0x3e and bytes[3]==0xea and bytes[6]==0x3e and bytes[8]==0xea and bytes[11]==0xc9 and + rows[bytes[2]] and rows[bytes[2]].action=="SET_UP_BED" and rows[bytes[7]] and rows[bytes[7]].action=="SET_UP_POSTER" then + local bed,poster=self:symbol("wDecoBed"),self:symbol("wDecoPoster") + if bed and poster and bytes[4]+bytes[5]*256==bed.address and bytes[9]+bytes[10]*256==poster.address then + info.defaults={bed=bytes[2],poster=bytes[7]};info.defaultsComplete=true + end + end + end + local gifts=self:symbol("DecorationIDs") + if gifts then + local valid,list=pcall(function() + local out={} + for n=0,255 do + assert(gifts.address+n<0x8000,"decoration gift table crosses bank") + local id=self.rom:byte(gifts.bank,gifts.address+n) + if id==255 then return out end + assert(rows[id] and Native.action(rows[id].action) and not Native.action(rows[id].action).put,"invalid gift decoration") + out[#out+1]=id + end + error("unterminated decoration gift table") + end) + if valid then info.gifts=list;info.giftsComplete=true end + end + info.complete=true;info.rowsRead=count + return rows,info +end + +function RomExtractorGen2:gen2QueueDecorationPrograms(pool) + if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end + local roles={ornament="DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript", + big="DecorationDesc_GiantOrnament.BigDollScript",null="DecorationDesc_NullPoster"} + local found={} + for role,name in pairs(roles)do + local at=self:symbol(name) + if not at or at.address<0x4000 or not self:gen2InRom(at.bank,at.address)then return nil end + found[role]=at + end + local pointers=self:symbol("DecorationDesc_PosterPointers") + if not pointers then return nil end + local ok,posters=pcall(function() + local out={} + for n=0,255 do + local pc=pointers.address+n*3;assert(pc<0x8000,"poster pointers cross bank") + local id=self.rom:byte(pointers.bank,pc) + if id==255 then return out end + assert(pc+2<0x8000,"truncated poster pointer") + local address=self.rom:word(pointers.bank,pc+1) + assert(address>=0x4000 and self:gen2InRom(pointers.bank,address)and not out[id],"invalid poster pointer") + out[id]={bank=pointers.bank,address=address} + end + error("unterminated poster pointers") + end) + if not ok then return nil end + local out={posters={},texts={}} + self._gen2ScriptTexts=self._gen2ScriptTexts or{} + for role,name in pairs({put="PutAwayTheDecoText",empty="NothingToPutAwayText", + setup="SetUpTheDecoText",replace="PutAwayAndSetUpText",same="AlreadySetUpText", + noChoice="PopulateDecoCategoryMenu.NothingToChooseText", + sideSetup="WhichSidePutOnText",sidePut="WhichSidePutAwayText"})do + local at=self:symbol(name) + if at and self:gen2InRom(at.bank,at.address)then + out.texts[role]=self:gen2RegisterScriptText(at.bank,at.address) + end + end + for role,at in pairs(found)do out[role]=self:gen2QueueScript(at.bank,at.address,pool)end + for id,at in pairs(posters)do out.posters[id]=self:gen2QueueScript(at.bank,at.address,pool)end + return out +end + -- Marts is a dw pointer table; each mart is `db count`, the item ids, `db -1`. -- The table ends where its own first entry starts. function RomExtractorGen2:gen2Marts() @@ -8126,7 +8253,10 @@ function RomExtractorGen2:gen2ResolveScriptMapIds(maps, keys, pool) if slot and type(row[slot]) == "number" and type(row[slot + 1]) == "number" then local entry = mapIndex[row[slot] * 256 + row[slot + 1]] local key = entry and keyByLabel[entry.label] - if key then + if (row[1]=="warp"or row[1]=="warpfacing") and row[slot]==0 and row[slot+1]==0 and + (self.version=="gold"or self.version=="silver"or self.version=="crystal")then + row[slot]="NONE";row[slot+1]=0 + elseif key then row[slot] = key row[slot + 1] = 0 end @@ -8891,6 +9021,8 @@ function RomExtractorGen2:extractMapScripts() self:beginStage("Gen2 map scripts end out.stds = self:gen2QueueStdScripts(pool) + out.decorations,out.decorationInfo=self:gen2Decorations() + out.decorationPrograms=self:gen2QueueDecorationPrograms(pool) out.marts = self:gen2Marts() out.spawns = self:gen2SpawnPoints(maps, keys) -- extractField runs after this stage and needs both of these: GSC has no diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index cbd913e5a..d0e8c48bf 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1310,7 +1310,7 @@ local function markerFor(version) -- Only these three caches need rebuilding; other markers remain stable. -- v8: field.gen2TradeAnim, GSC's own trade movie art (Gen2TradeArt). if version == "gold" or version == "silver" or version == "crystal" then - revision = "gen2-field-encounters-v8:" + revision = "gen2-decorations-native-v1:gen2-field-encounters-v8:" end -- field.tradeArt: the cable-trade movie's tiles out of the ROM -- (Gen1TradeArt); without it the trade ran on drawn stand-ins. diff --git a/src/mods/DecorationContent.lua b/src/mods/DecorationContent.lua new file mode 100644 index 000000000..e6d1ddd16 --- /dev/null +++ b/src/mods/DecorationContent.lua @@ -0,0 +1,100 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- A DECO byte is identity, not the sorted position of a mod record. +local Merge=require('src.mods.Merge') +local Native=require('src.world.Gen2Decorations') +local Schemas=require('src.mods.Schemas') +local M={} +local copy=Merge.deepCopy +local mounted=setmetatable({},{__mode='k'}) +local previous=setmetatable({},{__mode='k'}) +local function equal(a,b) + if a==b then return true end + if type(a)~='table'or type(b)~='table'then return false end + for k,v in pairs(a)do if not equal(v,b[k])then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +local function byte(id) + local digits=type(id)=='string'and id:match('^deco:(%d+)$') + local n=digits and tonumber(digits) + assert(n and n>=0 and n<=255 and 'deco:'..n==id,'decorations: expected canonical deco: ID') + return n +end +local function finite(value,seen) + if type(value)=='number'then assert(value==value and math.abs(value)=0 and n<=255 and type(row)=='table'then + state.base['deco:'..n]=copy(data.map_scripts.decorations[n]or row) + end + end + end + mounted[loader]=state + loader.content.decorations.base=function()return state.base end +end +function M.apply(loader,data) + local registry=loader.content.decorations;local state=mounted[loader] + if not(state and state.available)or not next(registry.ops)then return end + local pool=data.map_scripts;local before=pool.decorations;local out={} + for key,row in pairs(before)do if tonumber(key)==nil then out[key]=row end end + for id,value in registry:each()do out[byte(id)]=registry.ops[id]and record(registry,id,value)or copy(value)end + local changed={};local keys={} + for key in pairs(before)do keys[key]=true end;for key in pairs(out)do keys[key]=true end + for key in pairs(keys)do + if before[key]~=out[key]then changed[key]={before=before[key],value=out[key],snapshot=copy(out[key])}end + end + pool.decorations=out;previous[data]={pool=pool,before=before,after=out,changed=changed} +end +function M.api(loader,mod,native,generation) + local state=mounted[loader] + local available=generation==2 and state and state.available or false + local reason=not available and 'unsupported: decorations requires an imported native GSC attribute table'or nil + local registry=loader.content.decorations + local out={available=available,supports=function()return available,reason end} + function out:get(id)if not available then return nil,reason end;byte(id);return copy(native:get(id))end + function out:has(id)return self:get(id)~=nil end + function out:each() + if not available then return function()end end + local nextRow=native:each();return function()local id,value=nextRow();return id,copy(value)end + end + for _,verb in ipairs({'register','override','patch','remove'})do + out[verb]=function(_,id,value) + if not available then return nil,reason end + byte(id) + if verb=='remove'then return native:remove(id)end + finite(value);assert(type(value)=='table'and value~=Merge.DELETE,'decorations: expected record') + for key in pairs(value)do assert(registry.spec.fields[key],'decorations: unknown field '..tostring(key))end + local effective=verb=='patch'and Merge.deepMerge(copy(registry:get(id)or{}),copy(value),'record')or value + effective=record(registry,id,effective);native[verb](native,id,effective) + return out:get(id) + end + end + return out +end +return M diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..22977dbfc 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -717,7 +717,7 @@ function Loader:_contentApi(mod, registry, deprecation) if apiLevel >= 2 then error(err, 0) end Logger.warn("[%s] %s", modId, err) end - return { + local api = { register = function(_, id, value) note() validate("register", id, value) @@ -750,6 +750,11 @@ function Loader:_contentApi(mod, registry, deprecation) return registry:each() end, } + if registry.name=="decorations"then + return require("src.mods.DecorationContent").api(loader,mod,api,loader:_generation()) + end + return api + end -- mod.commands is sugar over the commands registry; the engine's own verbs @@ -1326,6 +1331,7 @@ function Loader:load(data) end end end + require("src.mods.DecorationContent").prepare(self,data) -- vanilla content is registrations too, and they land before discovery so -- a mod's register collides with the engine's and has to say override require("src.mods.Builtins").install(self.content, data) @@ -1551,6 +1557,7 @@ function Loader:load(data) stampAudioOwners(data, name, registry) end end + require("src.mods.DecorationContent").apply(self,data) -- dangling f.id references are attributed to the id's last writer; -- api 1 mods keep the warning-only compat path if data then diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 9014835cc..dd69d2166 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -1257,6 +1257,13 @@ R.text_pointers = { example = 'mod.content.text_pointers:patch("PalletTown", { TEXT_PALLETTOWN_SIGN = { text = "_MySign" } })', } +R.decorations = { + semantics="record",generations="2", + fields={type=f.int(1,6),name=f.str,action=f.opt(f.str),flag=f.int(0,65534),sprite=f.int(0,255)}, + example='mod.content.decorations:patch("deco:2", {name="CUSTOM"})', + notes="Native imported GSC DECO bytes (deco:0..255), ownership event bits, room callbacks and house-PC selection. Action must be a known SET_UP_/PUT_AWAY_ native decoration action. No retail attribute catalogue is bundled. Requires a complete imported native attribute table.", +} + -- ------- persistence -- compose, keyed by the owning mod id: the runner walks each owner's chain diff --git a/src/save_convert/Gen2Save.lua b/src/save_convert/Gen2Save.lua index 79a875bd7..10ae779eb 100644 --- a/src/save_convert/Gen2Save.lua +++ b/src/save_convert/Gen2Save.lua @@ -141,6 +141,7 @@ local LAYOUT_GEN2 = { numBalls = 0xD5FC, balls = 0xD5FD, numPCItems = 0xD616, pcItems = 0xD617, pokegearFlags = 0xD67C, + decorations = 0xD959, -- wDecoBed; eight bytes, big doll is last eventFlags = 0xD7B7, curBox = 0xD8BC, visitedSpawns = 0xD9EE, mapGroup = 0xDA00, mapNumber = 0xDA01, yCoord = 0xDA02, xCoord = 0xDA03, partyCount = 0xDA22, partySpecies = 0xDA23, partyMons = 0xDA2A, @@ -268,6 +269,7 @@ local LAYOUT_CRYSTAL = { numBalls = 0xD8D7, balls = 0xD8D8, numPCItems = 0xD8F1, pcItems = 0xD8F2, pokegearFlags = 0xD957, + decorations = 0xDC0F, -- wDecoBed, distinct from Gold/Silver eventFlags = 0xDA72, curBox = 0xDB72, visitedSpawns = 0xDCA5, mapGroup = 0xDCB5, mapNumber = 0xDCB6, yCoord = 0xDCB7, xCoord = 0xDCB8, partyCount = 0xDCD7, partySpecies = 0xDCD8, partyMons = 0xDCDF, @@ -365,6 +367,11 @@ end function Gen2Save.setLayout(version) applyLayout((type(version) == "table" and version) or LAYOUTS[tostring(version)] or LAYOUT_GEN2) + -- An unreviewed hack's legacy fallback layout does not prove where its + -- decoration bytes live. Explicit custom layouts may name their own run. + if type(version)~="table" and version~="gold" and version~="silver" and version~="crystal" then + O.decorations=nil + end end applyLayout(LAYOUT_GEN2) @@ -1024,6 +1031,9 @@ end -- decode: raw 32768-byte SRAM string -> save.lua-shaped table -- ------------------------------------------------------------------ +-- Physical WRAM order differs from the public semantic slot order. +local DECO_SLOTS={"bed","carpet","plant","poster","console","leftOrnament","rightOrnament","bigDoll"} + function Gen2Save.decode(bytes, data) assert(#bytes == Gen2Save.SAVE_SIZE, "expected a 32768-byte save") assert(charmap, "Gen2Save.setCharmap must be called before decode") @@ -1060,6 +1070,11 @@ function Gen2Save.decode(bytes, data) currentBox = 1, } + if O.decorations then + save.decorations={} + for i,key in ipairs(DECO_SLOTS)do save.decorations[key]=u8(bytes,O.decorations+i-1)end + end + -- bag: four pockets folded into one flat inventory local bagOrder = {} decodePairList(bytes, O.items, MAX_ITEMS, cw, save.inventory, bagOrder) @@ -1240,6 +1255,15 @@ function Gen2Save.encode(save, data, template) for i = 1, Gen2Save.SAVE_SIZE do buf[i] = zero end end + if O.decorations and type(save.decorations)=="table"then + for i,key in ipairs(DECO_SLOTS)do + local value=save.decorations[key]or 0 + assert(type(value)=="number"and value>=0 and value<=255 and value%1==0, + "invalid native decoration byte: "..key) + setByte(buf,O.decorations+i-1,value) + end + end + local player = save.player or {} local playerName = player.name or "GOLD" encodeName(buf, O.playerName, NAME_LENGTH, playerName) diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..9a394bc36 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -896,6 +896,20 @@ function Gen2Commands.scriptVarValue(ctx, var) return 1 end +function Commands.g2_deco_visibility(ctx) + require('src.world.Gen2Decorations').visibility(ctx) +end +function Commands.g2_deco_tiles(ctx) + require('src.world.Gen2Decorations').tiles(ctx) +end +function Commands.g2_deco_description(ctx,kind,allowed) + local target,name=require('src.world.Gen2Decorations').description(ctx.game.data,ctx.save,kind) + if target and allowed and allowed[target]then + if name then setBuffer(ctx.game,3,name)end + return target + end +end + -- Script_loadmem (25:$7495): write an immediate byte to a WRAM address. The -- same address-keyed mirror readmem/writemem use -- keyed by the raw -- address number exactly as they key it, so a script that pokes a byte here @@ -1779,6 +1793,19 @@ end -- --------------------------------------------------------------------------- function Commands.g2_warp(ctx, mapId, x, y, facing) + if mapId=="NONE" then + local ow=ctx.overworld + if ow and ow.map and type(ow.reloadMap)=="function"then + local map=ow.map.id + ctx.afterScript=ctx.afterScript or{} + ctx.afterScript[#ctx.afterScript+1]=function() + if ow.player and DIRS[facing]then ow.player.facing=DIRS[facing]end + ow:reloadMap(map,"native-script") + end + return math.huge + end + return + end if type(mapId) ~= "string" or mapId == "" then return end Commands.warp(ctx, mapId, x, y, facing) end diff --git a/src/script/Gen2ScriptVM.lua b/src/script/Gen2ScriptVM.lua index 0d5c51cfe..08070b97b 100644 --- a/src/script/Gen2ScriptVM.lua +++ b/src/script/Gen2ScriptVM.lua @@ -278,19 +278,30 @@ L.givepokemail = function() end -- NPC instead. -- loadtemptrainer/randomwildmon/loadpikachudata build a battle out of WRAM -- scratch the port fills from its own encounter tables. --- _2dmenu/describedecoration are presentation the port has no +-- _2dmenu is presentation the port has no -- equivalent for; the surrounding dialogue still runs. -- autoinput replays a canned joypad script over a cutscene. -- (catchtutorial used to live here too; it is implemented now, below.) local function vmNoop() end for _, name in ipairs({ "xycompare", "writeobjectxy", "loadtemptrainer", "randomwildmon", - "loadpikachudata", "_2dmenu", "describedecoration", + "loadpikachudata", "_2dmenu", "autoinput", }) do L[name] = vmNoop end +L.describedecoration=function(ir,s) + local p=s.decorationPrograms + if type(p)~='table'then return end + local allowed={} + for _,label in pairs(p.posters or{})do local target=branch(s,label);if target then allowed[target]=true end end + for _,role in ipairs({'ornament','big','null'})do + local target=branch(s,p[role]);if target then allowed[target]=true end + end + emit(s,{'g2_deco_description',ir[2],allowed}) +end + L["end"] = function(_, s) emit(s, { "g2_return" }) end @@ -1080,6 +1091,12 @@ local SPECIALS = { PokemonCenterPC = "g2_pokemon_center_pc", Special_PokemonCenterPC = "g2_pokemon_center_pc", PlayersHousePC = "g2_players_house_pc", + ToggleDecorationsVisibility = "g2_deco_visibility", + ToggleDecorationsVisibilitySpecial = "g2_deco_visibility", + Special_ToggleDecorationsVisibility = "g2_deco_visibility", + ToggleMaptileDecorations = "g2_deco_tiles", + ToggleMaptileDecorationsSpecial = "g2_deco_tiles", + Special_ToggleMaptileDecorations = "g2_deco_tiles", Special_PlayersHousePC = "g2_players_house_pc", Special_KrissHousePC = "g2_players_house_pc", MoveTutor = "g2_move_tutor", @@ -1215,9 +1232,6 @@ local SPECIALS_NOOP = { PlayCurMonCry = true, PlaySlowCry = true, SurfStartStep = true, - -- decorations are not modelled, so toggling their visibility does nothing - ToggleDecorationsVisibility = true, - ToggleMaptileDecorations = true, UnusedDummySpecial = true, UnusedBattleTowerDummySpecial1 = true, UnusedBattleTowerDummySpecial2 = true, @@ -2380,6 +2394,7 @@ function Gen2ScriptVM.compile(data, entry) local state = { out = out, stds = pool.stds, + decorationPrograms = pool.decorationPrograms, has = function(label) return type(label) == "string" and scripts[label] ~= nil end, diff --git a/src/script/Gen2Specials.lua b/src/script/Gen2Specials.lua index b554b340e..4c3cd65df 100644 --- a/src/script/Gen2Specials.lua +++ b/src/script/Gen2Specials.lua @@ -716,12 +716,16 @@ function Commands.g2_pokemon_center_pc(ctx) answer(ctx, 0) end --- PlayerPC is a Menu of its own, so it closes on its LOG OFF row and on B. --- Neither reaches this script, so there is nothing to wait on: push it and --- answer, exactly as g2_card_flip does. +-- The house script branches on the accumulated decoration-change bit only +-- after LOG OFF/B. Returning before the menu closes silently skips its reload. function Commands.g2_players_house_pc(ctx) - require("src.ui.Screens").push(ctx.game, "PlayerPC") - answer(ctx, 0) + local runner=ctx.runner + local done,changed=false,false + require("src.ui.Screens").push(ctx.game,"PlayerPC",{house=true,onDone=function(value) + if done then return end;done=true;changed=value==true;runner:resume() + end}) + runner:yield() + answer(ctx,changed and 1 or 0) end -- --------------------------------------------------------------------------- diff --git a/src/ui/Gen2DecorationMenu.lua b/src/ui/Gen2DecorationMenu.lua new file mode 100644 index 000000000..a091734cb --- /dev/null +++ b/src/ui/Gen2DecorationMenu.lua @@ -0,0 +1,106 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- The PC changes placed bytes. Room callbacks render them after native reload. +local Native=require('src.world.Gen2Decorations') +local Menu=require('src.ui.Menu') +local Runner=require('src.script.ScriptRunner') +local Strings=require('src.core.Strings') +local M={} +local categories={ + {'BED','bed'},{'CARPET','carpet'},{'PLANT','plant'},{'POSTER','poster'}, + {'GAME CONSOLE','console'},{'ORNAMENT','ornament'},{'BIG DOLL','bigDoll'}, +} +function M.new(game,opts) + opts=opts or{} + local changed,finished=false,false + local programs=Native.pool(game.data)and Native.pool(game.data).decorationPrograms + local texts=programs and programs.texts or{} + local function finish() + if finished then return end;finished=true + if opts.onDone then opts.onDone(changed)end + end + local function say(role,onDone,extra) + local id=texts[role] + if not id then if onDone then onDone()end;return end + local r=Runner.new(game,game.overworld) + r:run({{'show_text',id,nil,extra}}, {onDone=onDone}) + return r + end + local function perform(id,side) + local row=Native.attributes(game.data,id);local action=row and Native.action(row.action) + local state=Native.state(game.data,game.save) + if not(state and action)or not action.put and not Native.owns(game.data,game.save,id)then return end + local slot=action.slot or(side=='left'and 'leftOrnament'or side=='right'and 'rightOrnament') + if not slot then return end + local old=state[slot]or 0 + game.stringBuffers=game.stringBuffers or{} + game.stringBuffers[3]=Native.name(game.data,old) + game.stringBuffers[4]=Native.name(game.data,id) + local role + if action.put then role=old==0 and 'empty'or 'put' + elseif old==id then role='same' + elseif old==0 then role='setup';game.stringBuffers[3]=Native.name(game.data,id) + else role='replace'end + say(role,function()changed=Native.choose(game.data,game.save,id,side)or changed end) + end + local function select(id) + local row=Native.attributes(game.data,id);local action=row and Native.action(row.action) + if not action then return end + if not action.ornament then perform(id);return end + local selected,question + local function close(side) + selected=side + if question then game.stack:pop();question:resume() + elseif side then perform(id,side)end + end + local function sides() + game.stack:push(Menu.new(game,{ + {label=Strings('RIGHT SIDE'),onSelect=function()close('right')end}, + {label=Strings('LEFT SIDE'),onSelect=function()close('left')end}, + {label=Strings('CANCEL'),onSelect=function()close()end}, + },{tx=6,ty=6,tw=14,th=8,noSound=true,onCancel=function()close()end})) + end + local role=action.put and 'sidePut'or 'sideSetup' + if texts[role]then + question=say(role,function()if selected then perform(id,selected)end end,{stay={onShown=sides}}) + else sides()end + end + local function category(key) + local items,headers,seen={},{},{} + for raw,row in pairs(Native.pool(game.data).decorations)do + local id=tonumber(raw);local action=Native.action(row.action) + local group=action and(action.slot or 'ornament') + if id and id~=0 and group==key and not seen[id]then + seen[id]=true + if action.put then headers[#headers+1]=id + elseif Native.owns(game.data,game.save,id)then items[#items+1]=id end + end + end + table.sort(items);table.sort(headers) + return items,headers + end + local rows={} + if Native.ready(game.data)and Native.state(game.data,game.save)then + for _,c in ipairs(categories)do + local key=c[2];local ids=category(key) + if #ids>0 then + rows[#rows+1]={label=Strings(c[1]),keepOpen=true,onSelect=function() + local owned,headers=category(key) + if #owned==0 then say('noChoice');return end + local items={} + for _,id in ipairs(owned)do + local value=id;items[#items+1]={label=Native.name(game.data,value),onSelect=function()select(value)end} + end + for _,id in ipairs(headers)do + local value=id;items[#items+1]={label=Native.name(game.data,value),onSelect=function()select(value)end} + end + items[#items+1]={label=Native.name(game.data,0)~=''and Native.name(game.data,0)or Strings('CANCEL')} + game.stack:push(Menu.new(game,items,{tx=0,ty=0,tw=20,maxVisible=8,noSound=true})) + end} + end + end + end + rows[#rows+1]={label=Strings('EXIT'),onSelect=finish} + return Menu.new(game,rows,{tx=5,ty=0,tw=15,maxVisible=8,noSound=true,onCancel=finish}) +end +return M diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index 8203e0800..704b885a1 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -169,19 +169,31 @@ PlayerPC.withdraw = withdraw PlayerPC.deposit = deposit PlayerPC.toss = toss -function PlayerPC.new(game) +function PlayerPC.new(game,opts) + opts=opts or{} + local changed,done=false,false + local function finish() + if done then return end;done=true + if opts.onDone then opts.onDone(changed)end + end game.save.pcItems = game.save.pcItems or {} - return Menu.new(game, { + local items={ -- keepOpen so B in the item lists returns here instead of dropping the -- whole PC session (players_pc.asm re-shows the PC menu); same pattern -- as BoxMenu's rows { label = Strings("WITHDRAW ITEM"), keepOpen = true, onSelect = function() withdraw(game) end }, { label = Strings("DEPOSIT ITEM"), keepOpen = true, onSelect = function() deposit(game) end }, { label = Strings("TOSS ITEM"), keepOpen = true, onSelect = function() toss(game) end }, - { label = Strings("LOG OFF") }, + { label = Strings("LOG OFF"),onSelect=finish }, -- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm -- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10 - }, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true }) + } + if opts.house and require("src.world.Gen2Decorations").ready(game.data)then + table.insert(items,#items,{label=Strings("DECORATION"),keepOpen=true,onSelect=function() + require("src.ui.Screens").push(game,"Gen2DecorationMenu",{onDone=function(value)changed=value or changed end}) + end}) + end + return Menu.new(game,items,{tx=0,ty=0,tw=16,maxVisible=8,noSound=true,onCancel=finish}) end return PlayerPC diff --git a/src/world/Gen2Decorations.lua b/src/world/Gen2Decorations.lua new file mode 100644 index 000000000..d7eab2a3b --- /dev/null +++ b/src/world/Gen2Decorations.lua @@ -0,0 +1,133 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Native ownership flags, placed bytes and callback presentation are distinct. +-- Attribute/name rows come from the current cartridge, never a retail table. +local V=require('src.core.GameVersion') +local Strings=require('src.core.Strings') +local M={} +M.slots={'bed','carpet','plant','poster','console','bigDoll','leftOrnament','rightOrnament'} +local kinds={'BED','CARPET','PLANT','POSTER','CONSOLE','BIG_DOLL','DOLL'} +local slotFor={BED='bed',CARPET='carpet',PLANT='plant',POSTER='poster',CONSOLE='console',BIG_DOLL='bigDoll'} +local actions={} +for _,kind in ipairs(kinds)do + actions[#actions+1]='SET_UP_'..kind;actions[#actions+1]='PUT_AWAY_'..kind +end +function M.actionName(n)return actions[n]end +function M.action(name) + if type(name)~='string'then return nil end + local kind=name:match('^SET_UP_(.+)$');local put=false + if not kind then kind=name:match('^PUT_AWAY_(.+)$');put=true end + if kind=='DOLL'then return{ornament=true,put=put}end + return slotFor[kind] and {slot=slotFor[kind],put=put}or nil +end +function M.pool(data)return data and data.map_scripts end +function M.ready(data) + local p=M.pool(data);local info=p and p.decorationInfo + return V.generation(V.get())==2 and type(p)=='table' and type(p.decorations)=='table' and + type(info)=='table' and info.complete==true or false +end +function M.attributes(data,id) + if not M.ready(data)then return nil end + local rows=M.pool(data).decorations + return rows[tonumber(id)]or rows[tostring(id)] +end +function M.state(data,save) + if not M.ready(data)or type(save)~='table'then return nil end + if save.decorations==nil then + local info=M.pool(data).decorationInfo + if not info.defaultsComplete then return nil end + save.decorations=require('src.mods.Merge').deepCopy(info.defaults or{}) + end + return type(save.decorations)=='table'and save.decorations or nil +end +local names={ + [2]=Strings.source('%s BED'),[3]=Strings.source('%s CARPET'), + [4]=Strings.source('%s POSTER'),[5]=Strings.source('%s DOLL'),[6]=Strings.source('BIG %s'), +} +function M.name(data,id) + local row=M.attributes(data,id) + if not row then return ''end + local name=row.name + if row.type>=4 then + local info=M.pool(data).decorationInfo + local key=info.sourceNames and info.sourceNames[id]==name and info.species and info.species[id]or name + local mon=data.pokemon and data.pokemon[key] + name=mon and mon.name or name + end + return names[row.type] and Strings(names[row.type],name)or name +end +function M.owns(data,save,id) + local row=M.attributes(data,id);local action=row and M.action(row.action) + return type(save)=='table' and type(save.flags)=='table' and id~=0 and action and not action.put and require('src.script.Flags').get(save, + require('src.script.Gen2Flags').eventFlag(row.flag))or false +end +function M.give(data,save,id) + local row=M.attributes(data,id) + if type(save)~='table' or id==0 or not row or not M.action(row.action)or M.action(row.action).put then return false end + save.flags=save.flags or{} + require('src.script.Flags').set(save,require('src.script.Gen2Flags').eventFlag(row.flag));return true +end +function M.giveFlag(data,save,n) + local info=M.pool(data)and M.pool(data).decorationInfo + local id=info and info.gifts and info.gifts[(tonumber(n)or -1)+1] + return id~=nil and M.give(data,save,id)or false +end +function M.choose(data,save,id,side) + local state=M.state(data,save);local row=M.attributes(data,id) + local action=row and M.action(row.action) + if id==0 or not(state and action)then return false end + if not action.put and not M.owns(data,save,id)then return false end + local slot=action.slot + if action.ornament then + if side~='left'and side~='right'then return false end + slot=side=='left'and 'leftOrnament'or 'rightOrnament' + end + local value=action.put and 0 or id + local changed=(state[slot]or 0)~=value + state[slot]=value + if changed and action.ornament and not action.put then + local other=side=='left'and 'rightOrnament'or 'leftOrnament' + if state[other]==id then state[other]=0 end + end + return changed +end +-- These callbacks deliberately preserve the VM's current script/check vars. +function M.visibility(ctx) + local data=ctx.game.data;local state=M.state(data,ctx.save) + if not state then return end + ctx.save.flags=ctx.save.flags or{} + local C=require('src.script.Commands');local Flags=require('src.script.Gen2Flags') + for _,binding in ipairs({{'console',0,1857},{'leftOrnament',1,1858},{'rightOrnament',2,1859},{'bigDoll',3,1860}})do + local id=state[binding[1]]or 0;local row=M.attributes(data,id) + if id~=0 and row then + C.g2_variablesprite(ctx,binding[2],row.sprite);C.clear_flag(ctx,Flags.eventFlag(binding[3])) + else C.set_flag(ctx,Flags.eventFlag(binding[3]))end + end +end +function M.tiles(ctx) + local data=ctx.game.data;local state=M.state(data,ctx.save) + if not state then return end + ctx.save.flags=ctx.save.flags or{} + local C=require('src.script.Commands') + local function paint(slot,x,y) + local id=state[slot]or 0;local row=id~=0 and M.attributes(data,id) + if row then C.replace_block(ctx,x,y,row.sprite);return row.sprite end + end + paint('bed',0,2);paint('plant',3,2);paint('poster',3,0) + local carpet=paint('carpet',0,0) + if carpet then + C.replace_block(ctx,0,1,(carpet+1)%256);C.replace_block(ctx,1,1,(carpet+2)%256) + C.replace_block(ctx,2,1,(carpet+1)%256) + end + local flag=require('src.script.Gen2Flags').eventFlag(716) + if (state.poster or 0)~=0 then C.set_flag(ctx,flag)else C.clear_flag(ctx,flag)end +end +function M.description(data,save,kind) + local state=M.state(data,save);local programs=M.pool(data)and M.pool(data).decorationPrograms + if not state or type(programs)~='table'then return nil end + if kind==0 then return programs.posters and programs.posters[state.poster or 0]or programs.null end + if kind==3 then return programs.big end + local slot=({[1]='leftOrnament',[2]='rightOrnament',[4]='console'})[kind] + if slot then return programs.ornament,M.name(data,state[slot]or 0)end +end +return M diff --git a/tests/engine/gen2_decoration_content.lua b/tests/engine/gen2_decoration_content.lua new file mode 100644 index 000000000..2b5162754 --- /dev/null +++ b/tests/engine/gen2_decoration_content.lua @@ -0,0 +1,194 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local old=V.get() +local Native=require('src.world.Gen2Decorations');local E=require('src.import.RomExtractorGen2') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner') +local Commands=require('src.script.Commands');local Flags=require('src.script.Gen2Flags') +local Stack=require('src.core.StateStack');local TextBox=require('src.render.TextBox') +local Merge=require('src.mods.Merge') +local function fixture() + local d=T.fixtures.fresh() + d.map_scripts={decorations={ + [0]={type=1,name='CANCEL',flag=0,sprite=0}, + [1]={type=1,name='STORE BED',action='PUT_AWAY_BED',flag=0,sprite=0}, + [2]={type=2,name='SYNTHETIC',action='SET_UP_BED',flag=500,sprite=0}, + [3]={type=2,name='OTHER',action='SET_UP_BED',flag=501,sprite=44}, + [4]={type=1,name='STORE DOLL',action='PUT_AWAY_DOLL',flag=0,sprite=0}, + [5]={type=5,name='MODEL',action='SET_UP_DOLL',flag=502,sprite=18}, + [6]={type=1,name='CONSOLE',action='SET_UP_CONSOLE',flag=503,sprite=20}, + [7]={type=1,name='MAP',action='SET_UP_POSTER',flag=504,sprite=22}, + [8]={type=1,name='RUG',action='SET_UP_CARPET',flag=505,sprite=255}, + [9]={type=1,name='PLANT',action='SET_UP_PLANT',flag=506,sprite=24}, + [10]={type=6,name='MODEL',action='SET_UP_BIG_DOLL',flag=507,sprite=26}, + },decorationInfo={complete=true,defaultsComplete=true,defaults={bed=2,poster=7}, + giftsComplete=true,gifts={2,3,5},sourceNames={[5]='MODEL'},species={[5]='SPECIES_001'}}, + decorationPrograms={posters={[7]='POSTER'},null='NULL',ornament='ORNAMENT',big='BIG', + texts={put='PUT',empty='EMPTY',setup='SET',replace='REPLACE',same='SAME',sidePut='SIDE_PUT',sideSetup='SIDE_SET'}}, + scripts={CALLBACK={{'special',0,'ToggleDecorationsVisibility'},{'special',0,'ToggleMaptileDecorations'},{'endcallback'}}, + DESC={{'describedecoration',1}},POSTER={{'setval',70},{'end'}},NULL={{'setval',71},{'end'}}, + ORNAMENT={{'setval',72},{'end'}},BIG={{'setval',73},{'end'}}, + PC={{'special',0,'PlayersHousePC'},{'iftrue','RELOAD'},{'end'}},RELOAD={{'warp','NONE',0,0,0},{'end'}}}} + d.text={PUT='{RAM:wStringBuffer3}',SET='{RAM:wStringBuffer3}',REPLACE='{RAM:wStringBuffer3}/{RAM:wStringBuffer4}', + SAME='SYNTHETIC SAME',EMPTY='SYNTHETIC EMPTY',SIDE_SET='SYNTHETIC SIDE SET',SIDE_PUT='SYNTHETIC SIDE PUT'} + d.pokemon.SPECIES_001={name='RENAMED'} + return d +end +local function load(d,code,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local files,paths={},{} + if code then + files['mods/deco/manifest.json']=Json.encode({id='deco',name='Synthetic decorations',version='1.0.0', + api=mode and mode[1]or 2,content_format=mode and mode[2]or 'native',entry='main.lua',affects_link=false}) + files['mods/deco/main.lua']='local mod=...;mod.exports.api=mod;'..code;paths={'mods/deco'} + end + return T.sdk.loadMods(paths,{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +local function game(d) + local stack=setmetatable({},{__index=Stack});stack:init();stack:push({name='map'}) + local g={data=d,save={flags={},inventory={}},stack=stack,input={wasPressed=function()return false end}} + g.overworld={map={id='ROOM'},player={cellX=4,cellY=5,facing='left'},paint={},reloaded=0} + function g.overworld:replaceBlock(x,y,n)self.paint[x..','..y]=n end + function g.overworld:reloadMap(id)assert(id=='ROOM');self.reloaded=self.reloaded+1;Native.visibility({game=g,save=g.save});Native.tiles({game=g,save=g.save,overworld=self})end + return g +end +local function run(g,label) + local r=Runner.new(g,g.overworld);r:run(assert(VM.compile(g.data,label)));return r +end +local function choose(g,label) + local menu=g.stack:top() + for i,row in ipairs(menu.items)do if row.label==label then menu.index=i;g.input={wasPressed=function(_,key)return key=='a'end};g.stack:update(0) + g.input={wasPressed=function()return false end};return end end + error('missing menu choice '..label) +end +local function dismiss(g) + local box=g.stack:top();assert(getmetatable(box)==TextBox);box.done=true;g.input={wasPressed=function(_,key)return key=='a'end};g.stack:update(0);g.input={wasPressed=function()return false end} +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture();local before=d.map_scripts.decorations + local r=load(d,[[ + local p=mod.content["decorations"] + assert(p:supports()) + local copy=p:get('deco:2');copy.sprite=199;assert(p:get('deco:2').sprite==0) + p:patch('deco:2',{sprite=88}) + p:register('deco:200',{type=1,name='ADDED',flag=900,action='SET_UP_PLANT',sprite=77}) + ]],mode) + local label=version..' API '..mode[1]..' '..mode[2] + T.eq(#r.errors,0,label..' actual Loader accepts native DECO registry') + T.eq(d.map_scripts.decorations[2].sprite,88,label..' patch reaches actual native table') + T.eq(d.map_scripts.decorations[200].flag,900,label..' new byte reaches actual native table') + local g=game(d);T.eq(Native.state(d,g.save).bed,2,label..' initializes cartridge default once') + T.check(Native.give(d,g.save,200),label..' give owns authored record through actual event') + T.eq(g.save.flags[Flags.eventFlag(900)],true,label..' ownership is the authored flag') + T.check(Native.choose(d,g.save,200),label..' owned authored placement succeeds') + run(g,'CALLBACK') + T.eq(g.overworld.paint['3,2'],77,label..' actual VM callback paints authored block') + T.eq(g.overworld.paint['0,2'],88,label..' actual VM callback paints patched bed') + T.eq(g.save.flags[Flags.eventFlag(1857)],true,label..' empty console object is hidden') + T.eq(g.save.flags[Flags.eventFlag(716)],true,label..' placed poster enables native event') + finish(r) + local clean=load(d) + T.eq(d.map_scripts.decorations,before,label..' disabling restores pristine table identity') + T.eq(d.map_scripts.decorations[200],nil,label..' disabling removes owned addition') + finish(clean) + end + local d=fixture();local g=game(d) + T.eq(Native.choose(d,g.save,3),false,version..' unowned placement fails') + T.eq(Native.give(d,g.save,0),false,version..' CANCEL cannot be awarded') + T.eq(Native.give(d,g.save,1),false,version..' PUT header cannot be awarded') + T.check(Native.giveFlag(d,g.save,1),version..' actual gift ordinal awards real DECO byte') + T.eq(Native.owns(d,g.save,3),true,version..' gift ownership reaches event bit') + T.eq(Native.name(d,5),'RENAMED DOLL',version..' imported species identity follows actual name') + d.map_scripts.decorations[5].name='AUTHORED';T.eq(Native.name(d,5),'AUTHORED DOLL',version..' authored name wins old imported source') + for id=2,10 do Native.give(d,g.save,id)end + local state=Native.state(d,g.save) + state.leftOrnament=5;state.rightOrnament=5 + T.eq(Native.choose(d,g.save,5,'left'),false,version..' already placed doll is not a change') + T.eq(state.rightOrnament,5,version..' already placed doll does not mutate opposite side') + state.leftOrnament=0 + T.eq(Native.choose(d,g.save,5,'left'),true,version..' owned doll moves sides') + T.eq(state.rightOrnament,0,version..' real move clears opposite duplicate') + Native.choose(d,g.save,6);Native.choose(d,g.save,8);Native.choose(d,g.save,10) + local callback=run(g,'CALLBACK') + T.eq(g.save.gen2VarSprites[0],20,version..' console writes actual native variable sprite') + T.eq(g.save.gen2VarSprites[1],18,version..' left doll writes actual native variable sprite') + T.eq(g.save.gen2VarSprites[3],26,version..' big doll writes actual native variable sprite') + T.eq(g.overworld.paint['0,2'],0,version..' zero is valid block for nonzero placed DECO') + T.eq(g.overworld.paint['0,1'],0,version..' carpet offsets wrap at byte boundary') + T.eq(g.overworld.paint['1,1'],1,version..' carpet second offset wraps at byte boundary') + local desc=run(g,'DESC') + T.eq(desc.ctx.g2Var,72,version..' native describedecoration jumps to imported ornament script') + T.eq(g.stringBuffers[3],'AUTHORED DOLL',version..' native description fills real buffer three') + -- Real menus, native VM, TextBox and StateStack; only render/audio are harness IO. + g=game(fixture());Native.give(g.data,g.save,3);Native.give(g.data,g.save,5) + local pc=run(g,'PC') + T.check(pc:isRunning(),version..' native house-PC script waits for menu closure') + T.eq(pc.ctx.g2Var,nil,version..' native result is not guessed before selection') + choose(g,'DECORATION');choose(g,'BED');choose(g,'OTHER BED') + T.eq(Native.state(g.data,g.save).bed,2,version..' selection waits for original feedback box') + T.eq(g.stringBuffers[3],'SYNTHETIC BED',version..' replacement feedback buffers old name') + T.eq(g.stringBuffers[4],'OTHER BED',version..' replacement feedback buffers new name') + dismiss(g) + T.eq(g.save.decorations.bed,3,version..' acknowledging feedback commits native placed byte') + T.eq(g.overworld.reloaded,0,version..' furniture remains unchanged before PC exit') + choose(g,'ORNAMENT');choose(g,'RENAMED DOLL') + local prompt=g.stack:top();prompt.done=true;prompt:update(0) + choose(g,'RIGHT SIDE');dismiss(g) + T.eq(g.save.decorations.rightOrnament,5,version..' actual native side menu commits right doll') + choose(g,'EXIT') + T.check(pc:isRunning(),version..' leaving decoration submenu does not resume PC script') + choose(g,'LOG OFF') + T.eq(pc:isRunning(),false,version..' LOG OFF completes actual native PC script') + T.eq(pc.ctx.g2Var,1,version..' changed bit returns after menu closes') + T.eq(g.overworld.reloaded,1,version..' native NONE warp reloads exactly once') + T.eq(g.overworld.player.cellX,4,version..' NONE reload retains current cell rather than zero coordinates') + T.eq(g.overworld.paint['0,2'],44,version..' reload callback uses final selected bed') + T.eq(g.save.gen2VarSprites[2],18,version..' reload callback uses selected right doll') + g.data.map_scripts.scripts.FACE={{'warpfacing',3,'NONE',0,0,0},{'end'}} + g.overworld.player.facing='down';run(g,'FACE') + T.eq(g.overworld.player.facing,'right',version..' native NONE warpfacing applies its explicit direction') + g=game(fixture());pc=run(g,'PC');g.input={wasPressed=function(_,key)return key=='b'end};g.stack:update(0) + T.eq(pc.ctx.g2Var,0,version..' PC cancellation returns unchanged') + T.eq(g.overworld.reloaded,0,version..' PC cancellation cannot reload') +end +V.set('crystal') +for _,code in ipairs({ + "p:patch('deco:2',{sprite=256})", "p:patch('deco:2',{flag=65535})", "p:patch('deco:2',{type=0})", + "p:patch('deco:2',{action='GUESS'})", "p:patch('deco:2',{sprite=0/0})", "p:patch('deco:2',{flag=math.huge})", + "p:patch('deco:02',{sprite=3})", "p:patch('deco:256',{sprite=3})", "p:patch('deco:2',{unknown=true})", + "p:patch('deco:2',{sprite=44});error('entry failed')", +})do + local d=fixture();local r=load(d,'local p=mod.content["decorations"];'..code) + T.check(#r.errors>0,'invalid or failed decoration contribution is rejected') + T.eq(d.map_scripts.decorations[2].sprite,0,'failed entry rolls back all native decoration writes') + finish(r) +end +do + local d=fixture();local original=d.map_scripts.decorations + original['2']=Merge.deepCopy(original[2]) + local r=load(d,[[local p=mod.content["decorations"];p:remove('deco:2');p:patch('deco:3',{sprite=66}); + p:register('deco:200',{type=1,name='EXTERNAL',flag=901,action='SET_UP_PLANT',sprite=77})]]) + T.eq(d.map_scripts.decorations[2],nil,'remove clears numeric DECO alias') + T.eq(d.map_scripts.decorations['2'],nil,'remove clears string DECO alias') + d.map_scripts.decorations[200].sprite=99 + finish(r);local clean=load(d) + T.eq(d.map_scripts.decorations[2].sprite,0,'reload restores removed native numeric record') + T.eq(d.map_scripts.decorations['2'].sprite,0,'reload restores removed native string alias') + T.eq(d.map_scripts.decorations[3].sprite,44,'reload retires an unchanged owned patch') + T.eq(d.map_scripts.decorations[200].sprite,99,'reload preserves externally edited authored record independently') + finish(clean) +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + V.set(version);local d=fixture();if version=='prism'or version=='polishedcrystal'then d.map_scripts.decorationInfo.complete=false end + local before=d.map_scripts.decorations + local r=load(d,[[local p=mod.content["decorations"];assert(not p:supports());local v,why=p:patch('deco:2',{sprite=99});assert(v==nil and why)]]) + T.eq(#r.errors,0,version..' unsupported native decoration target declines explicitly') + T.eq(d.map_scripts.decorations,before,version..' unsupported generation does not write native namespace') + finish(r) +end +V.set(old);T.finish('native Gen2 decoration content, room callbacks and house PC') diff --git a/tests/engine/gen2_decoration_import.lua b/tests/engine/gen2_decoration_import.lua new file mode 100644 index 000000000..fe2409fb1 --- /dev/null +++ b/tests/engine/gen2_decoration_import.lua @@ -0,0 +1,113 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local old=V.get() +local E=require('src.import.RomExtractorGen2');local Native=require('src.world.Gen2Decorations') +local S=require('src.save_convert.Gen2Save');local Json=require('src.link.Json') +local function fixture(version,edit) + local raw=string.rep('\0',0x8000) + local function put(at,bytes)raw=raw:sub(1,at)..bytes..raw:sub(at+#bytes+1)end + local symbols={DecorationAttributes={1,0x4000},DecorationNames={1,0x401e}, + PokemonNames={1,0x4300},InitDecorations={1,0x4100},DecorationIDs={1,0x4200}, + wDecoBed={1,0xd000},wDecoPoster={1,0xd003},DecorationDesc_PosterPointers={1,0x4400}, + ['DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript']={1,0x4500}, + ['DecorationDesc_GiantOrnament.BigDollScript']={1,0x4501},DecorationDesc_NullPoster={1,0x4502}, + PutAwayTheDecoText={1,0x4600},NothingToPutAwayText={1,0x4602},SetUpTheDecoText={1,0x4604}} + -- Authored test cartridge, not a retail attribute catalogue. + put(0x4000,string.char(1,0,0,0,0,0,1,1,2,0,0,0,2,2,1,0x34,0x12,0, + 1,1,8,0,0,0,4,1,7,0x35,0x12,77)) + put(0x401e,string.char(128,0x50,129,0x50,130,0x50)) + put(0x4100,string.char(0x3e,2,0xea,0,0xd0,0x3e,4,0xea,3,0xd0,0xc9)) + put(0x4200,string.char(2,4,255));put(0x4300,string.char(131,0x50)) + put(0x4400,string.char(4,3,0x45,255));put(0x4500,string.char(0x91,0x91,0x91,0x91)) + if edit then edit(symbols,put)end + local e=E.new(raw,version,{symbols=symbols}) + function e:readSourceTable(k)assert(k=='charmap');return{['128']='X',['129']='Y',['130']='Z',['131']='Q'}end + return e +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local e=fixture(version);local rows,info=e:gen2Decorations() + T.check(info.complete,version..' native attribute boundary defines valid table') + T.eq(info.rowsRead,5,version..' reader imports exact authored row count') + T.eq(rows[0].name,'X',version..' DECO zero remains in actual native table') + T.eq(rows[2].name,'Z',version..' names use actual character decoder') + T.eq(rows[2].flag,0x1234,version..' event identity is native little-endian word') + T.eq(rows[2].sprite,0,version..' block zero survives real import') + T.eq(rows[4].name,'Q',version..' species name comes from cartridge indexed table') + T.eq(info.species[4],'SPECIES_001',version..' raw species identity has no National Dex inference') + T.eq(info.defaults.bed,2,version..' bed default comes from native init operands') + T.eq(info.defaults.poster,4,version..' poster default comes from native init operands') + T.eq(#info.gifts,2,version..' gift ordinal table is ROM-derived') + T.eq(info.gifts[2],4,version..' gift ordinal retains actual DECO byte') + local q={scripts={},queue={},movements={},instructions=0,desyncs=0,failures=0} + local roles=e:gen2QueueDecorationPrograms(q) + T.eq(#q.queue,4,version..' actual queue includes three description roles and poster') + T.check(roles.texts.setup:match('^TEXT_S'),version..' native feedback enters shared text import') + T.check(e._gen2ScriptTexts[roles.texts.setup]~=nil,version..' feedback text is registered for actual extractor') + e:gen2QueueDecorationPrograms(q);T.eq(#q.queue,4,version..' duplicate queue calls are idempotent') + local n=1;while n<=#q.queue do local job=q.queue[n];e:gen2DecodeScript(job[1],job[2],job[3],q);n=n+1 end + T.eq(q.desyncs,0,version..' actual bytecode decoder accepts queued description programs') + for _,edit in ipairs({ + function(s)s.DecorationNames[2]=0x401f end, + function(s)s.DecorationNames[1]=2 end, + function(s)s.DecorationAttributes=nil end, + function(s,put)put(0x4000,string.char(0))end, + function(s,put)put(0x4002,string.char(15))end, + function(s,put)put(0x4003,string.char(255,255))end, + function(s,put)put(0x401e,string.rep(string.char(128),129))end, + function(s)s.PokemonNames=nil end, + })do + local bad,why=fixture(version,edit):gen2Decorations() + T.eq(why.complete,false,version..' malformed attribute/name input cannot become ready') + T.eq(next(bad),nil,version..' malformed attribute/name input publishes no prefix') + end + local _,missing=fixture(version,function(s)s.InitDecorations=nil end):gen2Decorations() + T.eq(missing.complete,true,version..' missing init does not erase valid attribute table') + T.eq(missing.defaultsComplete,false,version..' missing init cannot invent retail defaults') + local _,wrong=fixture(version,function(s)s.wDecoBed[2]=0xd100 end):gen2Decorations() + T.eq(wrong.defaultsComplete,false,version..' init stores must target actual WRAM symbols') + local _,badGifts=fixture(version,function(_,put)put(0x4200,string.rep(string.char(2),256))end):gen2Decorations() + T.eq(badGifts.giftsComplete,false,version..' unterminated gifts cannot be claimed complete') + T.eq(badGifts.gifts,nil,version..' malformed gifts publish no prefix') + q={scripts={},queue={}} + T.eq(fixture(version,function(s)s.DecorationDesc_NullPoster=nil end):gen2QueueDecorationPrograms(q),nil,version..' missing description role declines whole binding') + T.eq(#q.queue,0,version..' missing role is rejected before queue mutation') + q={scripts={},queue={}} + T.eq(fixture(version,function(_,put)put(0x4400,string.char(4,0,0))end):gen2QueueDecorationPrograms(q),nil,version..' invalid poster pointer declines binding') + T.eq(#q.queue,0,version..' invalid poster table leaves queue untouched') + -- Resolve the precise native NONE pair, without accepting an unknown map. + e=fixture(version);function e:gen2MapIndex()return{}end;function e:gen2Trainers()return{}end + local p={scripts={TEST={{'warp',0,0,0,0},{'warpfacing',2,0,0,0,0},{'warp',0,1,0,0},{'blackoutmod',0,0}}}} + e:gen2ResolveScriptMapIds({}, {},p) + T.eq(p.scripts.TEST[1][2],'NONE',version..' only actual NONE warp becomes reload') + T.eq(p.scripts.TEST[2][3],'NONE',version..' warpfacing uses its own map-pair slot') + T.eq(p.scripts.TEST[3][2],0,version..' unknown map is not turned into a reload') + T.eq(p.scripts.TEST[4][2],0,version..' non-warp zero pair is not reinterpreted') + -- Independent manifest WRAM addresses verify the save codec's physical order. + local f=assert(io.open('tools/rom_manifest_'..version..'.json','rb'));local manifest=assert(Json.decode(f:read('*a')));f:close() + S.setLayout(version);S.setCharmap({['128']='A'}) + local d=T.fixtures.fresh();local save={player={name='A'},decorations={bed=1,carpet=2,plant=3,poster=4, + console=5,leftOrnament=6,rightOrnament=7,bigDoll=255}} + local raw=S.encode(save,d) + local anchor=version=='crystal'and 0xd47b or 0xd1a1 + for _,key in ipairs(Native.slots)do + local symbol='wDeco'..key:sub(1,1):upper()..key:sub(2) + local address=manifest.symbols[symbol][2];local offset=0x2009+address-anchor + T.eq(raw:byte(offset+1),save.decorations[key],version..' export matches independent '..symbol..' address') + end + local decoded=S.decode(raw,d) + for _,key in ipairs(Native.slots)do T.eq(decoded.decorations[key],save.decorations[key],version..' import preserves exact '..key..' byte')end + local round=S.encode(decoded,d,raw);local warnings=S.decode(round,d).warnings + local checksumOk=true;for _,w in ipairs(warnings)do if w:match('checksum')then checksumOk=false end end + T.check(checksumOk,version..' decoration export retains valid native checksum') + local saved=decoded.decorations;decoded.decorations=nil + local untouched=S.encode(decoded,d,raw) + for i=0,7 do T.eq(untouched:byte(S.OFFSETS.decorations+i+1),raw:byte(S.OFFSETS.decorations+i+1),version..' absent legacy state preserves imported decoration template')end + decoded.decorations=saved;decoded.decorations.bed=256 + T.check(not pcall(S.encode,decoded,d,raw),version..' invalid placed byte cannot wrap and corrupt native export') +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + V.set(version);T.eq(fixture(version):gen2Decorations(),nil,version..' unreviewed layout imports no stock catalogue') + S.setLayout(version);T.eq(S.OFFSETS.decorations,nil,version..' unknown save layout has no guessed decoration offset') +end +V.set(old);S.setLayout(old);T.finish('native Gen2 decoration ROM producers and eight-byte SRAM codec') From e49446d9b9beba591e4210302fa9e9a9eb1132d5 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:22:40 +0100 Subject: [PATCH 47/67] Complete native bedroom acquisition through Mom savings and original shopping scripts --- docs/native-gen2-decorations.md | 3 +- docs/native-gen2-mom-shopping.md | 87 +++++++++++++ src/battle/BattleState.lua | 34 +++-- src/import/RomExtractorGen2.lua | 50 +++++++ src/import/RomImporter.lua | 2 +- src/save_convert/Gen2Save.lua | 39 ++++++ src/script/Gen2Commands.lua | 10 +- src/world/Gen2MomShopping.lua | 139 ++++++++++++++++++++ src/world/Gen2PhoneService.lua | 11 ++ src/world/OverworldController.lua | 41 ++++++ tests/engine/gen2_mom_import.lua | 98 ++++++++++++++ tests/engine/gen2_mom_shopping.lua | 201 +++++++++++++++++++++++++++++ 12 files changed, 691 insertions(+), 24 deletions(-) create mode 100644 docs/native-gen2-mom-shopping.md create mode 100644 src/world/Gen2MomShopping.lua create mode 100644 src/world/Gen2PhoneService.lua create mode 100644 tests/engine/gen2_mom_import.lua create mode 100644 tests/engine/gen2_mom_shopping.lua diff --git a/docs/native-gen2-decorations.md b/docs/native-gen2-decorations.md index 22c63ead3..e9d31d63e 100644 --- a/docs/native-gen2-decorations.md +++ b/docs/native-gen2-decorations.md @@ -72,7 +72,8 @@ executes the original house-PC/description scripts and room callbacks, and round-trips all eight placed bytes. Original ROM hashes are checked unchanged. The local font/audio/render harness is used; no visual acceptance is inferred. -Full legacy `Decorations` facade parity, the complete gift/purchase lifecycle, +Mom’s native shopping caller is covered separately in [native-gen2-mom-shopping.md](native-gen2-mom-shopping.md). +Full legacy `Decorations` facade parity, the remaining gift lifecycle, new graphical acceptance and a full unchanged VASC playthrough remain separate open work. Importing the gift-ordinal table alone does not implement those callers. The existing preview is not refreshed by this source change. diff --git a/docs/native-gen2-mom-shopping.md b/docs/native-gen2-mom-shopping.md new file mode 100644 index 000000000..9a3277427 --- /dev/null +++ b/docs/native-gen2-mom-shopping.md @@ -0,0 +1,87 @@ +# Native GSC Mom accounts and purchases + +The existing native banking screen changed a separate saving boolean, while +trainer rewards went entirely to the wallet. The cartridge's shopping tables +and notification scripts were not imported. This connects the banking choice, +native flags, trainer reward accounts, ROM purchase rows, original phone +programs, decoration ownership and raw-save state. + +## Cartridge data and account state + +`map_scripts.momShopping` contains the user's cartridge's random and ladder +lists. Each row is eight bytes: big-endian three-byte trigger and price, then +kind and item/DECO bytes. End symbols determine the count. The random threshold +increment is read from the native instruction operands that populate +`hMoneyTemp`; no retail catalogue, purchase dialogue or guessed hack layout is +bundled. Both original item/doll phone programs enter the shared script/text +importer. Invalid or incomplete bindings publish no partial lists and do not +partially enqueue programs. The new GSC cache marker requires reimport. + +The native account fields are `g2MomMoney`, `g2MomSavingMode` and +`g2MomBankOpen`, alongside the existing boolean `g2MomSaving`. Saving bit zero +and the active bit agree with the same engine flags original scripts read. +The bank UI uses those shared fields; an imported active account is recognized +instead of silently opening a new account. Explicit start/stop choices select +SOME/none. Imported HALF/ALL settings retain their native low-bit meanings. + +Trainer rewards deal four quarters between the accounts. SOME sends one, +HALF two and ALL four to Mom; an already maxed Mom account sends everything to +the wallet. Each account saturates independently, so near-cap overflow is not +redirected. Held-item reward modification happens before this split. Original +reward-text entry points select the current mode and receive the real player +name and full prize total. Other generations and unreviewed hacks retain their +existing reward paths, including Prism's separate bank. + +## Purchase and phone lifecycle + +`g2MomShopping` retains the native ladder index, selected random-row byte and +three-byte trigger balance. The current ladder row has priority whenever its +trigger is met. Otherwise the threshold advances to/past the balance and only +an exact landing selects a random row; that threshold is consumed even when a +full PC refuses delivery. A random purchase does not advance the ladder. + +Items resolve by the current dataset's native index and enter the existing PC +store with the native stack/capacity rule. Dolls set the actual imported DECO +ownership event; they do not place themselves in the room. Successful delivery +precedes deduction and ladder advancement. No reception or an occupied native +reentry queue prevents a purchase before selection state changes. Missing +original phone programs do not grant an unannounced purchase. + +The common native trainer-return path attempts the purchase once. Its original +phone program waits behind the trainer's queued post-battle script and UI +overlays, then enters the existing native FIFO with Mom's current contact ID. +Caller state, ring/hang-up audio, and receive-delay reset use the existing native call path. +Where the host exposes an incoming-call notification seam, it is also used. Repeated battle +callbacks cannot duplicate the purchase, and a pending call from another +save/dataset cannot execute in the current session. Link and Battle Tower +returns do not trigger shopping. + +Raw SRAM conversion uses each GSC edition's actual WRAM-derived account, +mode, ladder, row-selection and threshold offsets. The account/threshold use +big-endian triples; surrounding mode bits and absent legacy template values +are retained. Unsupported layouts receive no guessed new offsets. + +## Verification and limits + +ROM-free tests exercise actual table/instruction readers, banking VM/UI, +trainer battle win, native world return, script FIFO, flags, capacity refusal, +threshold selection and SRAM conversion. Save addresses are independently +compared with the shipped GSC manifests. Synthetic cartridges, texts and +records are authored fixtures rather than retail data. + +A private read-only Crystal probe independently compares every numeric byte +of both shopping lists, executes all ten original ladder phone notifications, +checks actual item and DECO ownership delivery, resolves native reward slots +and round-trips the account/progress fields. It uses controlled dataset and +font/render/audio IO, not a GPU acceptance or full natural-story playthrough. +Original ROM hashes are checked unchanged. No ROM/private mod/save data is +included in the contribution. + +The legacy `src.core.gen2.MomShopping` facade, full legacy phone parity, +new GPU acceptance and a complete unchanged VASC playthrough remain open. +This source change does not refresh public preview assets. + +Contract references: [native purchase routine](https://github.com/pret/pokecrystal/blob/master/engine/events/mom_phone.asm), +[native trainer reward split](https://github.com/pret/pokecrystal/blob/master/engine/battle/core.asm). +Original additions use the [Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md); +existing upstream and third-party rights remain unchanged. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 297e84c74..ecf65b90d 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -9093,20 +9093,8 @@ function BattleState:enemyMonFainted() end) return end - -- ComputeTrainerReward (0E:58D4) sets wBattleReward = the class's base - -- reward byte (TrainerClassAttributes+2) times wCurPartyLevel, the level - -- of the LAST mon the party reader loaded. That is only a QUARTER of the - -- payout though: WinTrainerBattle (0F:4F46) then runs `ld c, 4` and adds - -- wBattleReward once per iteration -- b of the four quarters through - -- .AddMoneyToMom, the remaining c through .AddMoneyToWallet -- so the - -- trainer is worth base * level * 4 in total. b comes from - -- wMomSavingMoney & 7 (0 = you keep it all, 3 = she banks all four), and - -- is forced to 0 when Mom's account is already maxed. Nothing here - -- models Mom's savings account, which is the `keep it all` branch, so all - -- four quarters land in the wallet -- Falkner pays 25 * 9 * 4 = 900 and a - -- Sprout Tower sage 8 * 3 * 4 = 96, instead of the 225 and 24 a single - -- quarter gives. Gen1's TrainerBattleVictory has no such split, so the - -- multiplier stays on the Gen2 side of the fence. + -- Native GSC rewards are four quarters. Their destination is selected + -- by Mom's saving bits; Gen2MomShopping applies the per-account cap. -- THE LEVEL THE PRIZE IS COUNTED FROM. -- -- Gen 1 and Gen 2 count from the mon that was out when the battle ended. @@ -9154,7 +9142,9 @@ function BattleState:enemyMonFainted() prize = P.prize(self.data, self.trainer, opts) if self.trainerB then prize = prize + P.prize(self.data, self.trainerB, opts) end end - self.game.save.money = self.game.save.money + prize + local momAward=require("src.world.Gen2MomShopping").reward(self.game.save,prize) + if not momAward then self.game.save.money=self.game.save.money+prize end + self.g2MomAward=momAward -- Prism's Spurge Bank ATM (event/bank.asm) offers DIRECT DEPOSIT: with it -- enabled, a quarter of what you win in battle is routed to the account -- instead of the wallet. The switch lives in the ATM special @@ -9215,9 +9205,17 @@ function BattleState:enemyMonFainted() -- Platinum's font, so a Sinnoh win read "got 円 480". This default is -- only reached when the dataset has no `_MoneyForWinningText` of its own, -- which is every Gen 3 and Gen 4 cartridge. - self:sayNext(self:romText("_MoneyForWinningText", - "%s got " .. require("src.core.GameVersion").moneySign() .. "%d\nfor winning!", - self.game.save.player.name, prize)) + local momPool=require("src.world.Gen2MomShopping").pool(self.data) + local moneyText=momAward and momPool and momPool.rewardTexts and momPool.rewardTexts[momAward.mode] + if moneyText and (self.data.text and self.data.text[moneyText])then + local text=self.data.text[moneyText] + text=text:gsub("{PLAYER}",function()return self.game.save.player.name end):gsub("{NUM}",tostring(prize)) + self:sayNext(text) + else + self:sayNext(self:romText("_MoneyForWinningText", + "%s got " .. require("src.core.GameVersion").moneySign() .. "%d\nfor winning!", + self.game.save.player.name, prize)) + end end self.result = "win" self.afterQueue = "finish" diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index c8f354e22..bc6d0ff51 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -3320,6 +3320,52 @@ end -- Six native bytes per attribute row: kind/name/action/event-word/sprite. -- Names immediately follow attributes in stock GSC; that boundary supplies -- the row count instead of a copied retail catalogue. +-- Native Mom tables have eight bytes: two big-endian money triples and +-- kind/item bytes. End symbols supply counts, never a copied retail list. +function RomExtractorGen2:gen2MomShopping(pool) + if self.version~="gold"and self.version~="silver"and self.version~="crystal"then return nil end + local result={complete=false} + local ok,why=pcall(function() + for role,name in pairs({random="MomItems_1",ladder="MomItems_2"})do + local first,last=self:symbol(name),self:symbol(name..".End") + assert(first and last and first.bank==last.bank,"missing Mom table bounds") + local count=(last.address-first.address)/8 + assert(count%1==0 and count>0 and count<=255 and first.address>=0x4000 and last.address<=0x8000,"invalid Mom table bounds") + local rows={} + for i=0,count-1 do + local b=self.rom:bytes(first.bank,first.address+i*8,8) + local trigger=b[1]*65536+b[2]*256+b[3];local cost=b[4]*65536+b[5]*256+b[6] + assert(trigger<=999999 and cost<=999999 and(b[7]==1 or b[7]==2)and b[8]>0 and b[8]<255,"invalid Mom row") + rows[#rows+1]={trigger=trigger,cost=cost,kind=b[7],item=b[8]} + end + result[role]=rows + end + local at,ram=self:symbol("CheckBalance_MomItem2.check_have_2300"),self:symbol("hMoneyTemp") + assert(at and ram,"missing Mom trigger instructions") + local b=self.rom:bytes(at.bank,at.address,11) + assert(b[1]==0x21 and b[2]+b[3]*256==ram.address and b[4]==0x36 and b[6]==0x23 and + b[7]==0x36 and b[9]==0x23 and b[10]==0x36,"unrecognized Mom trigger instructions") + result.step=b[5]*65536+b[8]*256+b[11] + assert(result.step>0 and result.step<=999999,"invalid Mom trigger amount") + end) + if not ok then return{complete=false,reason=tostring(why)}end + local found={} + for role,name in pairs({item="Mom_GetScriptPointer.ItemScript",doll="Mom_GetScriptPointer.DollScript"})do + local at=self:symbol(name) + if not at or at.address<0x4000 or not self:gen2InRom(at.bank,at.address)then return{complete=false,reason="missing Mom phone program"}end + found[role]=at + end + result.programs={} + for role,at in pairs(found)do result.programs[role]=self:gen2QueueScript(at.bank,at.address,pool)end + result.rewardTexts={} + self._gen2ScriptTexts=self._gen2ScriptTexts or{} + for mode,name in pairs({[0]="GotMoneyForWinningText",[1]="SentSomeToMomText",[2]="SentHalfToMomText",[3]="SentAllToMomText"})do + local at=self:symbol(name) + if at and self:gen2InRom(at.bank,at.address)then result.rewardTexts[mode]=self:gen2RegisterScriptText(at.bank,at.address)end + end + result.complete=true;return result +end + function RomExtractorGen2:gen2Decorations() if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end local info={complete=false,rowsRead=0,defaultsComplete=false,giftsComplete=false} @@ -5649,6 +5695,8 @@ function RomExtractorGen2:gen2MapIndex() -- PALETTE_DARK, the only value ReplaceTimeOfDayPals answers with -- .NeedsFlash (23:$4400) palette = field(paletteAt, 0) % 16, + paletteBits = field(paletteAt,0), + phoneService = math.floor(field(paletteAt,0)/16)%16==0, } byGroupNumber[group * 256 + number] = entry byLabel[label] = byLabel[label] or entry @@ -6648,6 +6696,7 @@ function RomExtractorGen2:extractMapsFromRom() -- is why a Pokemon Center does not go dark at night on hardware -- and -- the Ruins of Alph chambers carry it too. def.mapPalette = header and header.palette or def.mapPalette + if header then def.mapPaletteBits=header.paletteBits;def.phoneService=header.phoneService end -- Byte 8 is the FISHGROUP_* constant, and 0 is FISHGROUP_NONE -- NOT -- FISHGROUP_SHORE, which is 1 (constants/map_data_constants.asm). -- gen2Fishing keys its table by that same constant, so 0 finds nothing @@ -9021,6 +9070,7 @@ function RomExtractorGen2:extractMapScripts() self:beginStage("Gen2 map scripts end out.stds = self:gen2QueueStdScripts(pool) + out.momShopping=self:gen2MomShopping(pool) out.decorations,out.decorationInfo=self:gen2Decorations() out.decorationPrograms=self:gen2QueueDecorationPrograms(pool) out.marts = self:gen2Marts() diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index d0e8c48bf..55c4a44c7 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1310,7 +1310,7 @@ local function markerFor(version) -- Only these three caches need rebuilding; other markers remain stable. -- v8: field.gen2TradeAnim, GSC's own trade movie art (Gen2TradeArt). if version == "gold" or version == "silver" or version == "crystal" then - revision = "gen2-decorations-native-v1:gen2-field-encounters-v8:" + revision = "gen2-mom-shopping-native-v1:gen2-decorations-native-v1:gen2-field-encounters-v8:" end -- field.tradeArt: the cable-trade movie's tiles out of the ROM -- (Gen1TradeArt); without it the trade ran on drawn stand-ins. diff --git a/src/save_convert/Gen2Save.lua b/src/save_convert/Gen2Save.lua index 10ae779eb..d313bddef 100644 --- a/src/save_convert/Gen2Save.lua +++ b/src/save_convert/Gen2Save.lua @@ -141,6 +141,7 @@ local LAYOUT_GEN2 = { numBalls = 0xD5FC, balls = 0xD5FD, numPCItems = 0xD616, pcItems = 0xD617, pokegearFlags = 0xD67C, + momMoney=0xD576,momSaving=0xD579,momWhich=0xD961,momSet=0xD962,momTrigger=0xD963, decorations = 0xD959, -- wDecoBed; eight bytes, big doll is last eventFlags = 0xD7B7, curBox = 0xD8BC, visitedSpawns = 0xD9EE, mapGroup = 0xDA00, mapNumber = 0xDA01, yCoord = 0xDA02, xCoord = 0xDA03, @@ -269,6 +270,7 @@ local LAYOUT_CRYSTAL = { numBalls = 0xD8D7, balls = 0xD8D8, numPCItems = 0xD8F1, pcItems = 0xD8F2, pokegearFlags = 0xD957, + momMoney=0xD851,momSaving=0xD854,momWhich=0xDC17,momSet=0xDC18,momTrigger=0xDC19, decorations = 0xDC0F, -- wDecoBed, distinct from Gold/Silver eventFlags = 0xDA72, curBox = 0xDB72, visitedSpawns = 0xDCA5, mapGroup = 0xDCB5, mapNumber = 0xDCB6, yCoord = 0xDCB7, xCoord = 0xDCB8, @@ -371,6 +373,7 @@ function Gen2Save.setLayout(version) -- decoration bytes live. Explicit custom layouts may name their own run. if type(version)~="table" and version~="gold" and version~="silver" and version~="crystal" then O.decorations=nil + O.momMoney=nil;O.momSaving=nil;O.momWhich=nil;O.momSet=nil;O.momTrigger=nil end end @@ -1130,6 +1133,18 @@ function Gen2Save.decode(bytes, data) end end + if O.momMoney then + save.g2MomMoney=u24be(bytes,O.momMoney) + local mode=u8(bytes,O.momSaving) + save.g2MomSavingMode=mode%8 + save.g2MomSaving=save.g2MomSavingMode~=0 + save.g2MomBankOpen=hasBit(mode,7)or nil + save.flags.ENGINE_MOM_SAVING_MONEY=hasBit(mode,0) + save.flags.ENGINE_MOM_ACTIVE=hasBit(mode,7) + save.g2MomShopping={whichItem=u8(bytes,O.momWhich),whichSet=u8(bytes,O.momSet), + triggerBalance=u24be(bytes,O.momTrigger)} + end + -- wVisitedSpawns -> the FLY list. HasVisitedSpawn tests bit [spawn id]; -- this port spells the same set save.visited[mapId] (src/ui/TownMap.lua), -- so without this an imported save could not fly anywhere. @@ -1320,6 +1335,30 @@ function Gen2Save.encode(save, data, template) end end + if O.momMoney then + local function nativeNumber(value,limit,label) + assert(type(value)=="number"and value%1==0 and value>=0 and value<=limit,"invalid native Mom "..label) + return value + end + if save.g2MomMoney~=nil then setU24be(buf,O.momMoney,nativeNumber(save.g2MomMoney,999999,"balance"))end + local value=buf[O.momSaving+1]:byte() + if save.g2MomSavingMode~=nil or save.g2MomSaving~=nil then + local mode=save.g2MomSavingMode~=nil and nativeNumber(save.g2MomSavingMode,7,"saving mode")or(save.g2MomSaving and 1 or 0) + if save.flags and save.flags.ENGINE_MOM_SAVING_MONEY~=nil then + mode=mode-mode%2+(save.flags.ENGINE_MOM_SAVING_MONEY and 1 or 0) + end + value=value-value%8+mode + end + if save.g2MomBankOpen~=nil then value=withBit(value,7,save.g2MomBankOpen==true)end + setByte(buf,O.momSaving,value) + if type(save.g2MomShopping)=="table"then + local state=save.g2MomShopping + setByte(buf,O.momWhich,nativeNumber(state.whichItem,255,"ladder index")) + setByte(buf,O.momSet,nativeNumber(state.whichSet or 0,255,"random row")) + setU24be(buf,O.momTrigger,nativeNumber(state.triggerBalance,999999,"random threshold")) + end + end + -- wVisitedSpawns: the FLY destinations this save has reached local spawnFlags = data and data.field and data.field.spawnFlags if type(spawnFlags) == "table" then diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index 9a394bc36..d5e0d0f69 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -2317,16 +2317,18 @@ end function Commands.g2_bank_of_mom(ctx) local save = ctx.save + local Mom=require("src.world.Gen2MomShopping") + Mom.prepareBank(save) -- CheckIfBankInitialized: the first visit opens the account and asks if save.g2MomBankOpen == nil then - save.g2MomBankOpen = true + Mom.openBank(save) Commands.ask(ctx, momLine(ctx, "saveMoney", "Should I save money for you?")) if ctx.lastCheck then - save.g2MomSaving = true + Mom.setSaving(save,true) return momSay(ctx, "startSavingMoney", "OK, I'll save your money.") end - save.g2MomSaving = false + Mom.setSaving(save,false) return momSay(ctx, "justDoWhatYouCan", "Just do what you can.") end @@ -2391,7 +2393,7 @@ function Commands.g2_bank_of_mom(ctx) momSay(ctx, "takenMoney", "Took ¥" .. amount .. ".") end) elseif choice == 3 then - save.g2MomSaving = not save.g2MomSaving + Mom.setSaving(save,not save.g2MomSaving) if save.g2MomSaving then momSay(ctx, "startSavingMoney", "OK, I'll save your money.") else diff --git a/src/world/Gen2MomShopping.lua b/src/world/Gen2MomShopping.lua new file mode 100644 index 000000000..ab075abdf --- /dev/null +++ b/src/world/Gen2MomShopping.lua @@ -0,0 +1,139 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Native accounts, ROM shopping rows and original phone programs share one save. +local Version=require('src.core.GameVersion') +local M={MAX_MONEY=999999} +local function integer(value,lo,hi) + return type(value)=='number'and value%1==0 and value>=lo and value<=hi and value or nil +end +local function money(value)return integer(value,0,M.MAX_MONEY)or 0 end +function M.stock() + local v=Version.get();return v=='gold'or v=='silver'or v=='crystal' +end +function M.mode(save) + local mode=integer(save.g2MomSavingMode,0,3) + local flag=save.flags and save.flags.ENGINE_MOM_SAVING_MONEY + if mode==nil then mode=flag~=nil and(flag and 1 or 0)or(save.g2MomSaving and 1 or 0) + elseif flag~=nil then mode=mode-mode%2+(flag and 1 or 0)end + return mode +end +function M.prepareBank(save) + if not M.stock()then return end + save.flags=save.flags or{} + local Flags=require('src.script.Flags') + local active=save.flags.ENGINE_MOM_ACTIVE + if active==nil and save.g2MomBankOpen==true then Flags.set(save,'ENGINE_MOM_ACTIVE') + elseif active==true then save.g2MomBankOpen=true + elseif active==false then save.g2MomBankOpen=nil end + if save.flags.ENGINE_MOM_SAVING_MONEY==nil and(save.g2MomSaving~=nil or save.g2MomSavingMode~=nil)then + if M.mode(save)%2==1 then Flags.set(save,'ENGINE_MOM_SAVING_MONEY')else Flags.clear(save,'ENGINE_MOM_SAVING_MONEY')end + end + save.g2MomSaving=M.mode(save)~=0 +end +function M.setSaving(save,on) + save.g2MomSaving=on==true + if not M.stock()then return end + save.g2MomSavingMode=on and 1 or 0 + save.flags=save.flags or{} + local Flags=require('src.script.Flags') + if on then Flags.set(save,'ENGINE_MOM_SAVING_MONEY')else Flags.clear(save,'ENGINE_MOM_SAVING_MONEY')end +end +function M.openBank(save) + save.g2MomBankOpen=true + if M.stock()then + save.flags=save.flags or{};require('src.script.Flags').set(save,'ENGINE_MOM_ACTIVE') + end +end +-- WinTrainerBattle deals four quarters; near-cap overflow is lost in the +-- selected account, not redirected to the other one. +function M.reward(save,total) + if not M.stock()or not integer(total,0,0xffffff*4)then return nil end + M.prepareBank(save) + local quarter=math.floor(total/4) + local mode=money(save.g2MomMoney)0 and type(p.ladder)=='table'and #p.ladder>0 +end +function M.caller(data) + local found + for id,row in pairs(data and data.map_scripts and data.map_scripts.phone or{})do + if integer(id,1,255)and type(row)=='table'and(row.class==nil or row.class==0)and(row.contactNumber or row.trainer)==1 then + if found then return nil end;found=id + end + end + return found +end +function M.state(data,save) + if not M.ready(data)then return nil end + if save.g2MomShopping==nil then save.g2MomShopping={whichItem=0,whichSet=0,triggerBalance=M.pool(data).step}end + local state=save.g2MomShopping + if type(state)~='table'or not integer(state.whichItem,0,255)or not integer(state.triggerBalance,0,M.MAX_MONEY)then return nil end + return state +end +function M.pick(data,save,random) + local state=M.state(data,save);if not state then return nil end + local p=M.pool(data);local saved=money(save.g2MomMoney) + state.whichSet=0 + local row=p.ladder[state.whichItem+1] + if row and saved>=row.trigger then return{row=row,set=2}end + while state.triggerBalancecap then return false end + save.pcItems=pc;pc[id]=held+1;return true +end +function M.tryBuy(data,save,opts) + opts=opts or{} + if not M.ready(data)or opts.phoneService~=true or opts.queued then return nil end + local caller=M.caller(data);if not caller then return nil end + local p=M.pool(data);local VM=require('src.script.Gen2ScriptVM') + local itemScript=p.programs and VM.compile(data,p.programs.item) + local dollScript=p.programs and VM.compile(data,p.programs.doll) + if not(itemScript and dollScript)then return nil end + local choice=M.pick(data,save,opts.random);if not choice then return nil end + if not receive(data,save,choice.row)then return nil end + save.g2MomMoney=math.max(0,money(save.g2MomMoney)-choice.row.cost) + local state=M.state(data,save) + if choice.set==2 then state.whichItem=(state.whichItem+1)%256 end + return{kind=choice.row.kind,item=choice.row.item,cost=choice.row.cost,set=choice.set, + saved=save.g2MomMoney,caller=caller,script=choice.row.kind==2 and dollScript or itemScript} +end +return M diff --git a/src/world/Gen2PhoneService.lua b/src/world/Gen2PhoneService.lua new file mode 100644 index 000000000..bd9d96c66 --- /dev/null +++ b/src/world/Gen2PhoneService.lua @@ -0,0 +1,11 @@ +local PhoneService = {} +function PhoneService.available(def) + if not def then return false end + if def.phoneService~=nil then return def.phoneService==true end + -- New imports retain the complete byte. Older authored datasets may have + -- stored it in mapPalette itself; old ROM caches retain their legacy rule + -- until reimport because their discarded bits cannot be reconstructed. + local raw=tonumber(def.mapPaletteBits) or tonumber(def.mapPalette) or 0 + return math.floor(raw/16)%16==0 +end +return PhoneService diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 54ac1c659..5c5f0b589 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3300,6 +3300,33 @@ function OverworldState:checkSpecialPhoneCall() onDone = function() Game.save.g2CurCaller = nil end }) end +-- A purchase is already committed at trainer reload. Its original phone +-- program waits for the trainer's queued dialogue and overlays to finish. +function OverworldState:checkMomShoppingCall() + local pending=self.g2MomCallPending + if not pending then return false end + if pending.save~=Game.save or pending.data~=Game.data then self.g2MomCallPending=nil;return false end + local top=Game.stack and Game.stack.top and Game.stack:top() + if top and top~=self then return false end + if self.player and(self.player.moving or self.player.targetX or self.player.stairExit or self.player.inputLocked)then return false end + if self.transitioning or self.runner:isRunning()or #(self.scriptMoves or{})>0 or self.engaging or self.emote or + self.teleportOut or self.whirlSpin or self.gen4Waterfall or self.flyAnim or self.fishing or + self.pendingScripts and self.pendingScripts[1]then return false end + self.g2MomCallPending=nil + local purchase=pending.purchase + Game.save.g2CurCaller=purchase.caller + pcall(function()require("src.core.Sound").play(Game.data,"Call")end) + self:queueScript(purchase.script,{phoneCaller=purchase.caller,onDone=function() + if Game.save~=pending.save or Game.data~=pending.data then return end + Game.save.g2CurCaller=nil + require("src.script.Gen2Commands").resetCallDelay(Game.save,math.floor(require("src.core.SaveData").playSeconds(Game.save)/60)) + pcall(function()require("src.core.Sound").play(Game.data,"Hang_Up")end) + end}) + local commands=require("src.script.Gen2Commands") + if commands.announceIncomingCall then commands.announceIncomingCall(Game.data,purchase.caller)end + return true +end + -- CheckPhoneCall (36:$4074): the random incoming call, rolled on every step -- the player finishes off a warp tile. Gen2Commands.rollIncomingCall carries -- the ROM's own gating -- the receive-call delay, the one-in-two coin flip, the @@ -3666,6 +3693,7 @@ function OverworldState:update(dt) -- not an event, and the check is two flag reads. self:applyForcedBike() self:checkSpecialPhoneCall() + self:checkMomShoppingCall() self:checkBugContestClock() -- world.tick: the per-frame seam for mods that simulate something in the -- overworld rather than draw it. @@ -14458,6 +14486,19 @@ function OverworldState:gen4FieldResumed() end function OverworldState:afterBattle(result, battle) + if result=="win" and battle and battle.kind=="trainer" and not battle.linkBattle and not battle.battleTower and + not battle.g2MomHandled and require("src.world.Gen2MomShopping").stock()then + battle.g2MomHandled=true + if not self.g2MomCallPending then + local purchase=require("src.world.Gen2MomShopping").tryBuy(Game.data,Game.save,{ + phoneService=require("src.world.Gen2PhoneService").available(self.map and self.map.def), + queued=self.pendingScripts and self.pendingScripts[1]~=nil, + }) + if purchase then self.g2MomCallPending={save=Game.save,data=Game.data,purchase=purchase}end + end + end + + self.gen4EncounterAttempts = 0 -- a battle a script did not start (a wild one, a trainer's sight) comes -- back to the field here; a scripted one already did in its onFinish diff --git a/tests/engine/gen2_mom_import.lua b/tests/engine/gen2_mom_import.lua new file mode 100644 index 000000000..036bc09e8 --- /dev/null +++ b/tests/engine/gen2_mom_import.lua @@ -0,0 +1,98 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local old=V.get() +local E=require('src.import.RomExtractorGen2');local Importer=require('src.import.RomImporter') +local markerFor +for i=1,100 do local name,value=debug.getupvalue(Importer.readyReport,i);if name=='markerFor'then markerFor=value;break end;if not name then break end end +assert(markerFor) +local function fixture(version,edit) + local raw=string.rep('\0',0x8000) + local function put(at,bytes)raw=raw:sub(1,at)..bytes..raw:sub(at+#bytes+1)end + local symbols={MomItems_1={1,0x4000},['MomItems_1.End']={1,0x4010},MomItems_2={1,0x4010},['MomItems_2.End']={1,0x4028}, + ['CheckBalance_MomItem2.check_have_2300']={1,0x4100},hMoneyTemp={0,0xff80}, + ['Mom_GetScriptPointer.ItemScript']={1,0x4200},['Mom_GetScriptPointer.DollScript']={1,0x4201}, + GotMoneyForWinningText={1,0x4300},SentSomeToMomText={1,0x4301},SentHalfToMomText={1,0x4302},SentAllToMomText={1,0x4303}} + put(0x4000,string.char(0,0,0,0,0,3,1,17,0,0,0,0,0,5,1,18, + 0,0,100,0,0,20,1,17,0,0,200,0,0,70,2,14,0,1,244,0,0,30,1,18)) + put(0x4100,string.char(0x21,0x80,0xff,0x36,0,0x23,0x36,0,0x23,0x36,17)) + put(0x4200,string.char(0x91,0x91)) + if edit then edit(symbols,put)end + local e=E.new(raw,version,{symbols=symbols});e._gen2ScriptTexts={} + return e +end +local function pool()return{scripts={},queue={},movements={},instructions=0,desyncs=0,failures=0}end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local e=fixture(version);local q=pool();local rows=e:gen2MomShopping(q) + T.eq(rows.complete,true,version..' actual Mom importer accepts bounded native tables') + T.eq(#rows.random,2,version..' random count comes from its end symbol') + T.eq(#rows.ladder,3,version..' ladder count comes from its end symbol') + T.eq(rows.ladder[3].trigger,500,version..' money trigger is a big-endian triple') + T.eq(rows.ladder[2].kind,2,version..' kind distinguishes real DECO byte from native item') + T.eq(rows.ladder[2].item,14,version..' row retains actual byte rather than a guessed species') + T.eq(rows.step,17,version..' random threshold is read from actual instructions, not a retail literal') + T.eq(#q.queue,2,version..' both original phone scripts enter native decode queue') + T.check(e._gen2ScriptTexts[rows.rewardTexts[1]]~=nil,version..' native reward prose reaches shared text importer') + e:gen2MomShopping(q);T.eq(#q.queue,2,version..' repeated import queues each script once') + for _,job in ipairs(q.queue)do e:gen2DecodeScript(job[1],job[2],job[3],q)end + T.eq(q.desyncs,0,version..' native queued scripts decode without desync') + T.check(markerFor(version):find('gen2%-mom%-shopping%-native%-v1:')~=nil,version..' new cache requires purchase data') + for _,edit in ipairs({ + function(s)s.MomItems_1=nil end, + function(s)s['MomItems_2.End']=nil end, + function(s)s['MomItems_1.End'][2]=0x4011 end, + function(s)s['MomItems_1.End'][1]=2 end, + function(s)s['MomItems_2.End'][2]=0x400f end, + function(s,put)put(0x4000,string.char(255,255,255))end, + function(s,put)put(0x4003,string.char(255,255,255))end, + function(s,put)put(0x4006,string.char(3))end, + function(s,put)put(0x4007,string.char(0))end, + function(s,put)put(0x4104,string.char(0))put(0x4107,string.char(0))put(0x410a,string.char(0))end, + function(s)s.hMoneyTemp[2]=0xff81 end, + function(s)s['CheckBalance_MomItem2.check_have_2300']=nil end, + function(s)s['Mom_GetScriptPointer.DollScript']=nil end, + function(s)s['Mom_GetScriptPointer.ItemScript'][2]=0x100 end, + })do + local bad=fixture(version,edit);local q=pool();local result=bad:gen2MomShopping(q) + T.eq(result.complete,false,version..' malformed or incomplete input cannot claim a native purchase binding') + T.eq(result.random,nil,version..' malformed binding publishes no partial retail table') + T.eq(#q.queue,0,version..' invalid binding does not partially mutate script queue') + end +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + local q=pool();T.eq(fixture(version):gen2MomShopping(q),nil,version..' unreviewed layout has no inferred retail shopping') + T.eq(#q.queue,0,version..' unrelated script queue stays unchanged') + T.check(not markerFor(version):find('gen2%-mom%-shopping%-native%-v1:'),version..' unrelated cache remains unchanged') +end +local Service=require('src.world.Gen2PhoneService');local Writer=require('src.import.LuaWriter') +local function extractor(version,palette,compact) + local raw=string.rep('\0',0x8000) + local function put(at,v)raw=raw:sub(1,at)..string.char(v)..raw:sub(at+2)end + put(0x4000,2);put(0x4001,0x40) + local header=compact and {1,2,0,0x50,1,1,palette}or{1,1,2,0,0x50,1,1,palette,0} + for i,v in ipairs(header)do put(0x4001+i,v)end + local layout=compact and {mapHeaderBytes=7,mapHeaderAttrAt=2,mapHeaderTilesetAt=0,mapHeaderEnvAt=1,mapHeaderLandmarkAt=4,mapHeaderMusicAt=5,mapHeaderPaletteAt=6,mapHeaderFishAt=-1,mapAttributesBank=2}or{} + return E.new(raw,version,{symbols={MapGroupPointers={1,0x4000},Native_MapAttributes={compact and 2 or 1,0x5000}},layout=layout}) +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for raw=0,255 do + local e=extractor(version,raw);local _,labels=e:gen2MapIndex();local header=assert(labels.Native) + T.eq(header.palette,raw%16,'native header retains the unchanged palette nibble') + T.eq(header.paletteBits,raw,'native header retains the complete real byte') + T.eq(header.phoneService,raw<16,'native header decodes the actual no-service nibble') + T.eq(Service.available({mapPalette=header.palette,mapPaletteBits=header.paletteBits,phoneService=header.phoneService}),raw<16,'runtime consumes the native imported no-service verdict') + end + for _,raw in ipairs({1,0x11,0xF4})do + local e=extractor(version,raw);local maps={NATIVE={id='NATIVE',label='Native',width=1,height=1,warps={}}};local encoded + -- Input table/file output are IO seams. Header lookup, map lowering and + -- Lua serialization are the actual importer, including false booleans. + function e:readSourceTable(name)return name=='maps'and maps or{}end + function e:write(name,value)if name=='maps'then encoded=Writer.encode(value)end end + e:extractMapsFromRom();local decoded=assert(loadstring(assert(encoded)))().NATIVE + T.eq(decoded.mapPalette,raw%16,'full map lowering preserves the palette rule') + T.eq(decoded.mapPaletteBits,raw,'full map lowering retains header bits') + T.eq(decoded.phoneService,raw<16,'serialization does not lose an explicit no-service false') + end +end +V.set(old);T.finish('native Mom table boundaries, threshold instruction operands, script queues and cache revision') diff --git a/tests/engine/gen2_mom_shopping.lua b/tests/engine/gen2_mom_shopping.lua new file mode 100644 index 000000000..a140cce25 --- /dev/null +++ b/tests/engine/gen2_mom_shopping.lua @@ -0,0 +1,201 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local Mom=require('src.world.Gen2MomShopping');local Native=require('src.world.Gen2Decorations') +local Flags=require('src.script.Gen2Flags');local Runner=require('src.script.ScriptRunner') +local W=require('src.world.OverworldController');local Stack=require('src.core.StateStack') +local Map=require('src.world.Map');local Pokemon=require('src.pokemon.Pokemon') +local Battle=require('src.battle.BattleState');local S=require('src.save_convert.Gen2Save') +local E=require('src.import.RomExtractorGen2');local J=require('src.link.Json') +local Sound=require('src.core.Sound');local oldSound=Sound.play;Sound.play=function()end -- audio device IO +local slot,oldGame +for i=1,100 do local name,value=debug.getupvalue(W.afterBattle,i);if name=='Game'then slot=i;oldGame=value;break end end +assert(slot) +local function data() + local d=T.fixtures.fresh() + d.items.ITEM_017={id='ITEM_017',index=17,name='SYNTHETIC A',pocket='ITEM'} + d.items.ITEM_018={id='ITEM_018',index=18,name='SYNTHETIC B',pocket='ITEM'} + d.map_scripts={momShopping={complete=true,step=17, + random={{trigger=0,cost=3,kind=1,item=17},{trigger=0,cost=5,kind=1,item=18}}, + ladder={{trigger=100,cost=20,kind=1,item=17},{trigger=200,cost=70,kind=2,item=14},{trigger=500,cost=30,kind=1,item=18}}, + programs={item='ITEM_PHONE',doll='DOLL_PHONE'},rewardTexts={[0]='REWARD0',[1]='REWARD1',[2]='REWARD2',[3]='REWARD3'}}, + phone={[3]={class=0,contactNumber=1,name='SYNTHETIC MOM'}}, + decorations={[14]={type=1,name='SYNTHETIC DOLL',action='SET_UP_DOLL',flag=900,sprite=18}}, + decorationInfo={complete=true},scripts={BANK={{'special',0,'BankOfMom'},{'end'}},ITEM_PHONE={{'setevent',901},{'end'}},DOLL_PHONE={{'setevent',902},{'end'}}}} + d.text={REWARD0='SYNTHETIC {PLAYER} TOTAL {NUM}',REWARD1='SYNTHETIC SOME {PLAYER} TOTAL {NUM}', + REWARD2='SYNTHETIC HALF {PLAYER} TOTAL {NUM}',REWARD3='SYNTHETIC ALL {PLAYER} TOTAL {NUM}'} + return d +end +local function save(balance) + return{flags={},money=0,g2MomMoney=balance or 0,inventory={},pcItems={},party={},player={name='QA'},pokedex={seen={},owned={}}} +end +local function buy(d,s,opts) + opts=opts or{};opts.phoneService=opts.phoneService~=false;return Mom.tryBuy(d,s,opts) +end +local function game(d,s) + local def={id='QA_MOM',generation=2,width=2,height=2,blocks={1,1,1,1},warps={},mapPalette=1,phoneService=true} + local ts={id='FLOOR',blockCells=2,collision={255,255,255,255,0,0,0,0},walkable={0},grassTiles={},waterTiles={}} + d.maps.QA_MOM=def + local w=setmetatable({map=Map.new(def,ts),player={cellX=0,cellY=0,facing='down'},scriptMoves={},pendingScripts={},npcs={}},{__index=W}) + local stack=setmetatable({states={w}},{__index=Stack}) -- world already entered in this fixture + local g={data=d,save=s,stack=stack,world=w,overworld=w,input={wasPressed=function()return false end}};w.runner=Runner.new(g,w) + debug.setupvalue(W.afterBattle,slot,g);return g,w +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for mode=0,3 do + for _,coin in ipairs({1,2})do + local s=save(100);s.money=10;s.g2MomSavingMode=mode + local quarters=({[0]=0,1,2,4})[mode];local award=Mom.reward(s,900*coin) + T.eq(s.g2MomMoney,100+225*coin*quarters,version..' native mode '..mode..' sends actual quarters to Mom') + T.eq(s.money,10+225*coin*(4-quarters),version..' wallet receives only its actual share') + T.eq(award.total,900*coin,version..' displayed total remains complete prize') + T.eq(award.mode,mode,version..' native text uses actual savings mode') + end + end + local s=save(999999);s.g2MomSaving=true + T.eq(Mom.reward(s,900).mode,0,version..' maxed savings disables skimming') + T.eq(s.money,900,version..' maxed savings sends all quarters to wallet') + s=save(999998);s.g2MomSaving=true;s.money=999998 + Mom.reward(s,900) + T.eq(s.g2MomMoney,999999,version..' near-cap Mom account saturates') + T.eq(s.money,999999,version..' wallet saturates separately') + s=save();s.g2MomSaving=true;s.flags.ENGINE_MOM_SAVING_MONEY=false + T.eq(Mom.mode(s),0,version..' explicit native flag overrides legacy saving boolean') + s.g2MomSavingMode=2;T.eq(Mom.mode(s),2,version..' HALF bit survives an unset SOME bit') + s.flags.ENGINE_MOM_SAVING_MONEY=true;T.eq(Mom.mode(s),3,version..' native setflag changes only bit zero') + Mom.setSaving(s,false);T.eq(Mom.mode(s),0,version..' bank stop clears saving mode') + Mom.openBank(s);Mom.setSaving(s,true) + T.eq(s.flags.ENGINE_MOM_ACTIVE,true,version..' bank opening reaches native active flag') + T.eq(s.flags.ENGINE_MOM_SAVING_MONEY,true,version..' bank start reaches native SOME flag') + local previous=save();previous.g2MomBankOpen=true;previous.g2MomSaving=true + Mom.prepareBank(previous) + T.eq(previous.flags.ENGINE_MOM_ACTIVE,true,version..' earlier native preview account migrates to active flag') + T.eq(previous.flags.ENGINE_MOM_SAVING_MONEY,true,version..' earlier native preview saving choice migrates to native flag') + previous.flags.ENGINE_MOM_ACTIVE=false;Mom.prepareBank(previous) + T.eq(previous.g2MomBankOpen,nil,version..' explicit native reset overrides legacy open boolean') + local legacy=data();legacy.map_scripts.phone[3].contactNumber=nil;legacy.map_scripts.phone[3].trainer=1 + T.eq(Mom.caller(legacy),3,version..' original non-trainer contact byte remains supported') + local d=data();s=save() + local bankGame,bankWorld=game(d,s) + local bank=Runner.new(bankGame,bankWorld);bank:run(assert(require('src.script.Gen2ScriptVM').compile(d,'BANK'))) + local box=bankGame.stack:top();box.done=true;box:update(0) + local yes=bankGame.stack:top();bankGame.input={wasPressed=function(_,key)return key=='a'end};yes.index=1;yes:update(0);bankGame.input={wasPressed=function()return false end};for _=1,20 do if bankGame.stack:top()==yes then yes:update(0)end end + local feedback=bankGame.stack:top();feedback.done=true;bankGame.input={wasPressed=function(_,key)return key=='a'end};feedback:update(0) + T.eq(bank:isRunning(),false,version..' actual BankOfMom UI completes first saving choice') + T.eq(s.flags.ENGINE_MOM_SAVING_MONEY,true,version..' actual BankOfMom UI writes native SOME flag') + T.eq(s.flags.ENGINE_MOM_ACTIVE,true,version..' actual BankOfMom UI opens native account') + T.eq(Mom.mode(s),1,version..' actual BankOfMom UI affects reward allocation mode') + local d=data();s=save(100) + local purchase=buy(d,s) + T.eq(purchase.kind,1,version..' first ladder purchase is native item') + T.eq(s.pcItems.ITEM_017,1,version..' native item byte resolves current dataset') + T.eq(s.g2MomMoney,80,version..' price deducted once') + T.eq(s.g2MomShopping.whichItem,1,version..' ladder advances only on delivery') + T.eq(s.g2MomShopping.whichSet,0,version..' ladder uses native zero set') + T.eq(purchase.caller,3,version..' Mom caller is current native contact identity') + s.g2MomMoney=200;purchase=buy(d,s) + T.eq(s.flags[Flags.eventFlag(900)],true,version..' doll purchase grants actual ownership flag') + T.eq(s.g2MomMoney,130,version..' doll price is deducted') + T.eq(s.g2MomShopping.whichItem,2,version..' doll advances ladder once') + T.eq(s.decorations,nil,version..' doll ownership does not place it in the room') + local g,w=game(d,s);local r=Runner.new(g,w);r:run(purchase.script) + T.eq(s.flags[Flags.eventFlag(902)],true,version..' doll notification runs native compiled role') + s=save(101);purchase=buy(d,s) + T.check(purchase~=nil,version..' ladder takes precedence over random threshold') + s=save(34);s.g2MomShopping={whichItem=255,triggerBalance=17} + local called=0;purchase=buy(d,s,{random=function(n)called=called+1;return n-1 end}) + T.eq(purchase.item,18,version..' exact multiple selects current random table row') + T.eq(called,1,version..' random purchase consumes one bounded roll') + T.eq(s.g2MomShopping.triggerBalance,51,version..' consumed exact threshold advances once') + T.eq(s.g2MomShopping.whichSet,2,version..' native random set is selected row plus one') + T.eq(s.g2MomShopping.whichItem,255,version..' random buy cannot consume ladder index') + T.eq(s.g2MomMoney,29,version..' random purchase deducts its own price') + s=save(35);s.g2MomShopping={whichItem=255,triggerBalance=17} + T.eq(buy(d,s),nil,version..' overshot multiple cannot buy') + T.eq(s.g2MomShopping.triggerBalance,51,version..' overshot threshold advances above current balance') + s=save(100);d.field.pcItemCap=1;s.pcItems.ITEM_018=99 + T.eq(buy(d,s),nil,version..' full PC rejects new stack') + T.eq(s.g2MomMoney,100,version..' refusal does not spend money') + T.eq(s.g2MomShopping.whichItem,0,version..' refusal does not advance ladder') + s.pcItems={ITEM_017=98};T.check(buy(d,s)~=nil,version..' existing stack with room accepts purchase') + T.eq(s.pcItems.ITEM_017,99,version..' item reaches last slot in actual stack') + s=save(100);s.pcItems.ITEM_017=99 + T.eq(buy(d,s),nil,version..' a full same-item stack needs another native PC stack') + s=save(200);s.g2MomShopping={whichItem=1,triggerBalance=17};s.pcItems.ITEM_018=99 + T.check(buy(d,s)~=nil,version..' doll ownership is independent of PC capacity') + s=save(100);T.eq(buy(d,s,{phoneService=false}),nil,version..' no reception suppresses purchase') + T.eq(s.g2MomShopping,nil,version..' no reception cannot consume threshold or initialize purchase state') + T.eq(buy(d,s,{queued=true}),nil,version..' occupied native map-reentry queue suppresses purchase') + T.eq(s.g2MomShopping,nil,version..' occupied queue cannot mutate purchase state') + d.map_scripts.scripts.ITEM_PHONE=nil + T.eq(buy(d,s),nil,version..' missing original phone program cannot grant a silent purchase') + T.eq(s.pcItems.ITEM_017,nil,version..' missing native program leaves inventory unchanged') + -- Actual battle win uses this account split and the cartridge-selected text. + d=data();require('src.battle.TypeChart').load(d);s=save(10) + s.party={Pokemon.new(d,'FIXMON_A',10)};s.g2MomSavingMode=1 + g,w=game(d,s) + local battle=Battle.newTrainer(g,'OPP_FIX_YOUNGSTER',1);battle.participants={} + for _,mon in ipairs(battle.enemyParty)do mon.hp=0 end + battle.enemyIndex=#battle.enemyParty;battle.enemy.mon=battle.enemyParty[battle.enemyIndex] + battle:enemyMonFainted() + T.eq(battle.result,'win',version..' real battle native win branch completes') + T.check(s.g2MomMoney>10,version..' real battle win actually increases Mom account') + T.eq(battle.g2MomAward.mode,1,version..' real battle records applied savings mode') + local found=false + for _,row in ipairs(battle.queue)do if row.text and row.text:find('SYNTHETIC SOME QA TOTAL ',1,true)then found=true end end + T.check(found,version..' real battle presents actual imported SOME reward text') + -- Actual world post-battle path commits once and waits behind native dialogue. + d=data();s=save(200);s.party={Pokemon.new(d,'FIXMON_A',10)} + s.g2MomShopping={whichItem=1,triggerBalance=17};g,w=game(d,s) + battle={kind='trainer',leveledUp={}} + w:afterBattle('win',battle) + T.eq(s.g2MomMoney,130,version..' actual trainer return purchases eligible doll') + T.check(w.g2MomCallPending~=nil,version..' actual trainer return defers original notification') + w:afterBattle('win',battle);T.eq(s.g2MomMoney,130,version..' repeated callback cannot buy twice') + w.pendingScripts={{script={{'end'}}}} + T.eq(w:checkMomShoppingCall(),false,version..' trainer after-dialogue queue keeps phone waiting') + w.pendingScripts={};w.player.moving=true + T.eq(w:checkMomShoppingCall(),false,version..' native phone does not interrupt a partial movement') + w.player.moving=false;g.stack:push({name='dialogue'}) + T.eq(w:checkMomShoppingCall(),false,version..' actual overlay owner keeps phone waiting') + g.stack:pop();T.eq(w:checkMomShoppingCall(),true,version..' idle native world queues original doll script') + T.eq(s.g2CurCaller,3,version..' deferred native call sets actual caller') + T.eq(w:checkMomShoppingCall(),false,version..' notification queues once') + local entry=w.pendingScripts[1];T.check(entry~=nil,version..' actual native FIFO receives notification') + if entry then w.runner:run(entry.script,entry.extra)end + T.eq(s.flags[Flags.eventFlag(902)],true,version..' actual queued original role executes') + T.eq(s.g2CurCaller,nil,version..' completed native call releases caller') + T.eq(s.g2CallCycles,0,version..' completed native call resets receive timer') + -- Physical SRAM offsets checked against the independent shipped symbols. + S.setLayout(version);S.setCharmap({['128']='A'}) + s.g2MomMoney=123456;s.g2MomSavingMode=2;s.g2MomSaving=true;s.g2MomBankOpen=true + s.flags.ENGINE_MOM_SAVING_MONEY=false;s.flags.ENGINE_MOM_ACTIVE=true + s.g2MomShopping={whichItem=2,whichSet=1,triggerBalance=98765} + local bytes=S.encode(s,d);local f=assert(io.open('tools/rom_manifest_'..version..'.json','rb'));local manifest=assert(J.decode(f:read('*a')));f:close() + local anchor=version=='crystal'and 0xd47b or 0xd1a1 + local function offset(key)return 0x2009+manifest.symbols[key][2]-anchor end + local function three(at)local a,b,c=bytes:byte(at+1,at+3);return a*65536+b*256+c end + T.eq(three(offset('wMomsMoney')),123456,version..' Mom account uses actual three-byte SRAM field') + T.eq(bytes:byte(offset('wMomSavingMoney')+1),130,version..' saving mode and active bit preserve native byte') + T.eq(bytes:byte(offset('wWhichMomItem')+1),2,version..' native ladder index exports at independent address') + T.eq(three(offset('wMomItemTriggerBalance')),98765,version..' native threshold exports at independent address') + local back=S.decode(bytes,d) + T.eq(back.g2MomMoney,123456,version..' native balance round-trips') + T.eq(Mom.mode(back),2,version..' HALF mode round-trips without becoming SOME') + T.eq(back.g2MomBankOpen,true,version..' bank active state round-trips') + T.eq(back.g2MomShopping.whichItem,2,version..' ladder progress round-trips') + T.eq(back.g2MomShopping.whichSet,1,version..' random row round-trips') + T.eq(back.g2MomShopping.triggerBalance,98765,version..' threshold progress round-trips') + local again=S.encode(back,d,bytes);local decoded=S.decode(again,d) + local good=true;for _,warning in ipairs(decoded.warnings)do if warning:find('checksum')then good=false end end + T.check(good,version..' Mom fields remain covered by valid native checksum') +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + V.set(version);local s=save(10);T.eq(Mom.reward(s,900),nil,version..' native GSC account split is isolated') + T.eq(Mom.tryBuy(data(),s,{phoneService=true}),nil,version..' foreign edition cannot buy retail GSC gifts') + S.setLayout(version);T.eq(S.OFFSETS.momMoney,nil,version..' unreviewed SRAM layout has no guessed Mom fields') +end +Sound.play=oldSound;debug.setupvalue(W.afterBattle,slot,oldGame);V.set(original);S.setLayout(original) +T.finish('native GSC Mom accounts, purchases, trainer return, phone queue and SRAM') From 87f69596012dafa8d5feb216b87f8025a0f42736 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:27:10 +0100 Subject: [PATCH 48/67] Retain native non-trainer phone identity for original Mom purchase calls --- src/import/RomExtractorGen2.lua | 4 +++- tests/engine/gen2_mom_import.lua | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index bc6d0ff51..7e1867848 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -8669,7 +8669,9 @@ function RomExtractorGen2:gen2PhoneContacts(pool) entry = { class = class, trainer = trainer } elseif trainer ~= 0 and names[trainer] then local name, role = names[trainer]:match("^([^\n\f\v]+)[\n\f\v]%s*(.*)$") - entry = { name = (name or names[trainer]):gsub(":$", ""), role = role } + -- Keep the non-trainer's native identity; names are localizable. + entry = { name = (name or names[trainer]):gsub(":$", ""), role = role, + class = 0, contactNumber = trainer } end -- The contact's OWN map, bytes 3 and 4. GetAvailableCallers (36:$40DE) -- compares it against wMapGroup/wMapNumber and drops the contact from the diff --git a/tests/engine/gen2_mom_import.lua b/tests/engine/gen2_mom_import.lua index 036bc09e8..5824fb69b 100644 --- a/tests/engine/gen2_mom_import.lua +++ b/tests/engine/gen2_mom_import.lua @@ -12,19 +12,25 @@ local function fixture(version,edit) local symbols={MomItems_1={1,0x4000},['MomItems_1.End']={1,0x4010},MomItems_2={1,0x4010},['MomItems_2.End']={1,0x4028}, ['CheckBalance_MomItem2.check_have_2300']={1,0x4100},hMoneyTemp={0,0xff80}, ['Mom_GetScriptPointer.ItemScript']={1,0x4200},['Mom_GetScriptPointer.DollScript']={1,0x4201}, + PhoneContacts={1,0x4400},SpecialPhoneCallList={1,0x4418},NonTrainerCallerNames={1,0x4500}, GotMoneyForWinningText={1,0x4300},SentSomeToMomText={1,0x4301},SentHalfToMomText={1,0x4302},SentAllToMomText={1,0x4303}} put(0x4000,string.char(0,0,0,0,0,3,1,17,0,0,0,0,0,5,1,18, 0,0,100,0,0,20,1,17,0,0,200,0,0,70,2,14,0,1,244,0,0,30,1,18)) put(0x4100,string.char(0x21,0x80,0xff,0x36,0,0x23,0x36,0,0x23,0x36,17)) put(0x4200,string.char(0x91,0x91)) + put(0x440c,string.char(0,1));put(0x4502,string.char(0x10,0x45));put(0x4510,string.char(128,0x50)) if edit then edit(symbols,put)end local e=E.new(raw,version,{symbols=symbols});e._gen2ScriptTexts={} + function e:readSourceTable(key)assert(key=='charmap');return{['128']='Q'}end return e end local function pool()return{scripts={},queue={},movements={},instructions=0,desyncs=0,failures=0}end for _,version in ipairs({'gold','silver','crystal'})do V.set(version);local e=fixture(version);local q=pool();local rows=e:gen2MomShopping(q) T.eq(rows.complete,true,version..' actual Mom importer accepts bounded native tables') + local phone=e:gen2PhoneContacts(nil) + T.eq(phone[1].contactNumber,1,version..' actual non-trainer contact identity survives localized name extraction') + T.eq(phone[1].class,0,version..' actual imported Mom contact retains native non-trainer class') T.eq(#rows.random,2,version..' random count comes from its end symbol') T.eq(#rows.ladder,3,version..' ladder count comes from its end symbol') T.eq(rows.ladder[3].trigger,500,version..' money trigger is a big-endian triple') From d0024cdb2ccf056bc87a740146094f8752deb72e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:51:44 +0100 Subject: [PATCH 49/67] Expose legacy decoration contracts over native GSC records and room state --- docs/native-gen2-decorations.md | 45 ++++++- src/compat/gen2/Decorations.lua | 138 ++++++++++++++++++++ src/core/Data.lua | 1 + src/core/GameVersion.lua | 5 + src/core/ModCompat.lua | 2 + src/mods/Loader.lua | 2 + src/ui/Gen2DecorationMenu.lua | 41 ++---- src/world/Gen2Decorations.lua | 124 +++++++++++++++--- tests/engine/gen2_decoration_facade.lua | 160 ++++++++++++++++++++++++ 9 files changed, 471 insertions(+), 47 deletions(-) create mode 100644 src/compat/gen2/Decorations.lua create mode 100644 tests/engine/gen2_decoration_facade.lua diff --git a/docs/native-gen2-decorations.md b/docs/native-gen2-decorations.md index e9d31d63e..702920fc3 100644 --- a/docs/native-gen2-decorations.md +++ b/docs/native-gen2-decorations.md @@ -57,6 +57,44 @@ the imported template intact; invalid placed bytes fail instead of wrapping. Unreviewed hack layouts receive neither guessed retail tables nor guessed save offsets. +## Legacy module contract + +`require("src.core.gen2.Decorations")` resolves to a signature adapter, not +directly to `src.world.Gen2Decorations`. It exposes `state(save)`, +`attributes(id)`, `name(id, monName)`, `idFor(id)`, `idForFlag(ordinal)`, +`registerInto(registry, data, owner)`, ownership/gift methods, +`ownedCategories(events)`, `rows(events, category)`, `apply(state, id, side, +monName)`, `clearOtherSide`, `visibility`, `tiles` and `posterVisible`. +The action/slot/description constants retain the legacy shapes. `ATTRIBUTES`, +`CATEGORIES` and `NOTHING_TO_CHOOSE` are read projections of the current +native dataset. Attribute rows remain the actual native records; registering +them into another registry passes copies. + +Data initialization and Loader entry bind the current native dataset. Reads +follow subsequent registry folding and rollback. Teardown or an edition +change retires the binding. Before a complete import, the facade returns no +catalogue/defaults instead of fabricating retail content. Removed records do +not fall back to a second catalogue. Both numeric bytes and numeric-string +bytes resolve and placements store numbers. + +Ownership accepts the legacy numeric `events:get/set` protocol or a native +save directly. `events(save)` supplies a numeric view backed by the same +canonical flags used by scripts and SRAM, with no parallel bitfield. That +view expires on rebinding/unload. Cancel/header rows cannot grant event zero. +Categories are rebuilt from native action identities; added rows participate +and removed headers are not invented. Existing empty placement is preserved. + +`apply` follows the legacy two-result contract `(changed, pages)` and does +not check ownership; the menu owns that check. Ornament side cancellation +does nothing, and `clearOtherSide` remains a separate operation for these +callers. The pages use the actual imported feedback text and buffers 3/4, +including authored replacements and explicit script-text removal. The native +PC and facade share placement planning and tile/object layout routines; the +PC still commits after feedback closes. A valid block zero and wrapped +carpet offsets retain native behavior rather than the old facade's filter. +This adapter does not turn a detached external event bitfield into the live +world, nor does wrapping a facade method patch every native module method. + ## Evidence and remaining scope ROM-free tests exercise actual importers, Loader, native script compiler and @@ -72,8 +110,13 @@ executes the original house-PC/description scripts and room callbacks, and round-trips all eight placed bytes. Original ROM hashes are checked unchanged. The local font/audio/render harness is used; no visual acceptance is inferred. +The public-module tests load a synthetic mod through the actual Loader, +change and remove records, grant ownership, place decorations and execute +the native room callback VM. They verify feedback pages, byte identity, +retirement on reload/edition changes, and rollback without a second catalogue. + Mom’s native shopping caller is covered separately in [native-gen2-mom-shopping.md](native-gen2-mom-shopping.md). -Full legacy `Decorations` facade parity, the remaining gift lifecycle, +The remaining gift lifecycle, legacy MomShopping/phone contracts, new graphical acceptance and a full unchanged VASC playthrough remain separate open work. Importing the gift-ordinal table alone does not implement those callers. The existing preview is not refreshed by this source change. diff --git a/src/compat/gen2/Decorations.lua b/src/compat/gen2/Decorations.lua new file mode 100644 index 000000000..952a801a6 --- /dev/null +++ b/src/compat/gen2/Decorations.lua @@ -0,0 +1,138 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Legacy argument/result contracts over the native cartridge and placed bytes. +-- No fallback catalogue, ownership bitfield or second decoration save exists. +local Native=require('src.world.Gen2Decorations') +local Version=require('src.core.GameVersion') +local Flags=require('src.script.Flags') +local Gen2Flags=require('src.script.Gen2Flags') +local Copy=require('src.mods.Merge').deepCopy +local M={SLOTS=Copy(Native.slots),OBJECT_SLOTS=Copy(Native.objectSlots),ACTIONS={}, + EVENT_PLAYERS_ROOM_POSTER=716,DECOFLAG_GOLD_TROPHY_DOLL=43,DECOFLAG_SILVER_TROPHY_DOLL=44, + DESC_SLOTS={DECODESC_POSTER={slot='poster'},DECODESC_LEFT_DOLL={slot='leftOrnament',named=true}, + DECODESC_RIGHT_DOLL={slot='rightOrnament',named=true},DECODESC_BIG_DOLL={slot='bigDoll'}, + DECODESC_CONSOLE={slot='console',named=true}}} +for n=1,14 do + local name=Native.actionName(n);local action=Native.action(name) + if not action.put then action.put=nil end + M.ACTIONS[name]=action +end +local bound,edition,epoch=nil,nil,0 +local function data() + return edition==Version.get()and Native.ready(bound)and bound or nil +end +function M.useRegistry(value) + epoch=epoch+1;bound=nil;edition=nil + if Native.ready(value)then bound=value;edition=Version.get()end + return bound~=nil +end +function M.idFor(id)return 'deco:'..tostring(id)end +function M.idForFlag(n) + n=n==nil and 0 or tonumber(n) + local d=data();local info=d and Native.pool(d).decorationInfo + if not(n and n%1==0 and n>=0 and n<=255 and info and info.giftsComplete)then return nil end + return info.gifts and info.gifts[n+1] +end +function M.attributes(id)return Native.attributes(data(),id)end +function M.state(save)return Native.state(data(),save)end +function M.name(id,monName)return Native.name(data(),id,monName)end +function M.registerInto(registry,source,owner) + local d=Native.ready(source)and source or data();local count=0 + if not d then return count end + local seen={} + for raw in pairs(Native.pool(d).decorations)do + local id=Native.byte(raw) + if id and not seen[id]then + seen[id]=true;registry:register(M.idFor(id),Copy(Native.attributes(d,id)),owner);count=count+1 + end + end + return count +end +-- Optional native adapter for callers of the numeric Events protocol. Its +-- get/set operations reach the same canonical flags as scripts and SRAM. +-- A retained adapter expires on rebind/unload; it never captures a new game. +function M.events(save) + if not data()or type(save)~='table'then return nil end + local generation=epoch + local function current(id) + return generation==epoch and data()and type(id)=='number'and id%1==0 and id>=0 and id<65535 + end + return{get=function(_,id) + return current(id)and type(save.flags)=='table'and Flags.get(save,Gen2Flags.eventFlag(id))or false + end,set=function(_,id,value) + if not current(id)then return false end + save.flags=save.flags or{} + if value then Flags.set(save,Gen2Flags.eventFlag(id))else Flags.clear(save,Gen2Flags.eventFlag(id))end + return true + end} +end +local function usable(id) + id=Native.byte(id) + local row=M.attributes(id);local action=row and Native.action(row.action) + return id and id~=0 and action and not action.put and row or nil +end +function M.owns(events,id) + local row=usable(id);if not row or type(events)~='table'then return false end + if type(events.get)=='function'then return events:get(row.flag)and true or false end + return Native.owns(data(),events,id) +end +function M.give(events,id) + local row=usable(id);if not row or type(events)~='table'then return false end + if type(events.set)=='function'then return events:set(row.flag,true)~=false end + return Native.give(data(),events,id) +end +function M.giveFlag(events,n)return M.give(events,M.idForFlag(n))end +function M.ownedCategories(events) + local out={} + for _,category in ipairs(Native.categories(data()))do + for _,id in ipairs(category.members)do + if M.owns(events,id)then out[#out+1]=category;break end + end + end + return out +end +function M.rows(events,category) + local out={} + -- Rebuild membership rather than trusting a category retained across reload. + for _,current in ipairs(Native.categories(data()))do + if category and current.id==category.id and(current.slot==category.slot or + category.slot==nil and category.id~=nil)then + for _,id in ipairs(current.members)do if M.owns(events,id)then out[#out+1]=id end end + if #out>0 then + for _,id in ipairs(current.headers)do out[#out+1]=id end + out[#out+1]=0 + end + break + end + end + return out +end +function M.apply(state,id,side,monName) + local placement=Native.placement(data(),state,id,side,monName) + if not placement then return false,{}end + state[placement.slot]=placement.value + return placement.changed,Native.feedbackPages(data(),placement) +end +function M.clearOtherSide(state,id,side) + id=Native.byte(id) + if not data()or type(state)~='table'or not id or id==0 or(side~='left'and side~='right')then return end + local other=side=='right'and 'leftOrnament'or 'rightOrnament' + if state[other]==id then state[other]=0 end +end +function M.visibility(state)return data()and Native.layoutVisibility(data(),state)or{}end +function M.tiles(state)return data()and Native.layoutTiles(data(),state)or{}end +function M.posterVisible(state)return data()~=nil and Native.posterVisible(state)end +return setmetatable(M,{__index=function(_,key) + if key=='CATEGORIES'then return Native.categories(data())end + if key=='ATTRIBUTES'then + local out={};local d=data() + for raw in pairs(d and Native.pool(d).decorations or{})do + local id=Native.byte(raw);if id then out[id]=Native.attributes(d,id)end + end + return out + end + if key=='NOTHING_TO_CHOOSE'then + local pages=Native.feedbackPages(data(),{role='noChoice',buffers={}}) + return pages[1] + end +end}) diff --git a/src/core/Data.lua b/src/core/Data.lua index ca41e93a0..6267ecb52 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -1887,6 +1887,7 @@ function Data:seedDefaults() end seedMissingGen2Tilesets(self) self:publishGen2Palettes() + require("src.compat.gen2.Decorations").useRegistry(self) ensureGen2WarpFallbacks(self) ensureGen2HomeTextFallbacks(self) end diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 330f4f3e9..7ebabc4fd 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1082,7 +1082,12 @@ GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver", "crystal", GameVersion.current = "red" function GameVersion.set(id) + local previous = GameVersion.current GameVersion.current = GameVersion.VERSIONS[id] and id or "red" + if previous ~= GameVersion.current then + local decorations = package.loaded["src.compat.gen2.Decorations"] + if decorations then decorations.useRegistry(nil) end + end return GameVersion.current end diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..de815b24f 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -67,6 +67,8 @@ ModCompat.ALIASES = { -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", + -- Explicit signature adapter: native operations take (data, save). + ["src.core.gen2.Decorations"] = "src.compat.gen2.Decorations", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 22977dbfc..f7c8474b9 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1321,6 +1321,7 @@ function Loader:load(data) -- wraps and flags the previous load put on engine tables come off first. ModSeams.unwindAll() self.baseData = data + require("src.compat.gen2.Decorations").useRegistry(data) -- every registry folds against the pristine view of its Data target; -- resolution is lazy so optional namespaces may appear later for _, registry in pairs(self.content) do @@ -1660,6 +1661,7 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + require("src.compat.gen2.Decorations").useRegistry(nil) local undone = select(1, ModSeams.unwindAll()) pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) diff --git a/src/ui/Gen2DecorationMenu.lua b/src/ui/Gen2DecorationMenu.lua index a091734cb..381b63b04 100644 --- a/src/ui/Gen2DecorationMenu.lua +++ b/src/ui/Gen2DecorationMenu.lua @@ -6,10 +6,6 @@ local Menu=require('src.ui.Menu') local Runner=require('src.script.ScriptRunner') local Strings=require('src.core.Strings') local M={} -local categories={ - {'BED','bed'},{'CARPET','carpet'},{'PLANT','plant'},{'POSTER','poster'}, - {'GAME CONSOLE','console'},{'ORNAMENT','ornament'},{'BIG DOLL','bigDoll'}, -} function M.new(game,opts) opts=opts or{} local changed,finished=false,false @@ -30,18 +26,11 @@ function M.new(game,opts) local row=Native.attributes(game.data,id);local action=row and Native.action(row.action) local state=Native.state(game.data,game.save) if not(state and action)or not action.put and not Native.owns(game.data,game.save,id)then return end - local slot=action.slot or(side=='left'and 'leftOrnament'or side=='right'and 'rightOrnament') - if not slot then return end - local old=state[slot]or 0 + local placement=Native.placement(game.data,state,id,side) + if not placement then return end game.stringBuffers=game.stringBuffers or{} - game.stringBuffers[3]=Native.name(game.data,old) - game.stringBuffers[4]=Native.name(game.data,id) - local role - if action.put then role=old==0 and 'empty'or 'put' - elseif old==id then role='same' - elseif old==0 then role='setup';game.stringBuffers[3]=Native.name(game.data,id) - else role='replace'end - say(role,function()changed=Native.choose(game.data,game.save,id,side)or changed end) + for n,name in pairs(placement.buffers)do game.stringBuffers[n]=name end + say(placement.role,function()changed=Native.choose(game.data,game.save,id,side)or changed end) end local function select(id) local row=Native.attributes(game.data,id);local action=row and Native.action(row.action) @@ -66,25 +55,21 @@ function M.new(game,opts) else sides()end end local function category(key) - local items,headers,seen={},{},{} - for raw,row in pairs(Native.pool(game.data).decorations)do - local id=tonumber(raw);local action=Native.action(row.action) - local group=action and(action.slot or 'ornament') - if id and id~=0 and group==key and not seen[id]then - seen[id]=true - if action.put then headers[#headers+1]=id - elseif Native.owns(game.data,game.save,id)then items[#items+1]=id end + local items={} + for _,c in ipairs(Native.categories(game.data))do + if c.slot==key then + for _,id in ipairs(c.members)do if Native.owns(game.data,game.save,id)then items[#items+1]=id end end + return items,c.headers end end - table.sort(items);table.sort(headers) - return items,headers + return items,{} end local rows={} if Native.ready(game.data)and Native.state(game.data,game.save)then - for _,c in ipairs(categories)do - local key=c[2];local ids=category(key) + for _,c in ipairs(Native.categories(game.data))do + local key=c.slot;local ids=category(key) if #ids>0 then - rows[#rows+1]={label=Strings(c[1]),keepOpen=true,onSelect=function() + rows[#rows+1]={label=Strings(c.label),keepOpen=true,onSelect=function() local owned,headers=category(key) if #owned==0 then say('noChoice');return end local items={} diff --git a/src/world/Gen2Decorations.lua b/src/world/Gen2Decorations.lua index d7eab2a3b..3f87b4942 100644 --- a/src/world/Gen2Decorations.lua +++ b/src/world/Gen2Decorations.lua @@ -6,6 +6,18 @@ local V=require('src.core.GameVersion') local Strings=require('src.core.Strings') local M={} M.slots={'bed','carpet','plant','poster','console','bigDoll','leftOrnament','rightOrnament'} +M.objectSlots={ + {slot='console',sprite=0,flag=1857},{slot='leftOrnament',sprite=1,flag=1858}, + {slot='rightOrnament',sprite=2,flag=1859},{slot='bigDoll',sprite=3,flag=1860}, +} +local groups={ + {'BED','bed'},{'CARPET','carpet'},{'PLANT','plant'},{'POSTER','poster'}, + {'GAME CONSOLE','console'},{'ORNAMENT','ornament'},{'BIG DOLL','bigDoll'}, +} +function M.byte(id) + local n=(type(id)=='number'or type(id)=='string')and tonumber(id) + return n and n%1==0 and n>=0 and n<=255 and n or nil +end local kinds={'BED','CARPET','PLANT','POSTER','CONSOLE','BIG_DOLL','DOLL'} local slotFor={BED='bed',CARPET='carpet',PLANT='plant',POSTER='poster',CONSOLE='console',BIG_DOLL='bigDoll'} local actions={} @@ -28,8 +40,9 @@ function M.ready(data) end function M.attributes(data,id) if not M.ready(data)then return nil end + id=M.byte(id);if not id then return nil end local rows=M.pool(data).decorations - return rows[tonumber(id)]or rows[tostring(id)] + return rows[id]or rows[tostring(id)] end function M.state(data,save) if not M.ready(data)or type(save)~='table'then return nil end @@ -44,24 +57,30 @@ local names={ [2]=Strings.source('%s BED'),[3]=Strings.source('%s CARPET'), [4]=Strings.source('%s POSTER'),[5]=Strings.source('%s DOLL'),[6]=Strings.source('BIG %s'), } -function M.name(data,id) +function M.name(data,id,monName) + id=M.byte(id) local row=M.attributes(data,id) if not row then return ''end local name=row.name if row.type>=4 then + if monName then name=monName(name)or name + else local info=M.pool(data).decorationInfo local key=info.sourceNames and info.sourceNames[id]==name and info.species and info.species[id]or name local mon=data.pokemon and data.pokemon[key] name=mon and mon.name or name + end end return names[row.type] and Strings(names[row.type],name)or name end function M.owns(data,save,id) + id=M.byte(id) local row=M.attributes(data,id);local action=row and M.action(row.action) return type(save)=='table' and type(save.flags)=='table' and id~=0 and action and not action.put and require('src.script.Flags').get(save, require('src.script.Gen2Flags').eventFlag(row.flag))or false end function M.give(data,save,id) + id=M.byte(id) local row=M.attributes(data,id) if type(save)~='table' or id==0 or not row or not M.action(row.action)or M.action(row.action).put then return false end save.flags=save.flags or{} @@ -73,6 +92,7 @@ function M.giveFlag(data,save,n) return id~=nil and M.give(data,save,id)or false end function M.choose(data,save,id,side) + id=M.byte(id) local state=M.state(data,save);local row=M.attributes(data,id) local action=row and M.action(row.action) if id==0 or not(state and action)then return false end @@ -91,17 +111,94 @@ function M.choose(data,save,id,side) end return changed end +-- Public menu metadata is derived from the current native rows, including +-- registry additions/removals. No independent retail catalogue is seeded. +function M.categories(data) + if not M.ready(data)then return{}end + local indexed={} + for raw in pairs(M.pool(data).decorations)do + local id=M.byte(raw);if id and id~=0 then indexed[id]=true end + end + local out={} + for _,group in ipairs(groups)do + local category={label=group[1],slot=group[2],members={},headers={}} + for id in pairs(indexed)do + local row=M.attributes(data,id);local action=row and M.action(row.action) + if action and(action.slot or 'ornament')==category.slot then + local list=action.put and category.headers or category.members;list[#list+1]=id + end + end + table.sort(category.members);table.sort(category.headers) + category.id=category.headers[1];out[#out+1]=category + end + return out +end +function M.placement(data,state,id,side,monName) + id=M.byte(id) + local row=M.attributes(data,id);local action=row and M.action(row.action) + if type(state)~='table'or not action or id==0 then return nil end + local slot=action.slot + if action.ornament then + if side~='left'and side~='right'then return nil end + slot=side=='left'and 'leftOrnament'or 'rightOrnament' + end + local old=M.byte(state[slot])or 0 + local value=action.put and 0 or id + local role=action.put and(old==0 and 'empty'or 'put')or + old==id and 'same'or old==0 and 'setup'or 'replace' + return{slot=slot,value=value,changed=old~=value,role=role, + buffers={[3]=M.name(data,role=='setup'and id or old,monName),[4]=M.name(data,id,monName)}} +end +function M.feedbackPages(data,placement) + if not placement then return{}end + local programs=M.pool(data)and M.pool(data).decorationPrograms + local id=programs and programs.texts and programs.texts[placement.role] + if not id then return{}end + local private=data._portableScriptText and data._portableScriptText[id] + local text=private and private.value or not private and data.text and data.text[id] + if type(text)~='string'then return{}end + text=text:gsub('{RAM:wStringBuffer([34])}',function(n)return placement.buffers[tonumber(n)]end) + text=text:gsub('{DONE}',''):gsub('{PROMPT}',''):gsub('\v','\n') + local pages={} + for page in(text..'\f'):gmatch('(.-)\f')do if page~=''then pages[#pages+1]=page end end + return pages +end +function M.layoutVisibility(data,state) + local out={} + for _,binding in ipairs(M.objectSlots)do + local id=type(state)=='table'and M.byte(state[binding.slot])or 0 + local row=id and id~=0 and M.attributes(data,id) + out[#out+1]={sprite=binding.sprite,flag=binding.flag,hidden=not row,byte=row and row.sprite or nil} + end + return out +end +function M.layoutTiles(data,state) + local out={} + local function paint(slot,x,y) + local id=type(state)=='table'and M.byte(state[slot])or 0 + local row=id and id~=0 and M.attributes(data,id) + if row then out[#out+1]={x=x,y=y,block=row.sprite};return row.sprite end + end + paint('bed',0,2);paint('plant',3,2);paint('poster',3,0) + local carpet=paint('carpet',0,0) + if carpet then + out[#out+1]={x=0,y=1,block=(carpet+1)%256} + out[#out+1]={x=1,y=1,block=(carpet+2)%256} + out[#out+1]={x=2,y=1,block=(carpet+1)%256} + end + return out +end +function M.posterVisible(state)return((state and state.poster)or 0)~=0 end -- These callbacks deliberately preserve the VM's current script/check vars. function M.visibility(ctx) local data=ctx.game.data;local state=M.state(data,ctx.save) if not state then return end ctx.save.flags=ctx.save.flags or{} local C=require('src.script.Commands');local Flags=require('src.script.Gen2Flags') - for _,binding in ipairs({{'console',0,1857},{'leftOrnament',1,1858},{'rightOrnament',2,1859},{'bigDoll',3,1860}})do - local id=state[binding[1]]or 0;local row=M.attributes(data,id) - if id~=0 and row then - C.g2_variablesprite(ctx,binding[2],row.sprite);C.clear_flag(ctx,Flags.eventFlag(binding[3])) - else C.set_flag(ctx,Flags.eventFlag(binding[3]))end + for _,row in ipairs(M.layoutVisibility(data,state))do + if not row.hidden then + C.g2_variablesprite(ctx,row.sprite,row.byte);C.clear_flag(ctx,Flags.eventFlag(row.flag)) + else C.set_flag(ctx,Flags.eventFlag(row.flag))end end end function M.tiles(ctx) @@ -109,18 +206,9 @@ function M.tiles(ctx) if not state then return end ctx.save.flags=ctx.save.flags or{} local C=require('src.script.Commands') - local function paint(slot,x,y) - local id=state[slot]or 0;local row=id~=0 and M.attributes(data,id) - if row then C.replace_block(ctx,x,y,row.sprite);return row.sprite end - end - paint('bed',0,2);paint('plant',3,2);paint('poster',3,0) - local carpet=paint('carpet',0,0) - if carpet then - C.replace_block(ctx,0,1,(carpet+1)%256);C.replace_block(ctx,1,1,(carpet+2)%256) - C.replace_block(ctx,2,1,(carpet+1)%256) - end + for _,row in ipairs(M.layoutTiles(data,state))do C.replace_block(ctx,row.x,row.y,row.block)end local flag=require('src.script.Gen2Flags').eventFlag(716) - if (state.poster or 0)~=0 then C.set_flag(ctx,flag)else C.clear_flag(ctx,flag)end + if M.posterVisible(state)then C.set_flag(ctx,flag)else C.clear_flag(ctx,flag)end end function M.description(data,save,kind) local state=M.state(data,save);local programs=M.pool(data)and M.pool(data).decorationPrograms diff --git a/tests/engine/gen2_decoration_facade.lua b/tests/engine/gen2_decoration_facade.lua new file mode 100644 index 000000000..108caedad --- /dev/null +++ b/tests/engine/gen2_decoration_facade.lua @@ -0,0 +1,160 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +require('src.core.ModCompat').install() +local D=require('src.core.gen2.Decorations') +local N=require('src.world.Gen2Decorations') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner') +local F=require('src.script.Gen2Flags') +local function fixture() + local d=T.fixtures.fresh() + d.map_scripts={decorations={ + [0]={type=1,name='BACK',flag=0,sprite=0}, + [40]={type=1,name='STORE',flag=0,action='PUT_AWAY_BED',sprite=0}, + [41]={type=2,name='ONE',flag=620,action='SET_UP_BED',sprite=0}, + [42]={type=2,name='TWO',flag=621,action='SET_UP_BED',sprite=22}, + [80]={type=1,name='STORE DOLL',flag=0,action='PUT_AWAY_DOLL',sprite=0}, + [81]={type=5,name='MODEL',flag=622,action='SET_UP_DOLL',sprite=31}, + [90]={type=1,name='RUG',flag=623,action='SET_UP_CARPET',sprite=255}, + [91]={type=1,name='SCREEN',flag=624,action='SET_UP_CONSOLE',sprite=33}, + [92]={type=4,name='MODEL',flag=625,action='SET_UP_POSTER',sprite=34}, + [93]={type=6,name='MODEL',flag=626,action='SET_UP_BIG_DOLL',sprite=35}, + },decorationInfo={complete=true,defaultsComplete=true,defaults={bed=41,poster=92}, + giftsComplete=true,gifts={42,81},species={[81]='SPECIES_001',[92]='SPECIES_001',[93]='SPECIES_001'}, + sourceNames={[81]='MODEL',[92]='MODEL',[93]='MODEL'}}, + decorationPrograms={texts={setup='SET',put='PUT',empty='EMPTY',same='SAME',replace='REPLACE',noChoice='NONE'}}, + scripts={ROOM={{'special',0,'ToggleDecorationsVisibility'},{'special',0,'ToggleMaptileDecorations'},{'end'}}}} + d.pokemon.SPECIES_001={name='LIVE'} + d.text={SET='A:{RAM:wStringBuffer3}{DONE}',PUT='B:{RAM:wStringBuffer3}{PROMPT}', + EMPTY='C',SAME='D',REPLACE='E:{RAM:wStringBuffer3}\fF:{RAM:wStringBuffer4}{DONE}',NONE='G'} + return d +end +local function load(d,code) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local files,paths={},{} + if code then + files['mods/test/manifest.json']=Json.encode({id='test',name='Synthetic',version='1.0.0',api=2,entry='main.lua',affects_link=false}) + files['mods/test/main.lua']='local mod=...;mod.exports.deco=require("src.core.gen2.Decorations");'..code + paths={'mods/test'} + end + return T.sdk.loadMods(paths,{data=d,fs=T.sdk.memfs(files)}) +end +local function done(r)Loader.teardown();r.release()end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + local d=fixture();local pristine=d.map_scripts.decorations + local r=load(d,[[ + assert(mod.exports.deco.ATTRIBUTES[41].sprite==0) + mod.content.decorations:patch('deco:42',{name='PATCHED',sprite=72}) + mod.content.decorations:register('deco:201',{type=1,name='AUTHORED',flag=900,action='SET_UP_BED',sprite=73}) + mod.content.decorations:remove('deco:41') + ]]) + T.eq(#r.errors,0,version..' real Loader imports public contract before entry executes') + T.eq(D,require('src.compat.gen2.Decorations'),version..' legacy module identity is the adapter') + T.check(D~=N,version..' incompatible signature is not a native alias') + T.eq(D.attributes(41),nil,version..' removed record never resurrects from retail fallback') + T.eq(D.ATTRIBUTES[41],nil,version..' current catalogue agrees with registry removal') + T.eq(D.attributes('42'),d.map_scripts.decorations[42],version..' string byte reads the one actual native record') + T.eq(D.name(42),'PATCHED BED',version..' merged name reaches public function') + T.eq(D.name('81'),'LIVE DOLL',version..' string identity still resolves imported species') + T.eq(D.name(81,function(base)return base=='MODEL'and 'CUSTOM'end),'CUSTOM DOLL',version..' legacy resolver is honored') + T.eq(D.name(92),'LIVE POSTER',version..' poster grammar') + T.eq(D.name(93),'BIG LIVE',version..' large doll grammar') + T.eq(D.name(999),'',version..' invalid byte has no invented name') + local save={flags={}};local state=D.state(save) + T.eq(state,save.decorations,version..' public state is the actual native placed-byte table') + T.same(state,{bed=41,poster=92},version..' defaults come from ROM despite non-retail identities') + T.same(D.state({decorations={}}),{},version..' explicitly empty native placement stays empty') + local events=D.events(save) + T.eq(D.owns(events,201),false,version..' new decoration starts unowned') + T.check(D.give(events,201),version..' numeric public event protocol awards authored record') + T.eq(save.flags[F.eventFlag(900)],true,version..' award reaches canonical native save flag') + T.eq(N.owns(d,save,201),true,version..' native ownership sees legacy award') + T.eq(D.give(events,0),false,version..' cancel cannot award flag zero') + T.eq(D.give(events,40),false,version..' put-away header cannot award flag zero') + T.eq(save.flags[F.eventFlag(0)],nil,version..' rejected award does not mutate map buffer') + T.eq(D.idForFlag(0),42,version..' gift ordinal follows native non-retail mapping') + T.check(D.giveFlag(events,1),version..' native ordinal awards doll') + T.eq(D.idForFlag(-1),nil,version..' invalid ordinal does not guess') + T.check(D.give(save,'42'),version..' direct native-save argument also grants ownership') + T.eq(N.owns(d,save,42),true,version..' direct award reaches actual native ownership') + local category=D.ownedCategories(events)[1] + T.same(category.members,{42,201},version..' categories contain actual merged IDs without removed member') + T.same(D.rows(events,category),{42,201,40,0},version..' owned members then actual header then cancel') + T.same(D.rows(events,{id=40,members={2}}),{42,201,40,0},version..' legacy plain category shape resolves native members') + T.eq(D.idFor(201),'deco:201',version..' canonical registry identity') + T.eq(D.NOTHING_TO_CHOOSE,'G',version..' feedback derives from actual ROM text ID') + local seen={};local owner={} + local count=D.registerInto({register=function(_,id,row,who)seen[id]=row;T.eq(who,owner,version..' seed keeps ownership')end},nil,owner) + T.eq(count,10,version..' seed uses current rows including additions and removals') + seen['deco:42'].name='DETACHED' + T.eq(D.name(42),'PATCHED BED',version..' seeding another registry cannot mutate native catalogue') + local changed,pages=D.apply(state,'42') + T.eq(changed,true,version..' public apply replaces placed native byte') + T.eq(state.bed,42,version..' numeric string never writes a string into SRAM state') + T.same(pages,{'E:','F:PATCHED BED'},version..' replacement uses both actual pages; removed old name is empty') + changed,pages=D.apply(state,42) + T.eq(changed,false,version..' same placement is not a change') + T.same(pages,{'D'},version..' same-placement feedback uses ROM role') + changed,pages=D.apply(state,40) + T.check(changed,version..' put-away changes filled slot') + T.same(pages,{'B:PATCHED BED'},version..' put-away names old item and removes end marker') + changed,pages=D.apply(state,40) + T.eq(changed,false,version..' empty put-away is not a change') + T.same(pages,{'C'},version..' empty feedback') + changed,pages=D.apply(state,201) + T.check(changed,version..' setup in empty slot') + T.same(pages,{'A:AUTHORED'},version..' setup uses original role with new item in buffer3') + changed,pages=D.apply(state,81,nil) + T.eq(changed,false,version..' cancelled side prompt has no placement') + T.same(pages,{},version..' cancelled side prompt has no feedback') + state.rightOrnament=81 + changed,pages=D.apply(state,81,'left') + T.check(changed,version..' chosen doll side changes') + T.eq(state.rightOrnament,81,version..' apply keeps separate legacy clearOtherSide contract') + D.clearOtherSide(state,81,'left');T.eq(state.rightOrnament,0,version..' explicit clear removes opposite duplicate') + state.console=91;state.bigDoll=93;state.carpet=90 + local paint={};local game={data=d,save=save};local world={replaceBlock=function(_,x,y,block)paint[x..','..y]=block end} + local runner=Runner.new(game,world);runner:run(assert(VM.compile(d,'ROOM'))) + for _,row in ipairs(D.tiles(state))do T.eq(paint[row.x..','..row.y],row.block,version..' actual VM tiles agree with public layout')end + T.eq(paint['0,2'],73,version..' legacy placement is visible to actual room callback') + T.eq(paint['0,1'],0,version..' wrapped carpet block remains a valid zero byte') + for _,row in ipairs(D.visibility(state))do + T.eq(save.flags[F.eventFlag(row.flag)],row.hidden,version..' actual room visibility matches public plan') + if not row.hidden then T.eq(save.gen2VarSprites[row.sprite],row.byte,version..' public sprite byte matches native callback')end + end + T.eq(D.posterVisible(state),true,version..' poster visibility agrees with native event') + T.eq(save.flags[F.eventFlag(716)],true,version..' native poster callback reads same placed state') + d.text.SET='H:{RAM:wStringBuffer3}{DONE}' + state.bed=0;local _,fresh=D.apply(state,42) + T.same(fresh,{'H:PATCHED BED'},version..' text edits after binding are live') + d._portableScriptText={SET={}};state.bed=0 + local _,masked=D.apply(state,42) + T.same(masked,{},version..' explicitly removed script text does not fall back') + done(r) + T.eq(D.attributes(42),nil,version..' teardown retires data binding') + T.eq(events:set(900,false),false,version..' retained numeric adapter cannot mutate after unload') + T.eq(save.flags[F.eventFlag(900)],true,version..' stale adapter leaves prior save intact') + local nextData=fixture();nextData.map_scripts.decorationInfo.defaults={bed=42} + local clean=load(nextData) + T.same(D.state({}),{bed=42},version..' next native dataset supplies its own defaults') + T.eq(D.attributes(201),nil,version..' old mod addition does not leak into new dataset') + T.eq(events:set(900,false),false,version..' old adapter cannot become live after rebinding') + V.set('red');V.set(version) + T.eq(D.attributes(42),nil,version..' edition switch and return cannot revive stale binding') + done(clean) + local reset=load(d) + T.eq(d.map_scripts.decorations,pristine,version..' reload preserves real registry rollback identity') + T.eq(D.attributes(41),pristine[41],version..' removed record returns only when native registry restores it') + done(reset) +end +V.set('crystal') +local unsupported={map_scripts={decorations={[2]={name='NOT IMPORTED'}}}} +T.eq(D.useRegistry(unsupported),false,'partial cache does not enable legacy native facade') +T.eq(D.state({}),nil,'missing native defaults do not invent retail bedroom') +T.same(D.CATEGORIES,{},'unbound catalogue has no copied retail categories') +V.set(original) +T.finish('native Gen2 decoration facade') From c5a997a8acc1c83a8cee29a984a6f35622e4924d Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:13:28 +0100 Subject: [PATCH 50/67] compat(M5): add per-mod decoded script callback profiles --- docs/decoded-script-callbacks.md | 81 ++++++ src/mods/DecodedCallbacks.lua | 312 +++++++++++++++++++++++ src/mods/Loader.lua | 13 +- src/script/Commands.lua | 3 +- src/script/DecodedScript.lua | 142 +++++++++++ src/script/Gen2ScriptVM.lua | 31 ++- src/script/Gen3ScriptVM.lua | 33 ++- src/script/ScriptRunner.lua | 61 ++++- tests/engine/decoded_script_profile.lua | 315 ++++++++++++++++++++++++ tests/fixtures/upstream_mods/worker.lua | 30 ++- tools/check_decoded_profiles.py | 59 +++++ tools/decoded-profile-check/conf.lua | 7 + tools/decoded-profile-check/main.lua | 100 ++++++++ 13 files changed, 1168 insertions(+), 19 deletions(-) create mode 100644 docs/decoded-script-callbacks.md create mode 100644 src/mods/DecodedCallbacks.lua create mode 100644 src/script/DecodedScript.lua create mode 100644 tests/engine/decoded_script_profile.lua create mode 100644 tools/check_decoded_profiles.py create mode 100644 tools/decoded-profile-check/conf.lua create mode 100644 tools/decoded-profile-check/main.lua diff --git a/docs/decoded-script-callbacks.md b/docs/decoded-script-callbacks.md new file mode 100644 index 000000000..a0a026ff7 --- /dev/null +++ b/docs/decoded-script-callbacks.md @@ -0,0 +1,81 @@ +# Per-mod decoded script callbacks (M5) + +The host can select a callback profile without editing a mod's manifest or Lua +files. Native callbacks remain the default. For example, the normal options +record can include: + +```lua +modCallbackProfiles = { + example_mew_starter = "decoded", +} +``` + +`Loader.new({ callbackProfiles = { example_mew_starter = "decoded" } })` is +an alternative for an embedding host. Select `"native"` or remove the entry to +roll back. The profile applies to Gen 2/3 and requires the mod's native callback +format. Unified callback records remain a separate existing choice. + +The selected mod registers `script.command` on a private, loader-owned bus. +Native subscribers retain their existing bus, names, operand arrays, ordering +and context. The original compiled script table remains identical until a mod +actually changes a decoded operation. Compiler provenance and tracing stay in +private side tables; no scratch records are placed in public script rows. + +The profile presents `(next, ctx, op, row)` for Gen 3 and +`(next, ctx, op, args, cmd)` for Gen 2. Numeric operand slots and the supported +named aliases describe the source operation, before native lowering. A single +source operation may have zero, one or several native rows. `next` runs that +whole native group; code after `next` observes its native effects. Rewriting an +opcode or operand recompiles a private overlay using the same native compiler. +Calls keep their active return addresses. Each loop visit offers the original +source operation again, so a previous rewrite or veto cannot rewrite the +imported source. Vetoes also remove compile-time text state. A retained `next` +cannot replay a completed operation. + +Gen-2 numeric `b/w/m` layouts in the stock editions support lossless raw byte +edits. Normalized pointers, rich menu/queue records and hack-specific operand +layouts do not have a lossless raw byte reconstruction here. Use supported +decoded fields; unsupported byte edits fail explicitly. This does not add +missing native opcode implementations or a separate foreign VM. Manually +replaced compiled row lists cannot be paired with stale source provenance and +are explicitly declined by the decoded profile. Hand-authored ordinary runner +lists keep their native callbacks. + +`pokemon.before_give` uses its existing event call site once. Only the selected +listener receives a separate view with symbolic species, `speciesId` when the +native species order provides it, and its profile context. Species changes +(including numeric IDs), bounded level changes and nicknames commit to the +native gift. A variable-backed Gen-3 gift also retains the edited species in +that native variable. Existing native listeners receive their original record. +Priorities, unsubscribe, once and failed-entry cleanup use the existing buses. + +The current context translation has one explicit map alias: the verified +FireRed/LeafGreen native Oak's Lab definition (group 4, number 3) appears as +`OAKS_LAB`. The definition must belong to that live map registry; conflicting +slot definitions decline translation. Other maps retain their existing IDs. +Shared map objects, storage keys and saves are never renamed. Broader map-name +compatibility is a separate existing integration. + +## Qualification + +The authenticated, unchanged starter package at pinned reference `535a5ec` runs +through the production Loader, compiler, runner, gift event and Pokémon +constructor over owned synthetic data. Its Gen-3 hook changes variable `0x4002` +to 151 and creates exactly one level-20 Mew named `HOGHEAD`; native map identity +remains `MAP_G04_N03`. This is a runtime contract check, not cartridge gameplay +acceptance. The fixture authentication manifest and mod package bytes are +unchanged. + +The focused suite also covers native command order and script identity, +post-next effects, multi-row calls, opcode replacements, return remapping, +zero-row commands, vetoed text state, repeated visits, Gen-2 raw/named operands, +retained callbacks, once/rollback cleanup and native gift record identity. + +`tools/check_decoded_profiles.py` runs an owned LÖVE window, the actual Loader +profile and the real ScriptRunner. It compares a pre-change runner, the current +native path and the selected decoded path over 64 writes plus an end per frame, +with 120 warm-up and 120 measured frames per phase. F11 reports +`script.exec (64 writes + end)` and each hook's own time. This deliberately +stresses script dispatch every frame; it does not measure a world/battle scene, +GPU improvements or handheld performance. The enabled translation has a cost; +no general FPS improvement is claimed. See the PR for fingerprinted results. diff --git a/src/mods/DecodedCallbacks.lua b/src/mods/DecodedCallbacks.lua new file mode 100644 index 000000000..c2b56f8bd --- /dev/null +++ b/src/mods/DecodedCallbacks.lua @@ -0,0 +1,312 @@ +-- Original contribution by Roxas2712. See LICENSE-ROXAS2712.md. +-- Selected by the host per mod; imported mod bytes and native buses stay intact. +local M = {} +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local fields = { + setvar = { "var", "value" }, + addvar = { "var", "value" }, + subvar = { "var", "value" }, + copyvar = { "var", "src" }, + setorcopyvar = { "var", "value" }, + compare_var_to_value = { "var", "value" }, + loadword = { "dest", "value" }, + setflag = { "flag" }, + clearflag = { "flag" }, + checkflag = { "flag" }, + callstd = { "id" }, + jumpstd = { "id" }, + gotostd = { "std" }, + ["goto"] = { "target" }, + call = { "target" }, + goto_if = { "cond", "target" }, + call_if = { "cond", "target" }, + setval = { "value" }, + addval = { "value" }, + writebyte = { "value" }, + setevent = { "event" }, + clearevent = { "event" }, + checkevent = { "event" }, + scall = { "script" }, + farscall = { "script" }, + sjump = { "script" }, + farsjump = { "script" }, + iftrue = { "script" }, + iffalse = { "script" }, + ifequal = { "value", "script" }, + ifnotequal = { "value", "script" }, + ifgreater = { "value", "script" }, + ifless = { "value", "script" }, + writetext = { "text" }, + jumptext = { "text" }, + farwritetext = { "text" }, + givepoke = { "species", "level", "item", "trainer", "name", "otName" }, + giveegg = { "species", "level" }, + givemon = { "species", "level", "item" }, + sdefer = { "script" }, + stopandsjump = { "script" }, + jumptextfaceplayer = { "text" }, + farjumptext = { "text" }, + pause = { "frames" }, + setscene = { "scene" }, + setmapscene = { "group", "map", "scene" }, + turnobject = { "object", "facing" }, + applymovement = { "object", "movement" }, + applymovementlasttalked = { "movement" }, + special = { "id" }, + playmusic = { "id" }, + playsound = { "id" }, + cry = { "id" }, + getmonname = { "species", "buffer" }, + getitemname = { "item", "buffer" }, + gettrainername = { "group", "trainer", "buffer" }, + loadtrainer = { "class", "member" }, + loadwildmon = { "species", "level" }, + winlosstext = { "winText", "lossText" }, + trainertext = { "index" }, + trainerflagaction = { "action" }, + setlasttalked = { "object" }, + showemote = { "emote", "object", "frames" }, + giveitem = { "item", "quantity" }, + verbosegiveitem = { "item", "quantity" }, + addcellnum = { "phone" }, + delcellnum = { "phone" }, + checkcellnum = { "phone" }, + readvar = { "var" }, + writevar = { "var" }, + follow = { "a", "b" }, + faceobject = { "a", "b" }, + follownotexact = { "a", "b" }, + trade = { "trade" }, + describedecoration = { "decoration" }, +} +local function copy(t) + local out = {} + for k, v in pairs(t) do + out[k] = v + end + return out +end +-- Only lossless numeric byte layouts can be reconstructed from native IR. +-- Decoded pointers/text use named fields; normalized hack layouts are not bytes. +local function rawOperands(op, ir) + local version = require("src.core.GameVersion").get() + if version ~= "gold" and version ~= "silver" and version ~= "crystal" then + return {}, nil + end + local spec + for _, command in ipairs(require("src.import.Gen2ScriptOps").commandsFor(version)) do + if command[1] == op then + spec = command[2] + break + end + end + if not spec or spec:find("[^bwm]") or #spec ~= #ir - 1 then + return {}, nil + end + local out = {} + for i = 1, #spec do + local value, width = ir[i + 1], ({ b = 1, w = 2, m = 3 })[spec:sub(i, i)] + if type(value) ~= "number" or value % 1 ~= 0 or value < 0 or value >= 256 ^ width then + return {}, nil + end + for _ = 1, width do + out[#out + 1] = value % 256 + value = math.floor(value / 256) + end + end + return out, spec +end +local function context(ctx) + local world, map = ctx.overworld, ctx.overworld and ctx.overworld.map + if not map then + return ctx + end + local data = ctx.game and ctx.game.data + local maps = data and data.maps + local version = require("src.core.GameVersion").get() + local id = map.id + if maps and (version == "firered" or version == "leafgreen") then + local def = maps[map.id] + local verified = def and map.def == def and def.group == 4 and def.number == 3 + if verified then + for _, other in pairs(maps) do + if type(other) == "table" and other ~= def and other.group == 4 and other.number == 3 then + verified = false + break + end + end + end + -- One explicit callback alias, qualified against this world's native + -- definition. Similar spellings or an unbound slot are not evidence. + if verified then + id = "OAKS_LAB" + end + end + local publicMap = setmetatable({ id = id }, { __index = map }) + local publicWorld = setmetatable({ map = publicMap }, { + __index = world, + __newindex = function(_, k, v) + world[k] = v + end, + }) + return setmetatable({ overworld = publicWorld }, { + __index = ctx, + __newindex = function(_, k, v) + ctx[k] = v + end, + }) +end +function M.install(loader, api, owner, generation) + if generation ~= 2 and generation ~= 3 then + return + end + local aliases = copy(fields) + if generation == 3 then + aliases.callstd = { "std" } + aliases.special = { "id" } + aliases.applymovement = { "localId", "movement" } + end + local wrap, on, once = api.hooks.wrap, api.events.on, api.events.once + local ids = require("src.mods.UnifiedIds").new(loader) + ids:entries("pokemon") + api.hooks.wrap = function(self, name, callback, priority) + if name ~= "script.command" then + return wrap(self, name, callback, priority) + end + return loader._decodedHooks:wrap(name, function(nextFn, ctx, op, ir) + local row = { op = op } + for i = 2, #ir do + row[i - 1] = ir[i] + end + for i, field in ipairs(aliases[op] or {}) do + row[field] = row[i] + end + local original = copy(row) + local public = context(ctx) + local args, byteSpec = {}, nil + if generation == 2 then + args, byteSpec = rawOperands(op, ir) + row.args = args + end + local originalArgs = copy(args) + local active = true + local function nextPublic(hctx, hname, hrow, hcmd) + assert(active, "decoded next is only valid during its callback") + assert(hctx == nil or hctx == public, "decoded next must retain its script context") + local edited = generation == 2 and (hcmd or row) or (hrow or row) + local raw = copy(ir) + for i = 1, math.max(#row, #edited) do + raw[i + 1] = edited[i] + end + for i, field in ipairs(aliases[op] or {}) do + if edited[field] ~= original[field] then + raw[i + 1] = edited[field] + end + end + if generation == 2 then + local bytes = hrow or edited.args or args + local changed = #bytes ~= #originalArgs + for k, v in pairs(bytes) do + if originalArgs[k] ~= v then + changed = true + end + end + if changed then + assert( + byteSpec, + "raw Gen-2 byte rewrite is unavailable for this normalized command; use decoded fields" + ) + assert(#bytes == #originalArgs, "Gen-2 operand byte width changed") + local at = 1 + for i = 1, #byteSpec do + local value, width = 0, ({ b = 1, w = 2, m = 3 })[byteSpec:sub(i, i)] + for n = 0, width - 1 do + local byte = bytes[at] + at = at + 1 + assert( + type(byte) == "number" and byte % 1 == 0 and byte >= 0 and byte < 256, + "invalid Gen-2 operand byte" + ) + value = value + byte * 256 ^ n + end + raw[i + 1] = value + end + end + end + raw[1] = hname or edited.op or op + return nextFn(ctx, raw[1], raw) + end + local result + if generation == 2 then + result = pack(pcall(callback, nextPublic, public, op, args, row)) + else + result = pack(pcall(callback, nextPublic, public, op, row)) + end + active = false + if not result[1] then + error(result[2], 0) + end + return unpack(result, 2, result.n) + end, priority, owner) + end + local function subscribe(register, self, name, callback, priority) + if name ~= "pokemon.before_give" then + return register(self, name, callback, priority) + end + return register(self, name, function(gift) + local view = copy(gift) + view.ctx = context(gift.ctx) + view.species = ids:read("pokemon", gift.species) + local order = gift.ctx.game and gift.ctx.game.data.constants and gift.ctx.game.data.constants.speciesOrder + or {} + local function numberFor(species) + for number, id in pairs(order) do + if id == species then + return number + end + end + end + view.speciesId = numberFor(gift.species) + local before = view.species + callback(view) + local species = gift.species + if view.species ~= before then + if type(view.species) == "number" then + species = assert(order[view.species], "unknown decoded gift species number") + else + species = assert(ids:resolve("pokemon", view.species)).key + end + end + local level = tonumber(view.level) + level = level and math.max(1, math.min(100, math.floor(level))) or gift.level + local nickname = type(view.nickname) == "string" and view.nickname ~= "" and view.nickname or gift.nickname + -- A variable-backed Gen-3 gift keeps the requested species in that same + -- native variable for the rest of the starter/story script. + local ctx, number = gift.ctx, numberFor(species) + local command = ctx.script and ctx.script[ctx.pc] + if + generation == 3 + and species ~= gift.species + and number + and command + and command[1] == "g3_give_pokemon" + and type(command[2]) == "number" + and command[2] >= 0x4000 + then + ctx.save.gen3Vars = ctx.save.gen3Vars or {} + ctx.save.gen3Vars[command[2]] = number + end + gift.species, gift.level, gift.nickname = species, level, nickname + end, priority) + end + api.events.on = function(self, ...) + return subscribe(on, self, ...) + end + api.events.once = function(self, ...) + return subscribe(once, self, ...) + end +end +return M diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index ba46218d0..87cbe1c41 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -156,7 +156,8 @@ function Loader.new(opts) end local self = setmetatable({ mods = {}, loaded = {}, errors = {}, initialized = false, - events = Events.new(), hooks = Hooks.new(), content = {}, assets = {}, + events = Events.new(), hooks = Hooks.new(), _decodedHooks = Hooks.new(), content = {}, assets = {}, + _callbackProfiles = opts and opts.callbackProfiles, exports = {}, apis = {}, migrations = {}, order = {}, modSave = {}, modOptions = {}, optionSchemas = {}, optionStatus = {}, imageCache = {}, @@ -200,6 +201,7 @@ function Loader:_loadState() end -- mod.options reads through this; M11 owns writing it back self.modOptions = options.modOptions or {} + self._callbackProfiles = self._callbackProfiles or options.modCallbackProfiles or {} -- Migrate the original prototype manager's separate state file into the -- normal persistent options file once. New Game never resets options. if next(options.mods or {}) == nil and self.fs.getInfo @@ -1010,6 +1012,13 @@ function Loader:_api(mod) }, } self.exports[modId] = api.exports + local callbackProfile = self._callbackProfiles and self._callbackProfiles[modId] + assert(callbackProfile == nil or callbackProfile == "native" or callbackProfile == "decoded", + "unknown callback profile for " .. modId) + if callbackProfile == "decoded" then + assert(api.callbackFormat == "native", "decoded profile cannot be combined with unified callbacks") + engineRequire("src.mods.DecodedCallbacks").install(loader, api, modId, hostGen) + end if api.callbackFormat == "unified" then engineRequire("src.mods.UnifiedCallbacks").install(loader, api, modId, hostGen) end @@ -1268,6 +1277,7 @@ function Loader:_rollback(modId) end self.events:removeOwner(modId) self.hooks:removeOwner(modId) + self._decodedHooks:removeOwner(modId) self.exports[modId] = nil self.optionSchemas[modId] = nil self.optionStatus[modId] = nil @@ -1518,6 +1528,7 @@ function Loader:load(data) end) -- engine call sites reach these buses -- and this error feed, for failures -- that only surface at play time -- through Runtime from here on + self.hooks._decodedHooks = self._decodedHooks Runtime.install(self.events, self.hooks, self.errors) self:_validate() local ordered = self:_resolve() diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 53cef91fa..6fc66e4ef 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -1285,7 +1285,8 @@ function Commands.give_pokemon(ctx, species, level, skipNickname, opts) -- This is intentionally an event rather than a special-case starter hook: -- mods can use the same seam for story gifts, fossils, or custom scripts. local gift = { ctx = ctx, species = species, level = level } - if ctx.game.mods then + local giftEvents = ctx.game.mods and ctx.game.mods.events + if giftEvents and (not giftEvents.listeners or giftEvents.listeners["pokemon.before_give"]) then ctx.game.mods.events:emit("pokemon.before_give", gift) species, level = gift.species, gift.level end diff --git a/src/script/DecodedScript.lua b/src/script/DecodedScript.lua new file mode 100644 index 000000000..b9ef825e8 --- /dev/null +++ b/src/script/DecodedScript.lua @@ -0,0 +1,142 @@ +-- Original contribution by Roxas2712. See LICENSE-ROXAS2712.md. +-- Compiler provenance lives outside public command rows and engine contexts. +local M = {} +local origins = setmetatable({}, { __mode = "k" }) + +function M.origin(rows, data, entry, compile, generation) + origins[rows] = { data = data, entry = entry, compile = compile, generation = generation } +end +function M.trace() + return { commands = {}, byKey = {}, owners = {}, labels = {}, returns = {}, returnKeys = {} } +end +function M.begin(t, label, index, ir) + local c = { key = label .. ":" .. index, label = label, index = index, ir = ir, returns = 0 } + t.commands[#t.commands + 1], t.byKey[c.key], t.current = c, c, c +end +function M.emit(t, row) + t.owners[row] = t.current +end +function M.returnLabel(t, label) + local c = t.current + if not c then + return + end + c.returns = c.returns + 1 + local key = c.key .. ":return:" .. c.returns + t.returns[label], t.returnKeys[key] = key, label +end +function M.finish(rows, t) + local positions = {} + for pc, row in ipairs(rows) do + positions[row] = pc + local c = t.owners[row] + if c and not c.pc then + c.pc = pc + end + end + t.starts = {} + local nextPC + for i = #t.commands, 1, -1 do + local c = t.commands[i] + local finish = assert(positions[t.labels[c.label]], "missing decoded script boundary") + if not nextPC or c.label ~= t.commands[i + 1].label then + nextPC = finish + end + c.after = nextPC + c.pc = c.pc or nextPC + nextPC = c.pc + end + for _, c in ipairs(t.commands) do + t.starts[c.pc] = t.starts[c.pc] or {} + t.starts[c.pc][#t.starts[c.pc] + 1] = c + end +end +function M.prepare(rows) + local origin = origins[rows] + if not origin then + return + end + local trace = M.trace() + local result = origin.compile(origin.data, origin.entry, { trace = trace }) + assert(M.same(result, rows), "decoded profile requires an unmodified native compiled script") + return rows, trace, origin +end +-- Recompile a private overlay; never change imported IR or the ordinary cache. +function M.rewrite(origin, oldTrace, overrides, command, ir, ctx) + overrides[command.label] = overrides[command.label] or {} + overrides[command.label][command.index] = ir + local scripts = {} + for label, rows in pairs(origin.data.map_scripts.scripts) do + if overrides[label] then + local copy = {} + for i, row in ipairs(rows) do + copy[i] = overrides[label][i] or row + end + scripts[label] = copy + else + scripts[label] = rows + end + end + local data, pool = {}, {} + for k, v in pairs(origin.data) do + data[k] = v + end + for k, v in pairs(origin.data.map_scripts) do + pool[k] = v + end + pool.scripts, data.map_scripts = scripts, pool + local trace = M.trace() + local rows = origin.compile(data, origin.entry, { trace = trace, keepLabels = oldTrace.labels }) + for _, field in ipairs({ "g2Stack", "g3Stack" }) do + for i, label in ipairs(ctx[field] or {}) do + local key = oldTrace.returns[label] + if key then + ctx[field][i] = assert(trace.returnKeys[key], "rewritten command removed an active return") + end + end + end + return rows, trace, assert(trace.byKey[command.key]) +end +function M.copy(value, seen) + if type(value) ~= "table" then + return value + end + seen = seen or {} + if seen[value] then + return seen[value] + end + local out = {} + seen[value] = out + for k, v in pairs(value) do + out[k] = M.copy(v, seen) + end + return out +end +function M.same(a, b, seen) + if a == b then + return true + end + if type(a) ~= "table" or type(b) ~= "table" then + return false + end + seen = seen or {} + if seen[a] then + return seen[a] == b + end + seen[a] = b + for k, v in pairs(a) do + if not M.same(v, b[k], seen) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end +function M.source(origin, command) + return origin.data.map_scripts.scripts[command.label][command.index] +end +return M diff --git a/src/script/Gen2ScriptVM.lua b/src/script/Gen2ScriptVM.lua index 0d5c51cfe..5c514f6f5 100644 --- a/src/script/Gen2ScriptVM.lua +++ b/src/script/Gen2ScriptVM.lua @@ -23,6 +23,7 @@ -- behind the hand-ported data/scripts/ modules, which still win outright. local Logger = require("src.core.Logger") +local Decoded = require("src.script.DecodedScript") local MapScripts = require("src.script.MapScripts") local Gen2Flags = require("src.script.Gen2Flags") require("src.script.Gen2Commands") @@ -75,7 +76,10 @@ local function itemId(n) return string.format("ITEM_%03d", n) end local L = {} -local function emit(s, row) s.out[#s.out + 1] = row end +local function emit(s, row) + s.out[#s.out + 1] = row + if s._decoded then Decoded.emit(s._decoded, row) end +end -- A pointer that fell outside the ROM, or a far script that failed to -- disassemble, is stored as "" by the extractor. Branches to one lower to @@ -2365,7 +2369,7 @@ function Gen2ScriptVM.lowered(op) return L[op] ~= nil end -function Gen2ScriptVM.compile(data, entry) +function Gen2ScriptVM.compile(data, entry, options) local pool = store(data) local scripts = pool and pool.scripts if not (scripts and type(entry) == "string" and scripts[entry]) then return nil end @@ -2373,12 +2377,13 @@ function Gen2ScriptVM.compile(data, entry) local key = entry compiled[scripts] = compiled[scripts] or {} local hit = compiled[scripts][key] - if hit ~= nil then return hit or nil end + if not options and hit ~= nil then return hit or nil end local out, queued, order = {}, { [entry] = true }, { entry } local counter = 0 local state = { out = out, + _decoded = options and options.trace, stds = pool.stds, has = function(label) return type(label) == "string" and scripts[label] ~= nil @@ -2391,14 +2396,20 @@ function Gen2ScriptVM.compile(data, entry) end, newLabel = function() counter = counter + 1 - return string.format("%s_r%d", entry, counter) + local label = string.format("%s_r%d", entry, counter) + if options then Decoded.returnLabel(options.trace, label) end + return label end, } + if options and options.keepLabels then + for label in pairs(options.keepLabels) do state.want(label) end + end local index = 1 while index <= #order do local label = order[index] index = index + 1 + if options then options.trace.current = nil end emit(state, { "label", label }) state.lastText = nil state.lastTextRow = nil @@ -2423,6 +2434,7 @@ function Gen2ScriptVM.compile(data, entry) local sifStack, guardLabel = {}, nil local rows = scripts[label] or {} for irIndex, ir in ipairs(rows) do + if options then Decoded.begin(options.trace, label, irIndex, ir) end local op = ir[1] if SIF_OPS[op] then local after = state.newLabel() @@ -2488,7 +2500,9 @@ function Gen2ScriptVM.compile(data, entry) for i = #sifStack, 1, -1 do emit(state, { "label", sifStack[i] }) end if guardLabel then emit(state, { "label", guardLabel }) end -- a script that ran off the end of its own bytecode still has to unwind - emit(state, { "g2_return" }) + local finish = { "g2_return" } + if options then options.trace.current = nil; options.trace.labels[label] = finish end + emit(state, finish) end -- A script that lowers to nothing is why an NPC can be walked up to, @@ -2498,7 +2512,12 @@ function Gen2ScriptVM.compile(data, entry) if #out == 0 then Logger.warn("gen2 script vm: '%s' compiled to zero rows (silent NPC)", key) end - compiled[scripts][key] = #out > 0 and out or false + if options then + Decoded.finish(out, options.trace) + else + Decoded.origin(out, data, entry, Gen2ScriptVM.compile, 2) + compiled[scripts][key] = #out > 0 and out or false + end return #out > 0 and out or nil end diff --git a/src/script/Gen3ScriptVM.lua b/src/script/Gen3ScriptVM.lua index d5c709496..efc956bd9 100644 --- a/src/script/Gen3ScriptVM.lua +++ b/src/script/Gen3ScriptVM.lua @@ -36,6 +36,7 @@ -- mis-frame most of the dialogue in Hoenn. local Logger = require("src.core.Logger") +local Decoded = require("src.script.DecodedScript") local MapScripts = require("src.script.MapScripts") require("src.script.Gen3Commands") @@ -80,7 +81,10 @@ Gen3ScriptVM.flagName = flagName local L = {} -local function emit(s, row) s.out[#s.out + 1] = row end +local function emit(s, row) + s.out[#s.out + 1] = row + if s._decoded then Decoded.emit(s._decoded, row) end +end local droppedBranch = {} @@ -803,19 +807,20 @@ end Gen3ScriptVM.store = store -function Gen3ScriptVM.compile(data, entry) +function Gen3ScriptVM.compile(data, entry, options) local pool = store(data) local scripts = pool and pool.scripts if not (scripts and type(entry) == "string" and scripts[entry]) then return nil end compiled[scripts] = compiled[scripts] or {} local hit = compiled[scripts][entry] - if hit ~= nil then return hit or nil end + if not options and hit ~= nil then return hit or nil end local out, queued, order = {}, { [entry] = true }, { entry } local counter = 0 local state = { out = out, + _decoded = options and options.trace, has = function(label) return type(label) == "string" and scripts[label] ~= nil end, @@ -827,30 +832,44 @@ function Gen3ScriptVM.compile(data, entry) end, newLabel = function() counter = counter + 1 - return ("%s_r%d"):format(entry, counter) + local label = ("%s_r%d"):format(entry, counter) + if options then Decoded.returnLabel(options.trace, label) end + return label end, } + if options and options.keepLabels then + for label in pairs(options.keepLabels) do state.want(label) end + end local index = 1 while index <= #order do local label = order[index] index = index + 1 + if options then options.trace.current = nil end emit(state, { "label", label }) -- lastText does NOT survive across script boundaries: a callstd at the -- top of one script must not print the line the previous one loaded state.lastText = nil - for _, ir in ipairs(scripts[label] or {}) do + for irIndex, ir in ipairs(scripts[label] or {}) do + if options then Decoded.begin(options.trace, label, irIndex, ir) end local lower = L[ir[1]] if lower then lower(ir, state) end end -- a script that ran off the end of its own bytecode still has to unwind - emit(state, { "g3_return" }) + local finish = { "g3_return" } + if options then options.trace.current = nil; options.trace.labels[label] = finish end + emit(state, finish) end if #out == 0 then Logger.warn("gen3 script vm: '%s' compiled to zero rows (silent NPC)", entry) end - compiled[scripts][entry] = #out > 0 and out or false + if options then + Decoded.finish(out, options.trace) + else + Decoded.origin(out, data, entry, Gen3ScriptVM.compile, 3) + compiled[scripts][entry] = #out > 0 and out or false + end return #out > 0 and out or nil end diff --git a/src/script/ScriptRunner.lua b/src/script/ScriptRunner.lua index 595ca1fa9..810c159b2 100644 --- a/src/script/ScriptRunner.lua +++ b/src/script/ScriptRunner.lua @@ -175,10 +175,20 @@ end -- an array of rows; control commands return a new program counter, as a -- row number or a label name. function ScriptRunner:exec(script, ctx) + local Decoded, trace, origin + local decodedHooks = Runtime.hooks._decodedHooks + if decodedHooks and decodedHooks.chains["script.command"] then + Decoded = require("src.script.DecodedScript") + local prepared + prepared, trace, origin = Decoded.prepare(script) + script = prepared or script + end local labels = ScriptRunner.scanLabels(script) local data = self.game and self.game.data local pc = 1 - while pc <= #script do + local function step() + if pc > #script then return end + if trace and ctx.scriptCancelled then pc = math.huge; return end local row = script[pc] local name = row[1] -- THE ROW THE RUNNER IS STANDING ON, kept so a script that parks and @@ -273,6 +283,55 @@ function ScriptRunner:exec(script, ctx) end end end + if not trace then + while pc <= #script do step() end + return + end + local overrides, visited = {}, {} + while pc <= #script do + local candidates = trace and trace.starts[pc] + local command + for _, candidate in ipairs(candidates or {}) do + if not visited[candidate.key] then command = candidate; break end + end + if not command then + step() + visited = {} + else + visited[command.key] = true + local positive = command.after > command.pc + local ran, active = false, true + local function vanilla(hctx, op, ir) + assert(active, "decoded next is only valid during its callback") + assert(not ran, "decoded command already executed") + assert(hctx == ctx, "decoded next must retain its native script context") + ran = true + if op ~= command.ir[1] or not Decoded.same(ir, command.ir) then + local copy = {}; for k, v in pairs(ir) do copy[k] = v end; copy[1] = op + script, trace, command = Decoded.rewrite(origin, trace, overrides, command, copy, ctx) + labels = ScriptRunner.scanLabels(script) + pc = command.pc + end + local first, after = command.pc, command.after + positive = after > first + while pc >= first and pc < after and pc <= #script do step() end + end + local copy = Decoded.copy(Decoded.source(origin, command)) + local ok, err = pcall(decodedHooks.call, decodedHooks, "script.command", vanilla, ctx, copy[1], copy) + active = false + if not ok then error(err, 0) end + if not ran then + -- Suppress compile-time state as well (loadword/text fusion), once for + -- this visit. The next visit still offers the original source opcode. + script, trace, command = Decoded.rewrite(origin, trace, overrides, command, { "_decoded_skip" }, ctx) + labels = ScriptRunner.scanLabels(script) + pc = command.after + positive = command.after > command.pc + end + if positive then visited = {} end + end + if ctx.scriptCancelled then return end + end end -- Called by blocking commands from inside the coroutine. diff --git a/tests/engine/decoded_script_profile.lua b/tests/engine/decoded_script_profile.lua new file mode 100644 index 000000000..f12aeab07 --- /dev/null +++ b/tests/engine/decoded_script_profile.lua @@ -0,0 +1,315 @@ +local T = require("tests.harness") +love = require("tests.love_stub") +local Version = require("src.core.GameVersion") +local Hooks = require("src.mods.Hooks") +local Events = require("src.mods.Events") +local Runtime = require("src.mods.Runtime") +local Runner = require("src.script.ScriptRunner") +local Decoded = require("src.script.DecodedScript") +local C2 = require("src.script.Gen2ScriptVM") +local C3 = require("src.script.Gen3ScriptVM") +local function fixture(gen, scripts) + Version.set(gen == 2 and "gold" or "firered") + local data = require("tests.fixtures.upstream_mods.data").fresh() + data.map_scripts = { source = gen == 3 and "RomExtractorGen3" or "RomExtractorGen2", scripts = scripts, maps = {} } + local game = { data = data, save = { flags = {}, party = {}, gen3Vars = {} } } + local world = { map = { id = "NATIVE" } } + local runner = Runner.new(game, world) + local hooks, decoded = Hooks.new(), Hooks.new() + hooks._decodedHooks = decoded + Runtime.install(Events.new(), hooks, {}) + return game, runner, (gen == 2 and C2 or C3).compile(data, "ENTRY"), hooks, decoded +end +for _, gen in ipairs({ 2, 3 }) do + local set, finish = gen == 2 and "setval" or "setvar", gen == 2 and "endall" or "end" + local row = gen == 2 and { set, 4 } or { set, 0x4002, 4 } + local game, runner, rows, native, decoded = fixture(gen, { ENTRY = { row, { finish } } }) + local calls, order, expected = 0, {}, 4 + native:wrap("script.command", function(next, ctx, op, args) + order[#order + 1] = op + T.eq(ctx.script, rows, "native callback retains the original compiled script identity") + return next(ctx, op, args) + end) + runner:exec(rows, runner:makeContext()) + T.eq(calls, 0, "native-only runs never dispatch decoded callbacks") + local baseline = table.concat(order, ",") + order = {} + decoded:wrap("script.command", function(next, ctx, op, ir) + calls = calls + 1 + local result = next(ctx, op, ir) + if op == set then + T.eq( + gen == 2 and ctx.g2Var or ctx.save.gen3Vars[0x4002], + expected, + "post-next observes the native command effects" + ) + end + return result + end) + runner:exec(rows, runner:makeContext()) + T.eq(calls, 2, "one decoded callback per source command") + T.eq(table.concat(order, ","), baseline, "native command order is unchanged with a transparent profile") + local prepared = Decoded.prepare(rows) + T.same(prepared, rows, "transparent compiler trace preserves every native row and argument") + native:removeOwner(nil) -- no changes: test-owned native callback remains installed + local replacement = Hooks.new() + replacement._decodedHooks = decoded + Runtime.install(Events.new(), replacement, {}) + decoded:wrap("script.command", function(next, ctx, op, ir) + if op == set then + local copy = {} + for k, v in pairs(ir) do + copy[k] = v + end + copy[gen == 2 and 2 or 3] = 151 + return next(ctx, op, copy) + end + return next(ctx, op, ir) + end, 100) + expected = 151 + local editedContext = runner:makeContext() + runner:exec(rows, editedContext) + T.eq( + gen == 2 and editedContext.g2Var or game.save.gen3Vars[0x4002], + 151, + "edited decoded operands reach native execution" + ) + T.eq(row[gen == 2 and 2 or 3], 4, "hook rewrites never mutate imported source rows") +end +-- Calls lower to several rows; a wrapper surrounds the complete command once. +local game, runner, rows, native, decoded = fixture(3, { + ENTRY = { { "call", "SUB" }, { "setvar", 0x4010, 7 }, { "end" } }, + SUB = { { "setvar", 0x4011, 8 }, { "return" } }, +}) +local seen = {} +decoded:wrap("script.command", function(next, ctx, op, ir) + seen[#seen + 1] = op + if op == "setvar" and ir[2] == 0x4011 then + return next(ctx, "addvar", { "addvar", 0x4011, 9 }) + end + return next(ctx, op, ir) +end) +runner:exec(rows, runner:makeContext()) +T.same( + seen, + { "call", "setvar", "return", "setvar", "end" }, + "multi-row call, edited opcode and return keep decoded order" +) +T.eq(game.save.gen3Vars[0x4011], 9, "opcode replacements execute their own lowering") +T.eq(game.save.gen3Vars[0x4010], 7, "recompilation retains active call return addresses") +-- Zero-row loadword supplies a later std. A veto skips the entire grouped command. +local game2, run2, rows2, _, dec2 = + fixture(3, { ENTRY = { + { "nop" }, + { "loadword", 0, "TEXT_A" }, + { "setvar", 0x4020, 3 }, + { "end" }, + } }) +local count = 0 +dec2:wrap("script.command", function(next, ctx, op, ir) + count = count + 1 + if op == "setvar" then + return + end + if op == "loadword" then + ir[3] = "TEXT_B" + end + return next(ctx, op, ir) +end) +run2:exec(rows2, run2:makeContext()) +T.eq(count, 4, "zero-row operations are visible exactly once") +T.eq(game2.save.gen3Vars[0x4020], nil, "veto skips native effects") +-- The actual Loader routes only the explicitly selected mod onto the decoded bus. +local SDK = require("tests.modkit.sdk") +local Json = require("src.link.Json") +local Loader = require("src.mods.Loader") +Version.set("firered") +local fs = SDK.memfs({ + ["options.lua"] = 'return {mods={probe=true},modCallbackProfiles={probe="decoded"}}', + ["mods/probe/manifest.json"] = Json.encode({ id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua" }), + ["mods/probe/main.lua"] = [[return function(mod) + mod.hooks:wrap('script.command',function(next,ctx,op,row) + if op=='setvar' then row.value=99 end + return next(ctx,op,row) + end) + mod.events:on('pokemon.before_give',function(gift) + mod.exports.map=gift.ctx.overworld.map.id + gift.level=20;gift.nickname='PROFILE' + end) + end]], +}) +local data = require("tests.fixtures.upstream_mods.data").fresh() +data.maps.MAP_G04_N03 = { id = "MAP_G04_N03", group = 4, number = 3 } +local loader = Loader.new({ fs = fs, dev = false }) +T.eq(loader:load(data), true, "actual Loader accepts an external per-mod profile") +T.eq(loader.hooks.chains["script.command"], nil, "selected hook does not alter the native chain") +T.check(loader._decodedHooks.chains["script.command"] ~= nil, "selected hook registers on its private bus") +data.map_scripts = + { source = "RomExtractorGen3", scripts = { ENTRY = { { "setvar", 0x4002, 4 }, { "end" } } }, maps = {} } +local g = { data = data, save = { flags = {}, party = {}, gen3Vars = {} }, mods = loader } +local world = { map = { id = "MAP_G04_N03", def = data.maps.MAP_G04_N03 } } +local r = Runner.new(g, world) +r:exec(C3.compile(data, "ENTRY"), r:makeContext()) +T.eq(g.save.gen3Vars[0x4002], 99, "named row edit travels through Loader and compiler to native state") +local gift = { ctx = r:makeContext(), species = "CHARMANDER", level = 5 } +loader.events:emit("pokemon.before_give", gift) +T.eq(loader.exports.probe.map, "OAKS_LAB", "profile translates the verified native lab slot") +T.eq(world.map.id, "MAP_G04_N03", "native map identity is never renamed") +T.eq(gift.level, 20, "profile gift edits reach the existing gift event") +T.eq(gift.nickname, "PROFILE", "profile nickname edits commit") +loader:_rollback("probe") +T.eq(loader._decodedHooks.chains["script.command"], nil, "rollback retires the per-mod decoded registration") + +-- Every visit starts from original IR, even when an earlier visit rewrote it. +do + local g, r, program, _, bus = fixture(3, { + ENTRY = { { "setvar", 0x4030, 0 }, { "goto", "LOOP" } }, + LOOP = { + { "addvar", 0x4030, 1 }, + { "setvar", 0x4031, 4 }, + { "compare_var_to_value", 0x4030, 3 }, + { "goto_if", 0, "LOOP" }, + { "end" }, + }, + }) + local values = {} + bus:wrap("script.command", function(next, ctx, op, ir) + if op == "setvar" and ir[2] == 0x4031 then + values[#values + 1] = ir[3] + if #values == 1 then + ir[3] = 151 + elseif #values == 2 then + return + end + end + return next(ctx, op, ir) + end) + r:exec(program, r:makeContext()) + T.same(values, { 4, 4, 4 }, "loops offer original source operands after both rewrites and vetoes") + T.eq(g.save.gen3Vars[0x4030], 3, "rewritten loop retains its branch and completion") + T.eq(g.save.gen3Vars[0x4031], 4, "transparent later visit restores the original command") +end +-- A vetoed compile-time text load must not leak into a later callstd. +do + local g, r, program, _, bus = fixture(3, { ENTRY = { { "loadword", 0, "TEXT_A" }, { "callstd", 4 }, { "end" } } }) + local shown = 0 + local Commands = require("src.script.Commands") + local old = Commands.g3_show_text + Commands.g3_show_text = function() + shown = shown + 1 + end + bus:wrap("script.command", function(next, ctx, op, ir) + if op == "loadword" then + return + end + return next(ctx, op, ir) + end) + r:exec(program, r:makeContext()) + Commands.g3_show_text = old + T.eq(shown, 0, "veto removes compile-time text state from subsequent lowering") +end +-- Gen-2 selected profile keeps the fourth decoded row and lossless byte edits. +do + Version.set("gold") + local files = { + ["options.lua"] = "return {mods={probe=true}}", + ["mods/probe/manifest.json"] = Json.encode({ id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua" }), + ["mods/probe/main.lua"] = [=[return function(mod) + mod.hooks:wrap('script.command',function(next,ctx,op,args,cmd) + if op=='setval' then + assert(cmd.op=='setval' and cmd.value==4 and args[1]==4) + args[1]=77 + mod.exports.retained=next + end + return next(ctx,op,args,cmd) + end) + mod.events:once('pokemon.before_give',function(gift)mod.exports.gifts=(mod.exports.gifts or 0)+1 end) + end]=], + } + local d = require("tests.fixtures.upstream_mods.data").fresh() + local l = Loader.new({ fs = SDK.memfs(files), dev = false, callbackProfiles = { probe = "decoded" } }) + T.eq(l:load(d), true, "constructor profile works without editing a manifest or options file") + d.map_scripts = { source = "RomExtractorGen2", scripts = { ENTRY = { { "setval", 4 }, { "endall" } } }, maps = {} } + local g = { data = d, save = { flags = {}, party = {} } } + local r = Runner.new(g, {}) + local ctx = r:makeContext() + r:exec(C2.compile(d, "ENTRY"), ctx) + T.eq(ctx.g2Var, 77, "Gen-2 raw byte edits are decoded before native lowering") + T.raises(function() + l.exports.probe.retained() + end, "only valid during", "retained next cannot replay a completed command") + l.events:emit("pokemon.before_give", { ctx = ctx, species = "CHARMANDER", level = 5 }) + l.events:emit("pokemon.before_give", { ctx = ctx, species = "CHARMANDER", level = 5 }) + T.eq(l.exports.probe.gifts, 1, "profiled once subscription retires on the original native event bus") +end +-- Deep source operands never escape into a retained callback record. +do + local g, r, program, _, bus = fixture(3, { ENTRY = { { "nop", { nested = 1 } }, { "end" } } }) + bus:wrap("script.command", function(next, ctx, op, ir) + if op == "nop" then + ir[2].nested = 2 + end + return next(ctx, op, ir) + end) + r:exec(program, r:makeContext()) + T.eq(g.data.map_scripts.scripts.ENTRY[1][2].nested, 1, "nested source operands remain unchanged") +end + +-- Gift records include the cartridge species number without changing native +-- event records; numeric edits and variable-backed gifts resolve through data. +do + Version.set("firered") + local d = require("tests.fixtures.upstream_mods.data").fresh() + d.constants.speciesOrder = { [4] = "CHARMANDER", [151] = "MEW" } + local files = { + ["options.lua"] = 'return {mods={probe=true},modCallbackProfiles={probe="decoded"}}', + ["mods/probe/manifest.json"] = Json.encode({ id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua" }), + ["mods/probe/main.lua"] = [=[return function(mod) + mod.events:on('pokemon.before_give',function(gift) + assert(gift.speciesId==4) + gift.species=151;gift.level=120;gift.nickname='NUMERIC' + end) + end]=], + } + local l = Loader.new({ fs = SDK.memfs(files), dev = false }) + assert(l:load(d)) + local g = { data = d, save = { gen3Vars = { [0x4002] = 4 } } } + local ctx = { game = g, save = g.save, script = { { "g3_give_pokemon", 0x4002, 5, 0 } }, pc = 1 } + local gift = { ctx = ctx, species = "CHARMANDER", level = 5 } + local nativeSeen + l.events:on("pokemon.before_give", function(row) + nativeSeen = row + end, -100, "native") + l.events:emit("pokemon.before_give", gift) + T.eq(gift.species, "MEW", "numeric profiled gift species resolves through this dataset") + T.eq(gift.level, 100, "decoded gift level stays in the supported range") + T.eq(g.save.gen3Vars[0x4002], 151, "variable-backed native gift retains the edited species number") + T.eq(nativeSeen, gift, "native listeners retain the exact original gift record") + T.eq(gift.speciesId, nil, "profile-only species metadata never leaks into native event records") +end + +-- Hook and group scopes survive a real runner suspension across updates. +do + local g, r, program, _, bus = fixture(3, { ENTRY = { { "delay", 2 }, { "setvar", 0x4050, 9 }, { "end" } } }) + local phases = {} + bus:wrap("script.command", function(next, ctx, op, ir) + if op == "delay" then + phases[#phases + 1] = "before" + end + local value = next(ctx, op, ir) + if op == "delay" then + phases[#phases + 1] = "after" + end + return value + end) + r:run(program) + T.eq(r:isRunning(), true, "profiled native delay suspends the actual runner") + T.same(phases, { "before" }, "hook remains before next while native command is suspended") + T.eq(g.save.gen3Vars[0x4050], nil, "later operation cannot run during the delay") + r:update() + r:update() + T.eq(r:isRunning(), false, "native updates finish the profiled delay") + T.same(phases, { "before", "after" }, "post-next executes exactly once after resumption") + T.eq(g.save.gen3Vars[0x4050], 9, "native continuation executes after resumption") +end +T.finish() diff --git a/tests/fixtures/upstream_mods/worker.lua b/tests/fixtures/upstream_mods/worker.lua index 8ad274e51..e99379d1d 100644 --- a/tests/fixtures/upstream_mods/worker.lua +++ b/tests/fixtures/upstream_mods/worker.lua @@ -12,7 +12,7 @@ local I={ local raw=assert(Files.read('mods/'..fixture..'/manifest.json')) local manifest=assert(Json.decode(raw));local id=manifest.id local mount='mods/'..id -local overlay={['options.lua']=Writer.encode({mods={[id]=true}})} +local overlay={['options.lua']=Writer.encode({mods={[id]=true},modCallbackProfiles={[id]=id=='example_mew_starter' and 'decoded' or nil}})} local function pack(importer,kind,name,entry) local p={format=1,importer=importer,pack=kind,kind=kind=='tiles' and 'tileset' or 'sprite',version='1.1.0',source={name='Synthetic fixture',md5=string.rep('0',32)},entries={[name]=entry}} overlay[I.manifestPath(importer,kind)]=Writer.encode(p) @@ -57,7 +57,9 @@ local names,known={},{} local handle=assert(io.open('tests/fixtures/upstream_mods/index.json','rb')) local index=assert(Json.decode(handle:read('*a')));handle:close() for _,name in ipairs(index.hooks)do known[name]=true end -for name in pairs(loader.hooks.chains)do assert(known[name],'unknown hook '..name);names[#names+1]=name end +for _,bus in ipairs({loader.hooks,loader._decodedHooks})do + for name in pairs(bus.chains)do assert(known[name],'unknown hook '..name);names[#names+1]=name end +end table.sort(names) local loaded=ok and result==true and loader.mods[id] and loader.mods[id].state=='loaded' local errorText=ok and table.concat(loader.errors,'; ') or tostring(result) @@ -67,5 +69,27 @@ if loaded and id=='example_mew_starter' and version=='red' then loader.events:emit('pokemon.before_give',gift) assert(gift.species=='MEW' and gift.level==20,'legacy starter event did not run') end -print('FIXTURE_RESULT '..Json.encode({fixture=fixture,version=version,loaded=loaded and true or false,hooks=names,error=errorText})) +-- Authentic unchanged starter code now runs through the actual Gen-3 compiler, +-- runner, existing gift event and Pokémon constructor on test-owned data. +local functional +if loaded and id=='example_mew_starter' and version=='firered' then + data.maps.MAP_G04_N03={id='MAP_G04_N03',group=4,number=3} + data.constants.speciesOrder={[4]='CHARMANDER',[151]='MEW'} + data.map_scripts={source='RomExtractorGen3',scripts={ENTRY={ + {'setvar',0x4002,4},{'givemon',0x4002,5,0},{'end'}, + }},maps={}} + local save={flags={},party={},gen3Vars={},player={name='TEST',id=12345}, + pokedex={seen={},owned={}}} + local game={data=data,save=save,mods=loader} + local world={map={id='MAP_G04_N03',def=data.maps.MAP_G04_N03}} + local runner=require('src.script.ScriptRunner').new(game,world) + runner:exec(require('src.script.Gen3ScriptVM').compile(data,'ENTRY'),runner:makeContext()) + assert(save.gen3Vars[0x4002]==151,'unchanged starter hook did not rewrite the native variable') + local mon=assert(save.party[1],'native gift did not create a Pokémon') + assert(#save.party==1 and mon.species=='MEW' and mon.level==20 and mon.nickname=='HOGHEAD', + 'unchanged starter event did not produce the requested native gift') + assert(world.map.id=='MAP_G04_N03','profile renamed the native map') + functional={species=mon.species,level=mon.level,nickname=mon.nickname,variable=save.gen3Vars[0x4002]} +end +print('FIXTURE_RESULT '..Json.encode({fixture=fixture,version=version,loaded=loaded and true or false,hooks=names,error=errorText,functional=functional})) if not loaded then os.exit(1)end diff --git a/tools/check_decoded_profiles.py b/tools/check_decoded_profiles.py new file mode 100644 index 000000000..c97276dac --- /dev/null +++ b/tools/check_decoded_profiles.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Measure native script execution with an owned LÖVE window and F11 sections.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--love', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + parser.add_argument('--baseline', required=True, help='local pre-M5 Git commit') + parser.add_argument('--report', type=Path, required=True) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(args.qa_tools or root / 'tools')) + from qa_storage import qa_source, qa_cache, qa_profile, qa_run + qa_source(root) + qa_source(args.love.resolve()) + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True, exist_ok=True) + files = ('src/script/ScriptRunner.lua', 'src/script/DecodedScript.lua', + 'src/script/Gen2ScriptVM.lua', 'src/script/Gen3ScriptVM.lua', + 'src/script/Commands.lua', 'src/mods/Loader.lua', 'src/mods/DecodedCallbacks.lua', + 'tools/decoded-profile-check/main.lua', 'tools/decoded-profile-check/conf.lua') + hashes = {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in files} + before = qa_run(['git', 'rev-parse', '--verify', args.baseline + '^{commit}'], cwd=root, + capture_output=True, text=True, check=True).stdout.strip() + blob = qa_run(['git', 'show', before + ':src/script/ScriptRunner.lua'], cwd=root, + capture_output=True, check=True).stdout + baseline = args.report.parent / 'baseline-runner.lua' + baseline.write_bytes(blob) + identity = 'oasc-gen2recomp-qa-decoded-profile-' + str(time.time_ns()) + qa_profile(Path.home() / 'Library/Application Support/LOVE' / identity) + result = qa_run([str(args.love.resolve()), str(root / 'tools/decoded-profile-check')], + cwd=root, env=dict(os.environ, POKEPORT_IDENTITY=identity, + DECODED_PROFILE_REPO=str(root), DECODED_PROFILE_QA_GUARDED='1', + DECODED_PROFILE_BASELINE=str(baseline.resolve())), + capture_output=True, text=True, timeout=90) + args.report.with_suffix('.log').write_text(result.stdout + result.stderr) + records = [line[len('M5_NATIVE_RESULT '):] for line in result.stdout.splitlines() + if line.startswith('M5_NATIVE_RESULT ')] + if result.returncode or len(records) != 1: + raise RuntimeError('native profile probe failed; see ' + str(args.report.with_suffix('.log'))) + if any(hashlib.sha256((root / name).read_bytes()).hexdigest() != digest for name, digest in hashes.items()): + raise RuntimeError('measured source changed during the probe') + report = json.loads(records[0]) + report.update(baseline=before, baselineRunnerSha256=hashlib.sha256(blob).hexdigest(), + sha256=hashes, minimumFreeGiB=20, playerProfilesUsed=False) + args.report.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/tools/decoded-profile-check/conf.lua b/tools/decoded-profile-check/conf.lua new file mode 100644 index 000000000..eff68e98f --- /dev/null +++ b/tools/decoded-profile-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv("DECODED_PROFILE_QA_GUARDED") == "1", "use tools/check_decoded_profiles.py") + t.identity = assert(os.getenv("POKEPORT_IDENTITY")) + t.window.title = "Decoded script profile QA" + t.window.width, t.window.height, t.window.vsync = 320, 180, 1 + t.modules.audio = false +end diff --git a/tools/decoded-profile-check/main.lua b/tools/decoded-profile-check/main.lua new file mode 100644 index 000000000..b4288d9a8 --- /dev/null +++ b/tools/decoded-profile-check/main.lua @@ -0,0 +1,100 @@ +local root = assert(os.getenv("DECODED_PROFILE_REPO")) +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +function love.errorhandler(message) + print(debug.traceback(tostring(message), 2)) + return function() + return 1 + end +end +local Version = require("src.core.GameVersion") +Version.set("firered") +local Native = require("src.script.ScriptRunner") +local baseline = assert(io.open(assert(os.getenv("DECODED_PROFILE_BASELINE")), "rb")) +local original = assert(loadstring(baseline:read("*a"), "@baseline/ScriptRunner.lua"))() +baseline:close() +local Profile = require("src.core.FrameProfile") +local Runtime = require("src.mods.Runtime") +local Loader = require("src.mods.Loader") +local SDK = require("tests.modkit.sdk") +local Json = require("src.link.Json") +local Compiler = require("src.script.Gen3ScriptVM") +local source = [[return function(mod) + mod.hooks:wrap('script.command',function(next,ctx,op,row)return next(ctx,op,row)end) +end]] +local phase, frame, runner, rows, game, started, total = 1, 0, nil, nil, nil, 0, 0 +local results, WINDOW, WARMUP = {}, 120, 120 +local function start() + Profile.setEnabled(false) + local data = require("tests.fixtures.upstream_mods.data").fresh() + local files = { + ["mods/probe/manifest.json"] = Json.encode({ id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua" }), + ["mods/probe/main.lua"] = source, + ["options.lua"] = "return {mods={probe=true}}", + } + local loader = + Loader.new({ fs = SDK.memfs(files), dev = false, callbackProfiles = { probe = phase == 3 and "decoded" + or "native" } }) + assert(loader:load(data)) + local ir = {} + for i = 1, 64 do + ir[i] = { "setvar", 0x4000 + i, i } + end + ir[#ir + 1] = { "end" } + data.map_scripts = { source = "RomExtractorGen3", scripts = { ENTRY = ir }, maps = {} } + game = { data = data, save = { gen3Vars = {}, flags = {}, party = {} }, mods = loader } + runner = (phase == 1 and original or Native).new(game, {}) + rows = Compiler.compile(data, "ENTRY") + frame, total = 0, 0 +end +function love.run() + start() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + Profile.setEnabled(true) + started = love.timer.getTime() + end + local close = Profile.section("script.exec (64 writes + end)") + local t = love.timer.getTime() + runner:exec(rows, runner:makeContext()) + if frame >= WARMUP then + total = total + love.timer.getTime() - t + end + close() + assert(game.save.gen3Vars[0x4040] == 64) + love.graphics.clear(0.03, 0.04, 0.06) + love.graphics.present() + if frame >= WARMUP then + Profile.frame() + end + frame = frame + 1 + if frame == WINDOW + WARMUP then + results[phase] = { + mode = ({ "before", "native", "decoded" })[phase], + frames = WINDOW, + scriptMeanMs = total / WINDOW * 1000, + seconds = love.timer.getTime() - started, + } + if phase == 3 then + print( + "M5_NATIVE_RESULT " + .. Json.encode({ + phases = results, + renderer = love.graphics.getRendererInfo(), + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + scope = "Native LÖVE ScriptRunner and actual per-mod Loader profile over 65 test-owned commands per frame; no world scene or cartridge gameplay", + }) + ) + return 0 + end + phase = phase + 1 + start() + end + end +end From 15436043950f45f66d2630cd38c3d74d4618d275 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:24:06 +0100 Subject: [PATCH 51/67] Release retired decoded-script sources and prevent failed-group retries --- docs/decoded-script-callbacks.md | 2 + src/script/DecodedScript.lua | 19 ++++++-- src/script/ScriptRunner.lua | 4 +- tests/engine/decoded_script_profile.lua | 60 ++++++++++++++++++++++++- 4 files changed, 79 insertions(+), 6 deletions(-) diff --git a/docs/decoded-script-callbacks.md b/docs/decoded-script-callbacks.md index a0a026ff7..ef4178d91 100644 --- a/docs/decoded-script-callbacks.md +++ b/docs/decoded-script-callbacks.md @@ -20,6 +20,8 @@ Native subscribers retain their existing bus, names, operand arrays, ordering and context. The original compiled script table remains identical until a mod actually changes a decoded operation. Compiler provenance and tracing stay in private side tables; no scratch records are placed in public script rows. +Provenance holds weak source references: retained rows cannot keep a retired +dataset alive, and replaced source pools explicitly retire their old provenance. The profile presents `(next, ctx, op, row)` for Gen 3 and `(next, ctx, op, args, cmd)` for Gen 2. Numeric operand slots and the supported diff --git a/src/script/DecodedScript.lua b/src/script/DecodedScript.lua index b9ef825e8..12f3f190d 100644 --- a/src/script/DecodedScript.lua +++ b/src/script/DecodedScript.lua @@ -4,7 +4,12 @@ local M = {} local origins = setmetatable({}, { __mode = "k" }) function M.origin(rows, data, entry, compile, generation) - origins[rows] = { data = data, entry = entry, compile = compile, generation = generation } + origins[rows] = { + data = setmetatable({ data, data.map_scripts.scripts }, { __mode = "v" }), + entry = entry, + compile = compile, + generation = generation, + } end function M.trace() return { commands = {}, byKey = {}, owners = {}, labels = {}, returns = {}, returnKeys = {} } @@ -52,10 +57,18 @@ function M.finish(rows, t) end end function M.prepare(rows) - local origin = origins[rows] - if not origin then + local stored = origins[rows] + if not stored then return end + local data = assert(stored.data[1], "decoded script source dataset has been retired") + assert( + data.map_scripts and data.map_scripts.scripts == stored.data[2], + "decoded script source cohort has been retired" + ) + -- Only an executing script borrows a strong source reference. The weak-key + -- compiler cache and this provenance table must not keep each other alive. + local origin = { data = data, entry = stored.entry, compile = stored.compile, generation = stored.generation } local trace = M.trace() local result = origin.compile(origin.data, origin.entry, { trace = trace }) assert(M.same(result, rows), "decoded profile requires an unmodified native compiled script") diff --git a/src/script/ScriptRunner.lua b/src/script/ScriptRunner.lua index 810c159b2..51e2621bd 100644 --- a/src/script/ScriptRunner.lua +++ b/src/script/ScriptRunner.lua @@ -300,7 +300,7 @@ function ScriptRunner:exec(script, ctx) else visited[command.key] = true local positive = command.after > command.pc - local ran, active = false, true + local ran, active, completed = false, true, false local function vanilla(hctx, op, ir) assert(active, "decoded next is only valid during its callback") assert(not ran, "decoded command already executed") @@ -315,11 +315,13 @@ function ScriptRunner:exec(script, ctx) local first, after = command.pc, command.after positive = after > first while pc >= first and pc < after and pc <= #script do step() end + completed = true end local copy = Decoded.copy(Decoded.source(origin, command)) local ok, err = pcall(decodedHooks.call, decodedHooks, "script.command", vanilla, ctx, copy[1], copy) active = false if not ok then error(err, 0) end + assert(not ran or completed, "decoded native command did not complete; downstream failure cannot be swallowed") if not ran then -- Suppress compile-time state as well (loadword/text fusion), once for -- this visit. The next visit still offers the original source opcode. diff --git a/tests/engine/decoded_script_profile.lua b/tests/engine/decoded_script_profile.lua index f12aeab07..95ed309ab 100644 --- a/tests/engine/decoded_script_profile.lua +++ b/tests/engine/decoded_script_profile.lua @@ -213,7 +213,12 @@ do Version.set("gold") local files = { ["options.lua"] = "return {mods={probe=true}}", - ["mods/probe/manifest.json"] = Json.encode({ id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua" }), + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + entry = "main.lua", + }), ["mods/probe/main.lua"] = [=[return function(mod) mod.hooks:wrap('script.command',function(next,ctx,op,args,cmd) if op=='setval' then @@ -263,7 +268,12 @@ do d.constants.speciesOrder = { [4] = "CHARMANDER", [151] = "MEW" } local files = { ["options.lua"] = 'return {mods={probe=true},modCallbackProfiles={probe="decoded"}}', - ["mods/probe/manifest.json"] = Json.encode({ id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua" }), + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + entry = "main.lua", + }), ["mods/probe/main.lua"] = [=[return function(mod) mod.events:on('pokemon.before_give',function(gift) assert(gift.speciesId==4) @@ -312,4 +322,50 @@ do T.same(phases, { "before", "after" }, "post-next executes exactly once after resumption") T.eq(g.save.gen3Vars[0x4050], 9, "native continuation executes after resumption") end + +-- Provenance must not keep a retired dataset alive through the compiler cache. +local weak, retained +local function retired(keepRows) + local g, r, program = fixture(3, { ENTRY = { { "setvar", 0x4060, 1 }, { "end" } } }) + local refs = setmetatable({ g.data, program }, { __mode = "v" }) + return refs, keepRows and program or nil +end +weak = retired(false) +collectgarbage("collect") +collectgarbage("collect") +T.eq(weak[1], nil, "private provenance releases an unowned dataset") +T.eq(weak[2], nil, "private provenance releases an unowned compiled program") +weak, retained = retired(true) +collectgarbage("collect") +collectgarbage("collect") +T.eq(weak[1], nil, "a retained native row list cannot retain the entire source dataset") +T.raises(function() + Decoded.prepare(retained) +end, "retired", "decoded execution refuses a retired source cohort") + +do + local g, r, program = fixture(3, { ENTRY = { { "setvar", 0x4060, 1 }, { "end" } } }) + g.data.map_scripts = { source = "RomExtractorGen3", scripts = { ENTRY = { { "end" } } }, maps = {} } + T.raises(function() + Decoded.prepare(program) + end, "retired", "a replaced source pool retires old compiled provenance") +end +-- Swallowing an error below next cannot retry partial native side effects. +do + local g, r, program, _, bus = fixture(3, { ENTRY = { { "setvar", 0x4060, 1 }, { "end" } } }) + local Commands = require("src.script.Commands") + local original, count = Commands.g3_setvar, 0 + Commands.g3_setvar = function() + count = count + 1 + error("owned native failure") + end + bus:wrap("script.command", function(next, ctx, op, ir) + pcall(next, ctx, op, ir) + end) + T.raises(function() + r:exec(program, r:makeContext({ source = { strict = true } })) + end, "did not complete", "a mod cannot swallow a failing native group and restart it") + Commands.g3_setvar = original + T.eq(count, 1, "native side effects occur at most once across a swallowed failure") +end T.finish() From e9395bb1d41a792977e86c4acc90417f6e2de7cc Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:29:20 +0100 Subject: [PATCH 52/67] Keep decoded tracing behind the original public compiler contract --- src/script/Gen2ScriptVM.lua | 10 ++++++-- src/script/Gen3ScriptVM.lua | 10 ++++++-- tests/engine/decoded_script_profile.lua | 32 +++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/script/Gen2ScriptVM.lua b/src/script/Gen2ScriptVM.lua index 5c514f6f5..fccd33e3b 100644 --- a/src/script/Gen2ScriptVM.lua +++ b/src/script/Gen2ScriptVM.lua @@ -2369,7 +2369,7 @@ function Gen2ScriptVM.lowered(op) return L[op] ~= nil end -function Gen2ScriptVM.compile(data, entry, options) +local function compile(data, entry, options) local pool = store(data) local scripts = pool and pool.scripts if not (scripts and type(entry) == "string" and scripts[entry]) then return nil end @@ -2515,12 +2515,18 @@ function Gen2ScriptVM.compile(data, entry, options) if options then Decoded.finish(out, options.trace) else - Decoded.origin(out, data, entry, Gen2ScriptVM.compile, 2) + Decoded.origin(out, data, entry, compile, 2) compiled[scripts][key] = #out > 0 and out or false end return #out > 0 and out or nil end +-- Keep the public compiler's original two-argument contract. Profile tracing +-- uses its private compiler reference, including when a mod wraps compile(). +function Gen2ScriptVM.compile(data, entry) + return compile(data, entry) +end + -- Prism renamed a good deal of Crystal's script vocabulary without changing -- what the commands DO -- `jump` for `sjump`, `if_equal` for `ifequal`, -- `end_all` for `endall`. An unlowered command is not an error, it just diff --git a/src/script/Gen3ScriptVM.lua b/src/script/Gen3ScriptVM.lua index efc956bd9..7de34bef5 100644 --- a/src/script/Gen3ScriptVM.lua +++ b/src/script/Gen3ScriptVM.lua @@ -807,7 +807,7 @@ end Gen3ScriptVM.store = store -function Gen3ScriptVM.compile(data, entry, options) +local function compile(data, entry, options) local pool = store(data) local scripts = pool and pool.scripts if not (scripts and type(entry) == "string" and scripts[entry]) then return nil end @@ -867,12 +867,18 @@ function Gen3ScriptVM.compile(data, entry, options) if options then Decoded.finish(out, options.trace) else - Decoded.origin(out, data, entry, Gen3ScriptVM.compile, 3) + Decoded.origin(out, data, entry, compile, 3) compiled[scripts][entry] = #out > 0 and out or false end return #out > 0 and out or nil end +-- Keep the public compiler's original two-argument contract. Profile tracing +-- uses its private compiler reference, including when a mod wraps compile(). +function Gen3ScriptVM.compile(data, entry) + return compile(data, entry) +end + -- --------------------------------------------------------------------------- -- registration -- --------------------------------------------------------------------------- diff --git a/tests/engine/decoded_script_profile.lua b/tests/engine/decoded_script_profile.lua index 95ed309ab..3a828060f 100644 --- a/tests/engine/decoded_script_profile.lua +++ b/tests/engine/decoded_script_profile.lua @@ -368,4 +368,36 @@ do Commands.g3_setvar = original T.eq(count, 1, "native side effects occur at most once across a swallowed failure") end + +for _, gen in ipairs({ 2, 3 }) do + local compiler = gen == 2 and C2 or C3 + local g, r, program, _, bus = fixture( + gen, + { ENTRY = gen == 2 and { { "setval", 4 }, { "endall" } } or { { "setvar", 0x4070, 4 }, { "end" } } } + ) + T.eq(compiler.compile(g.data, "ENTRY", true), program, "public compiler continues to ignore surplus arguments") + local original, calls = compiler.compile, 0 + compiler.compile = function(data, entry) + calls = calls + 1 + return original(data, entry) + end + program = compiler.compile(g.data, "ENTRY") + bus:wrap("script.command", function(next, ctx, op, ir) + if op == "setval" then + ir[2] = 7 + elseif op == "setvar" then + ir[3] = 7 + end + return next(ctx, op, ir) + end) + local ctx = r:makeContext() + r:exec(program, ctx) + T.eq( + gen == 2 and ctx.g2Var or g.save.gen3Vars[0x4070], + 7, + "a transparent public compiler wrapper cannot suppress decoded callbacks" + ) + T.eq(calls, 1, "private tracing does not replay public compiler wrappers") + compiler.compile = original +end T.finish() From 8a7500d868a0195c7b54a22e652c776cc60128a0 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:36:22 +0100 Subject: [PATCH 53/67] Keep decoded Gen2 call hooks open through native subroutine returns --- docs/decoded-script-callbacks.md | 3 ++- src/script/ScriptRunner.lua | 13 ++++++++++++- tests/engine/decoded_script_profile.lua | 24 ++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/decoded-script-callbacks.md b/docs/decoded-script-callbacks.md index ef4178d91..7941a0580 100644 --- a/docs/decoded-script-callbacks.md +++ b/docs/decoded-script-callbacks.md @@ -29,7 +29,8 @@ named aliases describe the source operation, before native lowering. A single source operation may have zero, one or several native rows. `next` runs that whole native group; code after `next` observes its native effects. Rewriting an opcode or operand recompiles a private overlay using the same native compiler. -Calls keep their active return addresses. Each loop visit offers the original +Calls keep their active return addresses. Gen-2 call hooks remain open until +the called subroutine returns, including across native waits. Each loop visit offers the original source operation again, so a previous rewrite or veto cannot rewrite the imported source. Vetoes also remove compile-time text state. A retained `next` cannot replay a completed operation. diff --git a/src/script/ScriptRunner.lua b/src/script/ScriptRunner.lua index 51e2621bd..3088c5ee0 100644 --- a/src/script/ScriptRunner.lua +++ b/src/script/ScriptRunner.lua @@ -288,7 +288,8 @@ function ScriptRunner:exec(script, ctx) return end local overrides, visited = {}, {} - while pc <= #script do + local dispatch + dispatch = function() local candidates = trace and trace.starts[pc] local command for _, candidate in ipairs(candidates or {}) do @@ -313,8 +314,15 @@ function ScriptRunner:exec(script, ctx) pc = command.pc end local first, after = command.pc, command.after + local nestedCall = origin.generation == 2 and (op == "scall" or op == "farscall") + local depth = nestedCall and #(ctx.g2Stack or {}) or 0 positive = after > first while pc >= first and pc < after and pc <= #script do step() end + -- Gen-2 decoded calls are synchronous: their hook's post-next code + -- observes the completed subroutine, including any native suspension. + if nestedCall then + while pc <= #script and not ctx.scriptCancelled and #(ctx.g2Stack or {}) > depth do dispatch() end + end completed = true end local copy = Decoded.copy(Decoded.source(origin, command)) @@ -332,6 +340,9 @@ function ScriptRunner:exec(script, ctx) end if positive then visited = {} end end + end + while pc <= #script do + dispatch() if ctx.scriptCancelled then return end end end diff --git a/tests/engine/decoded_script_profile.lua b/tests/engine/decoded_script_profile.lua index 3a828060f..4a1fac628 100644 --- a/tests/engine/decoded_script_profile.lua +++ b/tests/engine/decoded_script_profile.lua @@ -400,4 +400,28 @@ for _, gen in ipairs({ 2, 3 }) do T.eq(calls, 1, "private tracing does not replay public compiler wrappers") compiler.compile = original end + +-- The decoded Gen-2 call contract runs its subroutine before post-next code. +do + local g, r, program, _, bus = fixture(2, { + ENTRY = { { "scall", "SUB" }, { "setval", 7 }, { "endall" } }, + SUB = { { "setval", 9 }, { "pause", 2 }, { "end" } }, + }) + local observed + bus:wrap("script.command", function(next, ctx, op, ir) + local result = next(ctx, op, ir) + if op == "scall" then + observed = ctx.g2Var + end + return result + end) + r:run(program) + T.eq(observed, nil, "Gen-2 call hook remains suspended with its subroutine") + T.eq(r:isRunning(), true, "nested native wait keeps the caller suspended") + r:update() + r:update() + local ctx = r.ctx + T.eq(observed, 9, "Gen-2 post-next sees the completed subroutine state") + T.eq(ctx.g2Var, 7, "the caller continues after its decoded call hook closes") +end T.finish() From cc5275275a69cdba5e8f8ed3b30570775ade2c92 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:53:11 +0100 Subject: [PATCH 54/67] compat(M6): expose shared native NPC controls on legacy handles --- .gitignore | 1 + docs/legacy-npc-controls.md | 55 +++++++ src/mods/Api3NPC.lua | 33 +---- src/mods/Loader.lua | 4 + src/world/NpcControls.lua | 49 +++++++ src/world/WorldAPI.lua | 80 ++++++++++- tests/engine/legacy_npc_controls.lua | 134 ++++++++++++++++++ .../legacy_npc_controls/mods/legacy/main.lua | 18 +++ .../mods/legacy/manifest.json | 1 + tests/fixtures/legacy_npc_controls/worker.lua | 16 +++ tools/check_decoded_profiles.py | 18 ++- tools/check_legacy_npc_contract.py | 54 +++++++ tools/decoded-profile-check/main.lua | 4 + tools/decoded-profile-check/npcs.lua | 88 ++++++++++++ 14 files changed, 524 insertions(+), 31 deletions(-) create mode 100644 docs/legacy-npc-controls.md create mode 100644 src/world/NpcControls.lua create mode 100644 tests/engine/legacy_npc_controls.lua create mode 100644 tests/fixtures/legacy_npc_controls/mods/legacy/main.lua create mode 100644 tests/fixtures/legacy_npc_controls/mods/legacy/manifest.json create mode 100644 tests/fixtures/legacy_npc_controls/worker.lua create mode 100644 tools/check_legacy_npc_contract.py create mode 100644 tools/decoded-profile-check/npcs.lua diff --git a/.gitignore b/.gitignore index 7a048e237..19c16fb8e 100644 --- a/.gitignore +++ b/.gitignore @@ -183,4 +183,5 @@ mobile/ios/bundle_id.local !/tests/fixtures/ /tests/fixtures/* !/tests/fixtures/upstream_mods/ +!/tests/fixtures/legacy_npc_controls/ !/tests/engine/ diff --git a/docs/legacy-npc-controls.md b/docs/legacy-npc-controls.md new file mode 100644 index 000000000..6459e74e7 --- /dev/null +++ b/docs/legacy-npc-controls.md @@ -0,0 +1,55 @@ +# API-2 native NPC controls (M6) + +Legacy `mod.world:npc(mapId, indexOrName)` handles now expose `stepNow`, +`canStep`, `placeAt`, `isMoving`, `setPassable` and `setAppearance`. Existing +`ow`, `npc` and `id` fields, scripted movement, facing, position and elevation +remain unchanged. + +The operations share the existing API-3 native actor implementation through +`src/world/NpcControls.lua`. API 3 keeps its original admission, validation, +ownership and cleanup policies. API 2 adds controls to its existing handle and +retains immediate replay semantics: `stepNow(direction)` deliberately skips +collision and the scripted movement queue. Call `canStep(direction)` first +when the map should decide whether that move is legal. + +`placeAt(x, y, facing)` clears the active step, its target and phase, and aligns +native cell/pixel coordinates. Coordinates must be finite nonnegative integers +up to 1,000,000. The optional facing uses `up/down/left/right`. `isMoving()` +reads native motion; `setPassable(value)` uses legacy boolean coercion. +`setAppearance(id)` resolves the native sprite on a private definition copy +before committing it to the actor. A missing sprite or a rendering error leaves +the actor and imported template unchanged. + +New controls decline stale handles after a map/world/save/data/version change, +actor removal or mod retirement. Failed-entry rollback, disable and replacement +of the loader's bus retire the new controls. Previously applied controls remain +native scene changes. Existing legacy handle methods keep their behavior. +Caller permission attribution is restored on both success and failure. + +The extension is available without changing a mod manifest, API level or public +hook. It is an additive compatibility feature; reverting the M6 commit removes +the extension while restoring the original shared implementation. + +## Qualification + +The unchanged API-2 consumer fixture runs through both production loaders +against pinned reference `535a5ec` on Red, Blue, Yellow, Gold, Silver, Crystal, +FireRed and Emerald. Its public movement, passability, placement, motion and +appearance results match in all eight comparisons. The fixture declares those +three generations explicitly and is test-owned. + +The native suite also uses real Map collision and NPC updates, checks queue-free +motion and cancellation of a running step, preserves old fields/methods, checks +peer passability and sprite failure atomicity, and exercises stale ownership. +The existing API-3 NPC and portable movement suites qualify the shared helper. +Sprite construction uses a deterministic test seam; rendered artwork equivalence +and retail gameplay are outside these checks. + +`tools/check_decoded_profiles.py --npc-controls` uses the same guarded LÖVE host +as M5. The first two phases measure 64 identical legacy handle lookups, position +and facing calls per frame before and after M6. A third phase exercises all six +new controls and 1,024 native NPC updates per frame. It reports separate F11 +sections for lookup and new-control workloads, with 120 warm-up and 120 measured +frames per phase. These are owned CPU contract workloads with deterministic +sprite construction. The PR records the measured cost and source fingerprints; +no world-scene or handheld FPS claim is made. diff --git a/src/mods/Api3NPC.lua b/src/mods/Api3NPC.lua index 8a4091f2a..b1a29b51b 100644 --- a/src/mods/Api3NPC.lua +++ b/src/mods/Api3NPC.lua @@ -2,7 +2,7 @@ local Runtime = require("src.mods.Runtime") local Version = require("src.core.GameVersion") local Edits = require("src.mods.Api3WorldEdits") -local Collision = require("src.world.Collision") +local Controls = require("src.world.NpcControls") local copy = require("src.mods.Merge").deepCopy local N = {} local services = setmetatable({}, { __mode = "k" }) @@ -382,7 +382,7 @@ function N.new(loader, owner, gen, current, native, licensed, req) return npc.cellX, npc.cellY end if name == "isMoving" then - return not not npc.moving + return Controls.isMoving(npc) end if name == "canStep" or name == "stepNow" or name == "face" or name == "scriptMove" then if not dirs[a] then @@ -390,7 +390,7 @@ function N.new(loader, owner, gen, current, native, licensed, req) end end if name == "canStep" then - return not not Collision.canMove(onMap, peers or {}, npc, a) + return Controls.canStep(npc, onMap, peers, a) end if name == "scriptMove" and (b ~= nil and not integer(b, 1, 1024) or c ~= nil and type(c) ~= "function") @@ -432,32 +432,13 @@ function N.new(loader, owner, gen, current, native, licensed, req) elseif name == "face" then npc.facing = a elseif name == "stepNow" then - if npc.moving then - return nil, "already moving" - end - npc.facing = a - npc.targetX, npc.targetY = Collision.target(npc.cellX, npc.cellY, a) - npc.moving, npc.progress = true, 0 + return Controls.stepNow(npc, a) elseif name == "placeAt" then - npc.moving, npc.marching, npc.progress = false, false, 0 - npc.targetX, npc.targetY = nil, nil - npc.cellX, npc.cellY, npc.px, npc.py = a, b, a * 16, b * 16 - if c then - npc.facing = c - end + return Controls.placeAt(npc, a, b, c) elseif name == "setPassable" then - npc.passable = a + return Controls.setPassable(npc, a) elseif name == "setAppearance" then - if not sprite(game, a) then - return nil, "unknown sprite: " .. a - end - local proxy = { id = npc.id, def = copy(npc.def) } - proxy.def.sprite = a - proxy.def.graphicsId, proxy.def.graphics, proxy.def.graphicsVar, proxy.def.varSprite = - nil, nil, nil, nil - req("src.world.NPC").refreshSprite(proxy, game.data) - npc.def, npc.sprite, npc.appearance = proxy.def, proxy.sprite, a - npc.big = proxy.sprite and proxy.sprite.big or false + return Controls.setAppearance(npc, game, a, req) end return true end) diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index ac33e99e3..0d6b9f099 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1197,6 +1197,10 @@ function Loader:_api(mod) local module = game and engineRequire("src.world.WorldAPI") if not module then return nil end world = module.new(game, modId, readContext) + world._npcAlive = function() + return loader.mods[modId] == mod and loader.apis[modId] == api + and loader.exports[modId] ~= nil and isActive(mod) + end world.overviewContext = overviewContext return world end }, api, loader, mod.manifest)) diff --git a/src/world/NpcControls.lua b/src/world/NpcControls.lua new file mode 100644 index 000000000..d5a08d952 --- /dev/null +++ b/src/world/NpcControls.lua @@ -0,0 +1,49 @@ +-- Original contribution by Roxas2712. See LICENSE-ROXAS2712.md. +-- Shared native actor operations. Facades retain their own admission policy. +local Collision = require("src.world.Collision") +local copy = require("src.mods.Merge").deepCopy +local C = {} +function C.isMoving(actor) + return not not actor.moving +end +function C.canStep(actor, map, peers, direction) + return not not Collision.canMove(map, peers or {}, actor, direction) +end +function C.stepNow(actor, direction) + if actor.moving then + return nil, "already moving" + end + actor.facing = direction + actor.targetX, actor.targetY = Collision.target(actor.cellX, actor.cellY, direction) + actor.moving, actor.progress = true, 0 + return true +end +function C.placeAt(actor, x, y, facing) + actor.moving, actor.marching, actor.progress = false, false, 0 + actor.targetX, actor.targetY = nil, nil + actor.cellX, actor.cellY, actor.px, actor.py = x, y, x * 16, y * 16 + if facing then + actor.facing = facing + end + return true +end +function C.setPassable(actor, passable) + actor.passable = passable + return true +end +function C.setAppearance(actor, game, id, req) + if not (game.data and game.data.sprites and game.data.sprites[id]) then + return nil, "unknown sprite: " .. tostring(id) + end + -- Resolve first, then commit. A renderer failure cannot half-change a live + -- actor or its imported template. This is the existing API-3 native path. + local proxy = { id = actor.id, def = copy(actor.def) } + proxy.def.sprite = id + proxy.def.graphicsId, proxy.def.graphics, proxy.def.graphicsVar, proxy.def.varSprite = nil, nil, nil, nil + local nativeNpc = (req or require)("src.world.NPC") + nativeNpc.refreshSprite(proxy, game.data) + actor.def, actor.sprite, actor.appearance = proxy.def, proxy.sprite, id + actor.big = proxy.sprite and proxy.sprite.big or false + return true +end +return C diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 3e2ae3080..f2fddcada 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -10,6 +10,9 @@ local Actions = require("src.world.WorldActions") local MapLoader = require("src.world.MapLoader") local Gen3Elevation = require("src.world.Gen3Elevation") local Runtime = require("src.mods.Runtime") +local NpcControls = require("src.world.NpcControls") +local Collision = require("src.world.Collision") +local Version = require("src.core.GameVersion") local Reads = require("src.mods.WorldReads") local WorldOverview = require("src.mods.WorldOverview") local MapOverview = require("src.world.MapOverview") @@ -401,6 +404,79 @@ function Handle:elevation() return self.npc.gen3Elevation end +-- New immediate controls retain the legacy handle and its existing methods. +-- A retained handle cannot apply these controls to a later native cohort. +local function npcCoordinate(v) + return type(v) == "number" and v == v and v >= 0 and v <= 1000000 and v % 1 == 0 +end +local function control(handle, name, a, b, c) + local context = handle._controlContext + local api, game, ow, actor = context[1], context[2], context[3], context[4] + if + not ow.map + or handle.ow ~= ow + or handle.npc ~= actor + or api.game ~= game + or game.data ~= context[5] + or game.save ~= context[6] + or Version.get() ~= context[8] + or api:overworld() ~= ow + or ow.map ~= context[7] + or ow.map.id ~= context[10] + or game.overworld ~= context[11] + or api._npcAlive and (not api._npcAlive() or Runtime.hooks ~= context[9]) + then + return nil, "NPC handle is stale" + end + local found = false + for _, candidate in ipairs(ow.npcs or {}) do + if candidate == actor then + found = true + break + end + end + if not found then + return nil, "NPC handle is stale" + end + if name == "stepNow" or name == "canStep" then + if not Collision.DELTA[a] then + return nil, "invalid direction" + end + end + if name == "placeAt" then + if not npcCoordinate(a) or not npcCoordinate(b) or c ~= nil and not Collision.DELTA[c] then + return nil, "invalid coordinates" + end + end + if name == "canStep" then + return NpcControls.canStep(actor, ow.map, ow.cast or ow.entities, a) + end + if name == "isMoving" then + return NpcControls.isMoving(actor) + end + if name == "setAppearance" then + return NpcControls.setAppearance(actor, game, a) + end + if name == "setPassable" then + return NpcControls.setPassable(actor, not not a) + end + return NpcControls[name](actor, a, b, c) +end +for _, method in ipairs({ "stepNow", "canStep", "placeAt", "isMoving", "setPassable", "setAppearance" }) do + local name = method + Handle[name] = function(self, ...) + local previous = Runtime.currentMod + Runtime.currentMod = nil + local result = { pcall(control, self, name, ...) } + Runtime.currentMod = previous + if not result[1] then + local ok, message = pcall(tostring, result[2]) + return nil, "NPC operation failed: " .. (ok and message or "unprintable error") + end + return result[2], result[3] + end +end + function WorldAPI:npc(mapId, indexOrName) local ow = self:overworld() if not ow then return nil, NO_OVERWORLD end @@ -408,7 +484,9 @@ function WorldAPI:npc(mapId, indexOrName) for _, npc in ipairs(ow.npcs or {}) do if npc.def.index == indexOrName or npc.def.name == indexOrName or npc.id == indexOrName then - return setmetatable({ ow = ow, npc = npc, id = npc.id }, Handle) + return setmetatable({ ow = ow, npc = npc, id = npc.id, + _controlContext = { self, self.game, ow, npc, self.game.data, self.game.save, + ow.map, Version.get(), Runtime.hooks, ow.map and ow.map.id, self.game.overworld } }, Handle) end end return nil, "no such object: " .. tostring(indexOrName) diff --git a/tests/engine/legacy_npc_controls.lua b/tests/engine/legacy_npc_controls.lua new file mode 100644 index 000000000..a70ed9da9 --- /dev/null +++ b/tests/engine/legacy_npc_controls.lua @@ -0,0 +1,134 @@ +local T = require("tests.harness") +love = require("tests.love_stub") +local Contract = require("tests.api3_contract.npcs") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local function fixture(version) + local f = Contract.fixture("tests/fixtures/legacy_npc_controls", "gen2", version, "legacy") + -- API 2 materializes its legacy world lazily; the fixture already owns a + -- constructed game by the time the exported lookup is called. + f.loader._game = function() + return f.live + end + f.legacy = f.loader + f.handle = assert(f.loader.exports.legacy.npc(f.home, "IMPORTED")) + return f +end +for _, version in ipairs({ "red", "gold", "firered" }) do + local f = fixture(version) + local h, actor = f.handle, f.imported + T.eq(h.ow, f.ow, "legacy world field remains the native world") + T.eq(h.npc, actor, "legacy actor field retains its original identity") + T.eq(h.id, actor.id, "legacy public identity is preserved") + T.eq(h:isMoving(), false, "new motion query reads native state") + T.eq(h:placeAt(1, 1, "right"), true, "immediate placement is available to API 2") + T.eq(actor.px, 16, "placement synchronizes native pixel coordinates") + T.eq(actor.py, 16, "placement synchronizes native pixel coordinates") + T.eq(h:canStep("right"), true, "query uses actual native map collision") + local queue = #f.ow.scriptMoves + T.eq(h:stepNow("right"), true, "immediate stepping starts native motion") + T.eq(#f.ow.scriptMoves, queue, "immediate step creates no script queue entry") + T.eq(actor.targetX, 2, "native target follows the requested direction") + T.eq(h:isMoving(), true, "motion query sees the started step") + T.eq(h:stepNow("down"), nil, "an active step cannot be overwritten") + for _ = 1, 16 do + actor:update(f.ow.map, f.ow.entities) + end + T.eq(actor.cellX, 2, "actual NPC updates complete the step") + T.eq(h:isMoving(), false, "actual NPC updates clear native motion") + T.eq(h:stepNow("right"), true, "another native step starts") + T.eq(h:placeAt(3, 4, "up"), true, "placement cancels an in-flight step") + T.eq(actor.targetX, nil, "placement clears the old native target") + T.eq(actor.progress, 0, "placement clears the old step phase") + T.eq(actor.moving, false, "placement clears the old movement flag") + T.eq(actor.facing, "up", "placement applies optional facing") + local peer = + f:make(f.home, { index = 2, name = "PEER", sprite = "PROBE", x = 4, y = 4, movement = "STAY", range = "DOWN" }) + f.ow.npcs[#f.ow.npcs + 1] = peer + f.ow.entities[#f.ow.entities + 1] = peer + T.eq(h:canStep("right"), false, "collision query sees a native peer") + local other = assert(f.legacy.exports.legacy.npc(f.home, "PEER")) + T.eq(other:setPassable(true), true, "API-2 passability control reaches the native actor") + T.eq(h:canStep("right"), true, "native collision honors the changed peer passability") + T.eq(other:setPassable(false), true, "passability can be restored") + T.eq(h:canStep("right"), false, "restored passability restores native blocking") + local def = actor.def + T.eq(h:setAppearance("ALT"), true, "appearance resolves through the native sprite path") + T.eq(actor.appearance, "ALT", "native appearance identity updates") + T.eq(actor.def.sprite, "ALT", "native actor definition uses the chosen sprite") + T.eq(def.sprite, "PROBE", "imported shared template is retained") + local changed = actor.def + T.eq(h:setAppearance("MISSING"), nil, "missing appearance is declined") + T.eq(actor.def, changed, "a declined appearance cannot half-change the actor") + T.eq(h:stepNow("north"), nil, "bad directions are declined") + T.eq(h:placeAt(0 / 0, 2), nil, "non-finite coordinates are declined") + T.eq(actor.cellX, 3, "invalid input leaves the native cell unchanged") + local x, y = h:position() + T.eq(x, 3, "existing position method remains available") + T.eq(y, 4, "existing position method retains its returns") + T.eq(h:face("left"), true, "existing facing method remains available") + T.eq(actor.facing, "left", "existing facing method retains its native effect") + Runtime.currentMod = "legacy" + T.eq(h:isMoving(), false, "new control is callable under mod attribution") + T.eq(Runtime.currentMod, "legacy", "new control restores caller attribution") + Runtime.currentMod = nil + f.game.save = {} + T.eq(h:placeAt(0, 0), nil, "retained new controls decline a replacement save") + T.eq(actor.cellX, 3, "stale control cannot change the previous native actor") + f.restoreRenderer() +end +for _, change in ipairs({ "rollback", "disabled", "map", "map_id", "fast_world", "removed", "version", "bus" }) do + local f = fixture("gold") + local h = f.handle + if change == "rollback" then + f.legacy:_rollback("legacy") + elseif change == "disabled" then + f.legacy.mods.legacy.enabled = false + elseif change == "map" then + f.ow.map = { id = "CHANGED" } + elseif change == "map_id" then + f.ow.map.id = "CHANGED" + elseif change == "fast_world" then + f.game.overworld = {} + elseif change == "removed" then + f.ow.npcs = {} + elseif change == "version" then + Version.set("silver") + else + Runtime.install(require("src.mods.Events").new(), require("src.mods.Hooks").new(), {}) + end + T.eq(h:setPassable(true), nil, change .. " retires new controls") + T.eq(f.imported.passable, nil, change .. " does not change the old native actor") + f.restoreRenderer() +end + +do + local f = fixture("gold") + local Renderer = require("src.render.SpriteRenderer") + local old = Renderer.new + local def, sprite = f.imported.def, f.imported.sprite + Renderer.new = function() + error("owned sprite failure") + end + Runtime.currentMod = "legacy" + local ok, why = f.handle:setAppearance("ALT") + T.eq(ok, nil, "native sprite failures are contained") + T.check(why:find("owned sprite failure", 1, true) ~= nil, "native sprite failure remains diagnosable") + T.eq(Runtime.currentMod, "legacy", "failed native control restores caller attribution") + T.eq(f.imported.def, def, "failed native sprite resolution retains the old definition") + T.eq(f.imported.sprite, sprite, "failed native sprite resolution retains the old renderer") + Runtime.currentMod = nil + Renderer.new = old + f.restoreRenderer() +end + +do + local f = fixture("gold") + f.ow.map = nil + local h = assert(f.legacy.exports.legacy.npc(f.home, "IMPORTED")) + local value, why = h:isMoving() + T.eq(value, nil, "legacy lookup without a map cannot grant new controls") + T.eq(why, "NPC handle is stale", "missing-map control has a stable refusal") + f.restoreRenderer() +end +T.finish() diff --git a/tests/fixtures/legacy_npc_controls/mods/legacy/main.lua b/tests/fixtures/legacy_npc_controls/mods/legacy/main.lua new file mode 100644 index 000000000..ee1762048 --- /dev/null +++ b/tests/fixtures/legacy_npc_controls/mods/legacy/main.lua @@ -0,0 +1,18 @@ +return function(mod) + mod.exports.npc = function(...) + return mod.world:npc(...) + end + mod.exports.sample = function(mapId, ref, appearance) + local h = assert(mod.world:npc(mapId, ref)) + assert(h:placeAt(1, 1, "right")) + local allowed = h:canStep("right") + assert(h:setPassable(true)) + assert(h:stepNow("right")) + local moving = h:isMoving() + assert(h:placeAt(3, 4, "up")) + assert(h:setPassable(false)) + assert(h:setAppearance(appearance)) + local x, y = h:position() + return { x = x, y = y, allowed = allowed, moving = moving, stopped = not h:isMoving() } + end +end diff --git a/tests/fixtures/legacy_npc_controls/mods/legacy/manifest.json b/tests/fixtures/legacy_npc_controls/mods/legacy/manifest.json new file mode 100644 index 000000000..ac5706ad2 --- /dev/null +++ b/tests/fixtures/legacy_npc_controls/mods/legacy/manifest.json @@ -0,0 +1 @@ +{"id":"legacy","name":"Legacy NPC controls fixture","version":"1.0.0","api":2,"entry":"main.lua","gen2compat":true,"games":["gen1","gen2","gen3"]} diff --git a/tests/fixtures/legacy_npc_controls/worker.lua b/tests/fixtures/legacy_npc_controls/worker.lua new file mode 100644 index 000000000..bd4bbcf1b --- /dev/null +++ b/tests/fixtures/legacy_npc_controls/worker.lua @@ -0,0 +1,16 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version = assert(arg[1]), assert(arg[2]), assert(arg[3]) +love = require("tests.love_stub") +local f = dofile(root .. "/tests/api3_contract/npcs.lua").fixture( + root .. "/tests/fixtures/legacy_npc_controls", + backend, + version, + "legacy" +) +f.loader._game = function() + return f.live +end +local result = f.exports.sample(f.home, "IMPORTED", "ALT") +assert(result.x == 3 and result.y == 4 and result.allowed and result.moving and result.stopped) +f.restoreRenderer() +print("LEGACY_NPC_RESULT " .. require("src.link.Json").encode(result)) diff --git a/tools/check_decoded_profiles.py b/tools/check_decoded_profiles.py index c97276dac..7af5f6f0e 100644 --- a/tools/check_decoded_profiles.py +++ b/tools/check_decoded_profiles.py @@ -13,6 +13,7 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--love', type=Path, required=True) parser.add_argument('--qa-tools', type=Path) + parser.add_argument('--npc-controls', action='store_true') parser.add_argument('--baseline', required=True, help='local pre-M5 Git commit') parser.add_argument('--report', type=Path, required=True) args = parser.parse_args() @@ -27,10 +28,17 @@ def main(): 'src/script/Gen2ScriptVM.lua', 'src/script/Gen3ScriptVM.lua', 'src/script/Commands.lua', 'src/mods/Loader.lua', 'src/mods/DecodedCallbacks.lua', 'tools/decoded-profile-check/main.lua', 'tools/decoded-profile-check/conf.lua') + if args.npc_controls: + files = ('src/world/WorldAPI.lua', 'src/world/NpcControls.lua', 'src/mods/Api3NPC.lua', + 'src/mods/Loader.lua', 'tools/decoded-profile-check/main.lua', + 'tools/decoded-profile-check/conf.lua', 'tools/decoded-profile-check/npcs.lua', + 'tests/fixtures/legacy_npc_controls/mods/legacy/main.lua', + 'tests/fixtures/legacy_npc_controls/mods/legacy/manifest.json') hashes = {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in files} before = qa_run(['git', 'rev-parse', '--verify', args.baseline + '^{commit}'], cwd=root, capture_output=True, text=True, check=True).stdout.strip() - blob = qa_run(['git', 'show', before + ':src/script/ScriptRunner.lua'], cwd=root, + baseline_path = 'src/world/WorldAPI.lua' if args.npc_controls else 'src/script/ScriptRunner.lua' + blob = qa_run(['git', 'show', before + ':' + baseline_path], cwd=root, capture_output=True, check=True).stdout baseline = args.report.parent / 'baseline-runner.lua' baseline.write_bytes(blob) @@ -39,16 +47,18 @@ def main(): result = qa_run([str(args.love.resolve()), str(root / 'tools/decoded-profile-check')], cwd=root, env=dict(os.environ, POKEPORT_IDENTITY=identity, DECODED_PROFILE_REPO=str(root), DECODED_PROFILE_QA_GUARDED='1', - DECODED_PROFILE_BASELINE=str(baseline.resolve())), + DECODED_PROFILE_BASELINE=str(baseline.resolve()), + NPC_CONTROLS_QA='1' if args.npc_controls else '0'), capture_output=True, text=True, timeout=90) args.report.with_suffix('.log').write_text(result.stdout + result.stderr) - records = [line[len('M5_NATIVE_RESULT '):] for line in result.stdout.splitlines() - if line.startswith('M5_NATIVE_RESULT ')] + prefix = 'M6_NATIVE_RESULT ' if args.npc_controls else 'M5_NATIVE_RESULT ' + records = [line[len(prefix):] for line in result.stdout.splitlines() if line.startswith(prefix)] if result.returncode or len(records) != 1: raise RuntimeError('native profile probe failed; see ' + str(args.report.with_suffix('.log'))) if any(hashlib.sha256((root / name).read_bytes()).hexdigest() != digest for name, digest in hashes.items()): raise RuntimeError('measured source changed during the probe') report = json.loads(records[0]) + report['baselinePath'] = baseline_path report.update(baseline=before, baselineRunnerSha256=hashlib.sha256(blob).hexdigest(), sha256=hashes, minimumFreeGiB=20, playerProfilesUsed=False) args.report.write_text(json.dumps(report, indent=2) + '\n') diff --git a/tools/check_legacy_npc_contract.py b/tools/check_legacy_npc_contract.py new file mode 100644 index 000000000..5e4f6b8a3 --- /dev/null +++ b/tools/check_legacy_npc_contract.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Compare the unchanged API-2 NPC consumer on both pinned native backends.""" +import argparse +import hashlib +import json +from pathlib import Path +import sys + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--reference', type=Path, required=True) + parser.add_argument('--lua', required=True) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(args.qa_tools or root/'tools')) + from qa_storage import qa_source, qa_cache, qa_run + reference = args.reference.resolve() + qa_source(root); qa_source(reference); qa_source(args.lua) + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True, exist_ok=True) + ref = qa_run(['git','rev-parse','HEAD'], cwd=reference, capture_output=True, text=True, check=True).stdout.strip() + status=qa_run(['git','status','--porcelain','--untracked-files=no'],cwd=reference, + capture_output=True,text=True,check=True).stdout + if status.strip(): raise RuntimeError('pinned reference has tracked modifications') + if ref != '535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91': + raise RuntimeError('reference does not match the pinned NPC contract') + files=root/'tests/fixtures/legacy_npc_controls/mods/legacy' + hashes={p.name:hashlib.sha256(p.read_bytes()).hexdigest()for p in files.iterdir() if p.is_file()} + worker = root/'tests/fixtures/legacy_npc_controls/worker.lua' + records=[] + for version in ('red','blue','yellow','gold','silver','crystal','firered','emerald'): + results={} + for backend, engine in [('gen1',reference),('gen2',root)]: + result=qa_run([args.lua,str(worker),str(root),backend,version],cwd=engine, + capture_output=True,text=True,timeout=60) + lines=[line[len('LEGACY_NPC_RESULT '):]for line in result.stdout.splitlines() + if line.startswith('LEGACY_NPC_RESULT ')] + if result.returncode or len(lines)!=1: + raise RuntimeError(f'{backend}/{version}:\n{result.stdout}{result.stderr}') + results[backend]=json.loads(lines[0]) + if results['gen1']!=results['gen2']: + raise RuntimeError(f'{version}: NPC contract differs: {results}') + records.append({'version':version,'result':results['gen2']}) + print('PASS '+version,flush=True) + if {p.name:hashlib.sha256(p.read_bytes()).hexdigest()for p in files.iterdir() if p.is_file()} != hashes: + raise RuntimeError('NPC consumer bytes changed during the comparison') + args.report.write_text(json.dumps({'reference':ref,'consumerSha256':hashes,'comparisons':records},indent=2)+'\n') + + +if __name__=='__main__': + main() diff --git a/tools/decoded-profile-check/main.lua b/tools/decoded-profile-check/main.lua index b4288d9a8..e73a148aa 100644 --- a/tools/decoded-profile-check/main.lua +++ b/tools/decoded-profile-check/main.lua @@ -6,6 +6,10 @@ function love.errorhandler(message) return 1 end end +if os.getenv('NPC_CONTROLS_QA') == '1' then + require('tools.decoded-profile-check.npcs') + return +end local Version = require("src.core.GameVersion") Version.set("firered") local Native = require("src.script.ScriptRunner") diff --git a/tools/decoded-profile-check/npcs.lua b/tools/decoded-profile-check/npcs.lua new file mode 100644 index 000000000..f667c0137 --- /dev/null +++ b/tools/decoded-profile-check/npcs.lua @@ -0,0 +1,88 @@ +-- Owned native CPU workload. Sprite construction is a deterministic seam; +-- map collision and actor updates use the production engine. +local Contract = require("tests.api3_contract.npcs") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local Native = require("src.world.WorldAPI") +local file = assert(io.open(assert(os.getenv("DECODED_PROFILE_BASELINE")), "rb")) +local Original = assert(loadstring(file:read("*a"), "@baseline/WorldAPI.lua"))() +file:close() +local phase, frame, fixture, total = 1, 0, nil, 0 +local results, WINDOW, WARMUP = {}, 120, 120 +local function start() + Profile.setEnabled(false) + package.loaded["src.world.WorldAPI"] = phase == 1 and Original or Native + fixture = Contract.fixture("tests/fixtures/legacy_npc_controls", "gen2", "gold", "legacy") + fixture.loader._game = function() + return fixture.live + end + -- Bind the actual API-2 loader facade before restoring the global module. + assert(fixture.exports.npc(fixture.home, "IMPORTED")) + package.loaded["src.world.WorldAPI"] = Native + frame, total = 0, 0 +end +function love.run() + start() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + Profile.setEnabled(true) + end + local label = phase == 3 and "npc.controls + native updates (64)" or "npc.lookup + legacy calls (64)" + local close = Profile.section(label) + local began = love.timer.getTime() + for _ = 1, 64 do + local h = assert(fixture.exports.npc(fixture.home, "IMPORTED")) + if phase == 3 then + assert(h:placeAt(1, 1, "right")) + assert(h:canStep("right")) + assert(h:setPassable(true)) + assert(h:stepNow("right")) + assert(h:isMoving()) + for _ = 1, 16 do + fixture.imported:update(fixture.ow.map, fixture.ow.entities) + end + assert(not h:isMoving()) + assert(h:setAppearance("ALT")) + else + h:position() + h:face("left") + end + end + if frame >= WARMUP then + total = total + love.timer.getTime() - began + end + close() + love.graphics.clear(0.03, 0.04, 0.06) + love.graphics.present() + if frame >= WARMUP then + Profile.frame() + end + frame = frame + 1 + if frame == WINDOW + WARMUP then + results[phase] = { + mode = ({ "before", "native", "controls" })[phase], + frames = WINDOW, + operationMeanMs = total / WINDOW * 1000, + } + fixture.restoreRenderer() + if phase == 3 then + print("M6_NATIVE_RESULT " .. Json.encode({ + phases = results, + renderer = love.graphics.getRendererInfo(), + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + scope = "Actual LÖVE host, production API-2 loader, native Map collision and NPC updates over owned data; deterministic sprite construction, no rendered artwork or cartridge scene", + })) + return 0 + end + phase = phase + 1 + start() + end + end +end From 5d6e1e63c86117f4ed551c57de8029722cca6a93 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:00:01 +0100 Subject: [PATCH 55/67] Allow the NPC fixture driver to load the legacy API consumer --- tests/api3_contract/npcs.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/api3_contract/npcs.lua b/tests/api3_contract/npcs.lua index 7ee2a243f..1e3170249 100644 --- a/tests/api3_contract/npcs.lua +++ b/tests/api3_contract/npcs.lua @@ -1,9 +1,9 @@ -- Real loaders/native public adapters with native actor/map/timing seams. local M = {} local unpack = table.unpack or unpack -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, modId) local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/npcs%.lua$") - local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "npcs") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, modId or "npcs") local Runtime = require("src.mods.Runtime") love = love or require("tests.love_stub") -- Render construction has no bearing on the public state contract. From db524f3e7c733b855e2cf25f9f89f2030a1f7104 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:33:35 +0100 Subject: [PATCH 56/67] compat(M7): align Gen2 registry descriptors and isolate native limits --- docs/gen2-registry-contracts.md | 30 +++++++++++++++ src/mods/ApricornContent.lua | 4 ++ src/mods/DecorationContent.lua | 1 + src/mods/LandmarkContent.lua | 1 + src/mods/Schemas.lua | 8 ++-- tests/engine/gen2_apricorn_content.lua | 1 + tools/check_gen2_registry_contract.py | 49 +++++++++++++++++++++++++ tools/gen2-registry-contract/fields.lua | 18 +++++++++ 8 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 docs/gen2-registry-contracts.md create mode 100644 tools/check_gen2_registry_contract.py create mode 100644 tools/gen2-registry-contract/fields.lua diff --git a/docs/gen2-registry-contracts.md b/docs/gen2-registry-contracts.md new file mode 100644 index 000000000..435b17f30 --- /dev/null +++ b/docs/gen2-registry-contracts.md @@ -0,0 +1,30 @@ +# Gen2 registry contracts + +The `apricorns`, `decorations`, `landmarks`, `phone_contacts` and +`radio_channels` record descriptors match the pinned `535a5ec` Gen2 contract. +Their native adapters, imported built-ins, merge ownership and rollback already +exist; this change aligns the remaining descriptor bounds without replacing +those implementations. Gen3 decoration and landmark adapters are outside this +Gen2 contract. + +Record validation and native execution have separate constraints. Native phone +indices remain 1..255 with original rows and holes reserved; landmark indices +remain bytes. Native apricorn event operands and decoration ownership events +remain 0..65534; 65535 has a variable-operand meaning in native scripts. +Apricorn ordering indices remain 1..65535. The adapters reject these unsupported +values before recording the contribution, so errors remain attributable and +earlier writes by the same failed owner roll back. Finite-value, reference, +collision and canonical-ID checks remain in place. + +Landmark coordinates retain their existing native OAM units. This change does +not reinterpret previously authored records. Public record fields, merge +semantics and existing valid registrations keep their behavior. + +Run `tools/check_gen2_registry_contract.py --reference +--lua --report /fields.json` to compare the complete Gen2 +field descriptors, including optionality, bounds and referenced registries. +The same owned inspector runs unchanged in both checkouts. Native content +suites separately exercise the real Loader, merge targets and consumers. + +This is load-time validation. It adds no per-frame operation or performance +flag. ARM performance has not been measured. diff --git a/src/mods/ApricornContent.lua b/src/mods/ApricornContent.lua index bd55a63be..2146365c6 100644 --- a/src/mods/ApricornContent.lua +++ b/src/mods/ApricornContent.lua @@ -41,6 +41,10 @@ local function row(loader,id,value,defer) for k in pairs(value)do assert(registry.spec.fields[k],'apricorns: unknown field '..tostring(k))end local ok,why=require('src.mods.Schemas').check(registry.spec,'apricorns',id,value,'register') assert(ok,why) + -- The public schema describes records; native script operands have a + -- narrower space (0xffff selects the halfword variable in setevent). + assert(value.event<=65534,'apricorns: native event must be 0..65534') + assert(value.index<=65535,'apricorns: native recipe index must be 1..65535') local function resolve(id) if not defer then return nativeItem(loader,id) end local found,key=pcall(nativeItem,loader,id) diff --git a/src/mods/DecorationContent.lua b/src/mods/DecorationContent.lua index e6d1ddd16..059e0f790 100644 --- a/src/mods/DecorationContent.lua +++ b/src/mods/DecorationContent.lua @@ -33,6 +33,7 @@ local function record(registry,id,value) assert(type(value)=='table'and value~=Merge.DELETE,'decorations: expected record') for key in pairs(value)do assert(registry.spec.fields[key],'decorations: unknown field '..tostring(key))end local ok,why=Schemas.check(registry.spec,'decorations',id,value,'register');assert(ok,why) + assert(value.flag<=65534,'decorations: native ownership event must be 0..65534') assert(value.action==nil or Native.action(value.action),'decorations: unknown native action') return copy(value) end diff --git a/src/mods/LandmarkContent.lua b/src/mods/LandmarkContent.lua index 1b45322bb..eb18aa634 100644 --- a/src/mods/LandmarkContent.lua +++ b/src/mods/LandmarkContent.lua @@ -30,6 +30,7 @@ local function record(registry,id,value) finite(value) for key in pairs(value)do assert(registry.spec.fields[key],'landmarks: unknown field '..tostring(key))end local ok,why=Schemas.check(registry.spec,'landmarks',id,value,'register');assert(ok,why) + assert(index(value.index),'landmarks: native index must be a byte') return copy(value) end function M.prepare(loader,data) diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index d79a49459..f5387d823 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -1260,7 +1260,7 @@ R.text_pointers = { R.decorations = { semantics="record",generations="2", - fields={type=f.int(1,6),name=f.str,action=f.opt(f.str),flag=f.int(0,65534),sprite=f.int(0,255)}, + fields={type=f.int(1,6),name=f.str,action=f.opt(f.str),flag=f.int(0),sprite=f.int(0,255)}, example='mod.content.decorations:patch("deco:2", {name="CUSTOM"})', notes="Native imported GSC DECO bytes (deco:0..255), ownership event bits, room callbacks and house-PC selection. Action must be a known SET_UP_/PUT_AWAY_ native decoration action. No retail attribute catalogue is bundled. Requires a complete imported native attribute table.", } @@ -1270,7 +1270,7 @@ R.decorations = { R.phone_contacts = { semantics = "record", generations = "2", fields = { - index=f.opt(f.int(1,255)), number=f.opt(f.int(0,255)), + index=f.opt(f.int(0,255)), number=f.opt(f.int(0,255)), name=f.opt(f.str), class=f.opt(f.str), member=f.opt(f.str), map=f.opt(f.id("maps")), rematchEvent=f.opt(f.int(0,2047)), calleeTime=f.opt(f.int(0,7)), callerTime=f.opt(f.int(0,7)), @@ -1282,14 +1282,14 @@ R.phone_contacts = { R.apricorns = { semantics="record", generations="2", - fields={apricorn=f.id("items"),ball=f.id("items"),event=f.int(0,65534),index=f.int(1,65535)}, + fields={apricorn=f.id("items"),ball=f.id("items"),event=f.int(0),index=f.int(1)}, example='mod.content.apricorns:patch("RED_APRICORN", {ball="ULTRA_BALL"})', notes="Native Gen2 recipe records bind real Kurt item selection, event flags and ball delivery. A current native Kurt program import and indexed GSC item IDs are required. New recipes preserve the selected native item byte; generated native branches retain original dialogue/day flags/refusal behavior.", } R.landmarks = { semantics="record",generations="2", - fields={id=f.opt(f.str),name=f.str,x=f.int(0),y=f.int(0),index=f.int(0,255)}, + fields={id=f.opt(f.str),name=f.str,x=f.int(0),y=f.int(0),index=f.int(0)}, example='mod.content.landmarks:patch("LANDMARK_ROUTE_29", {x=80})', notes="Native Gen2 town-map records and script landmark names. Coordinates retain native OAM byte units, not tile cells. The index is the map-header byte. Native identities win index collisions; otherwise lexical ID order is deterministic. Imported identity metadata is independent of display names.", } diff --git a/tests/engine/gen2_apricorn_content.lua b/tests/engine/gen2_apricorn_content.lua index c780450a4..687ad29c8 100644 --- a/tests/engine/gen2_apricorn_content.lua +++ b/tests/engine/gen2_apricorn_content.lua @@ -151,6 +151,7 @@ end V.set('crystal') for _,bad in ipairs({ "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=65535,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=5000,index=65536})", "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=-1,index=8})", "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=0/0,index=8})", "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=5000,index=math.huge})", diff --git a/tools/check_gen2_registry_contract.py b/tools/check_gen2_registry_contract.py new file mode 100644 index 000000000..1dd52e393 --- /dev/null +++ b/tools/check_gen2_registry_contract.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Compare the five public Gen2 registry descriptors with a pinned backend.""" +import argparse +import hashlib +import json +from pathlib import Path +import sys + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--reference', type=Path, required=True) + parser.add_argument('--lua', required=True) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + args=parser.parse_args() + root=Path(__file__).resolve().parents[1] + sys.path.insert(0,str(args.qa_tools or root/'tools')) + from qa_storage import qa_source, qa_cache, qa_run + reference=args.reference.resolve() + qa_source(root); qa_source(reference); qa_source(args.lua) + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True,exist_ok=True) + ref=qa_run(['git','rev-parse','HEAD'],cwd=reference,capture_output=True,text=True,check=True).stdout.strip() + status=qa_run(['git','status','--porcelain','--untracked-files=no'],cwd=reference, + capture_output=True,text=True,check=True).stdout + if status.strip() or ref!='535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91': + raise RuntimeError('reference must be the clean pinned contract') + worker=root/'tools/gen2-registry-contract/fields.lua' + digest=hashlib.sha256(worker.read_bytes()).hexdigest() + results=[] + for engine in (reference,root): + run=qa_run([args.lua,str(worker)],cwd=engine,capture_output=True,text=True,timeout=60) + records=[line.removeprefix('REGISTRY_CONTRACT ') for line in run.stdout.splitlines() + if line.startswith('REGISTRY_CONTRACT ')] + if run.returncode or len(records)!=1: + raise RuntimeError(run.stdout+run.stderr) + results.append(json.loads(records[0])) + if results[0]!=results[1]: + raise RuntimeError('public registry descriptors differ: '+json.dumps(results,sort_keys=True)) + if hashlib.sha256(worker.read_bytes()).hexdigest()!=digest: + raise RuntimeError('contract consumer changed during the comparison') + args.report.write_text(json.dumps({'reference':ref,'consumerSha256':digest, + 'registries':results[1]},indent=2)+'\n') + print('PASS: five identical Gen2 registry descriptors') + + +if __name__=='__main__': + main() diff --git a/tools/gen2-registry-contract/fields.lua b/tools/gen2-registry-contract/fields.lua new file mode 100644 index 000000000..025db5579 --- /dev/null +++ b/tools/gen2-registry-contract/fields.lua @@ -0,0 +1,18 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Inspect public Gen2 descriptors without executing another engine's bodies. +package.path='./?.lua;./?/init.lua;'..package.path +local Schemas=require('src.mods.Schemas') +local Json=require('src.link.Json') +local function describe(field) + local out={kind=field.kind,min=field.min,max=field.max,registry=field.registry} + if field.inner then out.inner=describe(field.inner)end + return out +end +local out={} +for _,name in ipairs({'apricorns','decorations','landmarks','phone_contacts','radio_channels'})do + local spec=assert(Schemas.REGISTRIES[name],name) + local entry={semantics=spec.semantics,fields={}} + for key,field in pairs(spec.fields)do entry.fields[key]=describe(field)end + out[name]=entry +end +print('REGISTRY_CONTRACT '..Json.encode(out)) From 19316eb6c5c368f0e57a36da8d5c84da6b25ca29 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:35:43 +0100 Subject: [PATCH 57/67] Measure registry validation with a guarded native FrameProfile probe --- tools/check_gen2_registry_contract.py | 30 +++++++++++++-- tools/gen2-registry-contract/conf.lua | 7 ++++ tools/gen2-registry-contract/main.lua | 55 +++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 tools/gen2-registry-contract/conf.lua create mode 100644 tools/gen2-registry-contract/main.lua diff --git a/tools/check_gen2_registry_contract.py b/tools/check_gen2_registry_contract.py index 1dd52e393..a20dee711 100644 --- a/tools/check_gen2_registry_contract.py +++ b/tools/check_gen2_registry_contract.py @@ -3,8 +3,10 @@ import argparse import hashlib import json +import os from pathlib import Path import sys +import time def main(): @@ -13,12 +15,17 @@ def main(): parser.add_argument('--lua', required=True) parser.add_argument('--report', type=Path, required=True) parser.add_argument('--qa-tools', type=Path) + parser.add_argument('--love', type=Path) + parser.add_argument('--baseline', help='local commit for the optional native validation profile') args=parser.parse_args() root=Path(__file__).resolve().parents[1] sys.path.insert(0,str(args.qa_tools or root/'tools')) - from qa_storage import qa_source, qa_cache, qa_run + from qa_storage import qa_source, qa_cache, qa_profile, qa_run reference=args.reference.resolve() qa_source(root); qa_source(reference); qa_source(args.lua) + if args.love: + if not args.baseline: parser.error('--love requires --baseline') + qa_source(args.love.resolve()) qa_cache(args.report.parent) args.report.parent.mkdir(parents=True,exist_ok=True) ref=qa_run(['git','rev-parse','HEAD'],cwd=reference,capture_output=True,text=True,check=True).stdout.strip() @@ -40,8 +47,25 @@ def main(): raise RuntimeError('public registry descriptors differ: '+json.dumps(results,sort_keys=True)) if hashlib.sha256(worker.read_bytes()).hexdigest()!=digest: raise RuntimeError('contract consumer changed during the comparison') - args.report.write_text(json.dumps({'reference':ref,'consumerSha256':digest, - 'registries':results[1]},indent=2)+'\n') + report={'reference':ref,'consumerSha256':digest,'registries':results[1]} + if args.love: + baseline=qa_run(['git','show',args.baseline+':src/mods/Schemas.lua'],cwd=root, + capture_output=True,check=True).stdout + path=args.report.parent/'baseline-schemas.lua' + path.write_bytes(baseline) + identity='oasc-gen2-registry-qa-'+str(time.time_ns()) + qa_profile(Path.home()/'Library/Application Support/LOVE'/identity) + run=qa_run([str(args.love.resolve()),str(root/'tools/gen2-registry-contract')],cwd=root, + env=dict(os.environ,GEN2_REGISTRY_QA='1',GEN2_REGISTRY_ROOT=str(root), + GEN2_REGISTRY_BASELINE=str(path.resolve()),POKEPORT_IDENTITY=identity), + capture_output=True,text=True,timeout=60) + args.report.with_suffix('.log').write_text(run.stdout+run.stderr) + profiles=[line.removeprefix('REGISTRY_PROFILE ')for line in run.stdout.splitlines() + if line.startswith('REGISTRY_PROFILE ')] + if run.returncode or len(profiles)!=1:raise RuntimeError('native registry probe failed') + report['profile']=json.loads(profiles[0]) + report['baseline']=args.baseline + args.report.write_text(json.dumps(report,indent=2)+'\n') print('PASS: five identical Gen2 registry descriptors') diff --git a/tools/gen2-registry-contract/conf.lua b/tools/gen2-registry-contract/conf.lua new file mode 100644 index 000000000..a414765a8 --- /dev/null +++ b/tools/gen2-registry-contract/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv('GEN2_REGISTRY_QA')=='1','use the guarded registry checker') + t.identity=assert(os.getenv('POKEPORT_IDENTITY')) + t.window.title='Gen2 registry validation profile' + t.window.width,t.window.height,t.window.vsync=480,240,1 + t.modules.audio=false +end diff --git a/tools/gen2-registry-contract/main.lua b/tools/gen2-registry-contract/main.lua new file mode 100644 index 000000000..4a52777ac --- /dev/null +++ b/tools/gen2-registry-contract/main.lua @@ -0,0 +1,55 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local root=assert(os.getenv('GEN2_REGISTRY_ROOT')) +package.path=root..'/?.lua;'..root..'/?/init.lua;'..package.path +local Profile=require('src.core.FrameProfile') +local Json=require('src.link.Json') +local file=assert(io.open(assert(os.getenv('GEN2_REGISTRY_BASELINE')),'rb')) +local before=assert(loadstring(file:read('*a'),'@baseline/Schemas.lua'))() +file:close() +local after=require('src.mods.Schemas') +local rows={ + apricorns={apricorn='FRUIT',ball='BALL',event=600,index=1}, + decorations={type=2,name='BED',action='SET_UP_BED',flag=500,sprite=0}, + landmarks={name='HOME',x=80,y=72,index=1}, + phone_contacts={index=2,name='CALLER',number=2,calleeTime=0,callerTime=0}, + radio_channels={channel=9,name='STATION'}, +} +local phase,frame,total=1,0,0 +local results={} +function love.errorhandler(message) + print(debug.traceback(tostring(message),2)) + return function()return 1 end +end +function love.run() + return function() + love.event.pump() + for name in love.event.poll()do if name=='quit'then return 1 end end + if frame==120 then Profile.setEnabled(true)end + local schema=phase==1 and before or after + local close=Profile.section('mods.registry.validate (320 records)') + local began=love.timer.getTime() + for _=1,64 do + for name,row in pairs(rows)do + assert(schema.check(schema.REGISTRIES[name],name,'QA',row,'register')) + end + end + if frame>=120 then total=total+love.timer.getTime()-began end + close() + love.graphics.clear(.03,.04,.06) + love.graphics.print('Gen2 registry validation: '..(phase==1 and 'before' or 'after'),12,12) + love.graphics.present() + if frame>=120 then Profile.frame()end + frame=frame+1 + if frame==240 then + results[phase]={mode=phase==1 and 'before' or 'after',frames=120,meanMs=total/120*1000} + if phase==2 then + print('REGISTRY_PROFILE '..Json.encode({phases=results,renderer=love.graphics.getRendererInfo(), + os=love.system.getOS(),arch=jit.arch, + scope='Owned records, native Love host and FrameProfile section; schema validation only, no gameplay scene'})) + return 0 + end + Profile.setEnabled(false) + phase,frame,total=2,0,0 + end + end +end From c68d4558c3d6238a23d3ebe7d3fccf22e6094846 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 58/67] 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 1d827882b2562e71a245d449db9e4cce6cc46209 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 59/67] 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 f43ad90d4a35452dc926942ba6fa937ba91fa8ef 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 60/67] 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 d05e89171e8547b8ddb93647d0e409c4a78af57d 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 61/67] 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 2c1c2276fc0c688a6a9758f8f34bb97dec5f14c2 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 62/67] 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 72719467b83e4c44977cf1db48f03950b1c3cc7d 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 63/67] 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 9bd45a9b2e5cf3949e9e2fd9d380814c142be742 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 64/67] 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 c14237db2ae0f92e5a590c52367b63e43a7235e2 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 04:19:27 +0100 Subject: [PATCH 65/67] 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 5b0669bba361cdb7d20ebb26e2c199b27ad33ed0 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 66/67] 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 36d78277688d3a34ddbfee9c6ad466ccedbaa871 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:59:04 +0100 Subject: [PATCH 67/67] Describe native registry prerequisite callbacks in capability and boundary catalogs --- src/mods/Capabilities.lua | 2 ++ src/mods/UnifiedCallbacks.lua | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua index 32da142d7..4a6f182f9 100644 --- a/src/mods/Capabilities.lua +++ b/src/mods/Capabilities.lua @@ -33,6 +33,7 @@ add("hooks", "123", "battle.overlay ui.naming.grid ui.party.submenu ui.start_men add("hooks", "12", "ui.pc.items") add("hooks", "45", "battle.status_hud_visible") add("hooks", "12", "intro.oak_speech.build") +add("hooks", "2", "phone.contact_list") add("events", "12345", [[ assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt @@ -48,6 +49,7 @@ add("events", "12345", [[ world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged ]]) add("events", "2345", "pokemon.egg_hatched") +add("events", "2", "apricorn.converted phone.call_received radio.channel") add("events", "12", [[ intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started intro.oak_speech.step diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua index 5e6a6195d..52acde317 100644 --- a/src/mods/UnifiedCallbacks.lua +++ b/src/mods/UnifiedCallbacks.lua @@ -16,6 +16,7 @@ end -- This is an audited boundary catalog, not a claim that every game executes -- every seam. The source-catalog test requires a decision for each new seam. C.hooks = names([[ + phone.contact_list 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 @@ -26,6 +27,7 @@ C.hooks = names([[ ui.title_menu.items warp.destination world.tod zoom.range ]]) C.events = names([[ + apricorn.converted phone.call_received radio.channel 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