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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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 602302a8362cea3e0238b4cf7d53b5226f72f269 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:50:10 +0200 Subject: [PATCH 16/42] Add guarded API 3 wild encounters and shared contracts --- docs/api3-portability.md | 9 +- docs/api3-wild-battle.md | 66 +++++++ docs/modding.md | 3 +- src/mods/Api3.lua | 9 +- src/mods/Api3WildBattle.lua | 177 +++++++++++++++++ src/mods/Loader.lua | 4 + tests/api3_contract/mods/wild_battle/main.lua | 16 ++ .../mods/wild_battle/manifest.json | 1 + tests/api3_contract/wild_battle.lua | 128 +++++++++++++ tests/api3_contract/worker.lua | 5 +- tests/engine/api3_compat.lua | 2 +- tests/engine/api3_wild_battle.lua | 179 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 13 files changed, 591 insertions(+), 10 deletions(-) create mode 100644 docs/api3-wild-battle.md create mode 100644 src/mods/Api3WildBattle.lua create mode 100644 tests/api3_contract/mods/wild_battle/main.lua create mode 100644 tests/api3_contract/mods/wild_battle/manifest.json create mode 100644 tests/api3_contract/wild_battle.lua create mode 100644 tests/engine/api3_wild_battle.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 5065c4c1a..2e11c84cd 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -81,11 +81,12 @@ The common world calls currently enabled for generations 1–3 are: - [`toggleObject` and `replaceBlock`](api3-world-edits.md) for validated native 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. + ownership-aware removal and opaque live actor controls; +- [`startWildBattle`](api3-wild-battle.md) for validated native encounters. Check `mod:supports("world", name)` before relying on a call. The other Gen1 world method names remain callable but explicitly return -`nil, "unsupported: ..."`: `startWildBattle` and `invalidateMap` have not yet been verified against the +`nil, "unsupported: ..."`: `invalidateMap` has 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. @@ -132,8 +133,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 and eight NPC cases extend this to 48 comparisons -and 96 isolated loader runs. It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 56 comparisons +and 112 isolated loader runs (including eight wild-battle cases). 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 diff --git a/docs/api3-wild-battle.md b/docs/api3-wild-battle.md new file mode 100644 index 000000000..d6896e2f5 --- /dev/null +++ b/docs/api3-wild-battle.md @@ -0,0 +1,66 @@ +# API-3 wild encounters + +`mod.world:startWildBattle(species, level, onDone?)` starts a native wild +encounter for generations 1–3. Check `mod:supports("world", "startWildBattle")`. +Capture `mod.world` at entry and call later from an idle world. Gen4/5 decline; +API-1/2 behavior and the portable script vocabulary remain unchanged. + +```lua +local mod = ... +local world = mod.world +local function encounter() + return world:startWildBattle("MEW", 30, function(result) + mod.log:info("Encounter ended: %s", tostring(result)) + end) +end +``` + +The call returns `true` for admission or `nil, reason`. Species must be a +nonempty identifier of at most 256 bytes without control characters; resolve +it through the active dataset's canonical species index. Unknown/ambiguous +species are refused. Level must be a number, a whole integer from 1 to 100; +strings are not coerced. The optional callback must be a function. + +A current save, a healthy non-egg party member and the topmost idle overworld +are required. Menus, battles/transitions, scripts/parallel scripts, movement, +link sessions, controller locks and reentry decline before native construction. +Refused arguments do not consume constructor RNG or mark the species seen. +Reading the facade/early calls never bootstrap Game. + +Use the existing native BattleState constructor, overworld pushBattle and +post-battle afterBattle path. This preserves native enemy creation, entry +presentation/music and the usual evolution/blackout handling. Do not substitute +a hand-built battle screen. The host can still refuse or throw after native +construction; already entered native effects are not a transactional patch. + +Completion is once per battle, after native afterBattle has run. Native result +arguments, including nil holes, reach the optional callback. Synchronous native +completion is deferred until admission/guard release; rejected admission never +calls it. Callback errors are contained and attributed once. A completion may +start a later action subject to its normal idle-world checks. + +Captured Game/save/data/stack/world/player/version/bus and owner identities are +revalidated. Observed session changes permanently retire pending mod callbacks; +input.step observes them before and after the native update. Rollback, reload +and teardown retire the owner's callback/facade. An already admitted native +battle is not removed when a mod unloads: its native cleanup still runs if the +original game/session remains current. A late result cannot run cleanup against +a replaced session. Native cleanup failure is reported and suppresses the mod +callback; duplicate completion does not repeat native cleanup. + +## Verification + +Run `luajit tests/engine/api3_wild_battle.lua` and the pinned cross-engine checker +from [API-3 portability](api3-portability.md). The identical public fixture runs +on both production loaders for Red, Blue, Yellow, Gold, Silver, Crystal, +FireRed and Emerald, extending the checker to 56 comparisons / 112 isolated +runs. It covers early calls, invalid arguments, healthy-party checks, native +admission, busy-world refusal and synchronous/asynchronous callback results. +Native battle construction/entry/timing are seams in the shared driver. + +Local tests also exercise actual host BattleState/Pokemon construction and +Pokedex seen updates on synthetic datasets, session replacement/restore, +mod rollback/reload/unload, native failure, callback replay/errors and guard +release. Transition and afterBattle entry are instrumented seams; these are +ROM-free runtime boundary tests, not a retail-ROM battle/evolution/blackout +playthrough or rendered transition equivalence. diff --git a/docs/modding.md b/docs/modding.md index d6169c421..0c610a201 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -25,7 +25,8 @@ 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, [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. +[API-3 NPC actions](api3-npc.md) for runtime actors and movement handles, +and [API-3 wild encounters](api3-wild-battle.md) for native battle admission. ## Feature queries (`mod:supports`) diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index 8e678aca5..9ac146e65 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -5,6 +5,7 @@ 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 Wild = require("src.mods.Api3WildBattle") local copy = require("src.mods.Merge").deepCopy local A = {} local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty @@ -15,7 +16,7 @@ local SUPPORTED = {} for name in ([[overworld current activeBlockAt party canReorderParty reorderParty availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters - setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock spawnNpc removeNpc npc]]):gmatch( + setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock spawnNpc removeNpc npc startWildBattle]]):gmatch( "%S+" ) do @@ -83,10 +84,11 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) end return nativeWorld end - local warp, licensed, npc + local warp, licensed, npc, wild if supportedGeneration then warp, licensed = Warp.new(loader, api.id, gen, current, native) npc = NPC.new(loader, api.id, gen, current, native, licensed, engineRequire) + wild = Wild.new(loader, api.id, current, native, licensed, engineRequire) end local function dispatch(name, ...) if not supportedGeneration or not SUPPORTED[name] then @@ -95,6 +97,9 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) if name == "spawnNpc" or name == "removeNpc" or name == "npc" then return npc(name, ...) end + if name == "startWildBattle" then + return wild(...) + end if name == "warpTo" then return warp(...) end diff --git a/src/mods/Api3WildBattle.lua b/src/mods/Api3WildBattle.lua new file mode 100644 index 000000000..0cb0ba143 --- /dev/null +++ b/src/mods/Api3WildBattle.lua @@ -0,0 +1,177 @@ +-- API-3 encounter admission over native construction, transition and afterBattle. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Edits = require("src.mods.Api3WorldEdits") +local Ids = require("src.mods.UnifiedIds") +local Party = require("src.pokemon.Party") +local B = {} +local pending, observers = {}, setmetatable({}, { __mode = "k" }) +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function text(value) + local ok, result = pcall(tostring, value) + return ok and result or "unprintable error" +end +local function retire(token) + token.retired, token.callback = true, nil + pending[token] = nil +end +function B.cancelAll(loader, owner) + for token in pairs(pending) do + if (not loader or loader == token.loader) and (not owner or owner == token.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) + if observers[loader] == loader.hooks then + return + end + observers[loader] = loader.hooks + loader.hooks:wrap("input.step", function(nextFn, ...) + 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_wild_battle") +end +function B.new(loader, owner, current, native, licensed, req) + local ids = Ids.new(loader) + observe(loader) + return function(species, level, callback) + if not licensed() then + return nil, "world facade is retired" + end + local game = current() + if not game then + return nil, "no game" + end + local ow = native(game):overworld() + if not ow then + return nil, "no overworld" + end + local ok, reason = Edits.ready(game, ow) + if not ok then + return nil, reason + end + for token in pairs(pending) do + if token.game == game then + if token.valid() then + return nil, "world is busy" + end + retire(token) + end + end + if type(species) ~= "string" or #species == 0 or #species > 256 or species:find("%c") then + return nil, "invalid species id" + end + if type(level) ~= "number" or level ~= level or level < 1 or level > 100 or level % 1 ~= 0 then + return nil, "level must be a whole number 1..100" + end + if callback ~= nil and type(callback) ~= "function" then + return nil, "invalid callback" + end + local group, why = ids:resolve("pokemon", species) + if not group then + return nil, why:find("^ambiguous") and why or "unknown species: " .. species + end + local key = group.key + if not (game.data.pokemon and game.data.pokemon[key]) then + return nil, "unknown species: " .. species + end + if not Party.firstHealthy(game.save.party or {}) then + return nil, "no healthy party" + end + if type(ow.pushBattle) ~= "function" or type(ow.afterBattle) ~= "function" then + return nil, "battle runtime unavailable" + end + local save, data, stack, player, world = game.save, game.data, game.stack, ow.player, game.overworld + local version, hooks, events = Version.get(), Runtime.hooks, Runtime.events + local token = { loader = loader, owner = owner, game = game, callback = callback } + local function live() + return current() == game + and game.save == save + and game.data == data + and game.stack == stack + and game.overworld == world + and ow.player == player + and native(game):overworld() == ow + and Version.get() == version + end + function token.valid() + return not token.retired + and licensed() + and live() + and Runtime.hooks == hooks + and Runtime.events == events + end + local admitted, deferred, finished, battle + local function finish(...) + if finished then + return + end + if admitted == nil then + if not deferred then + deferred = pack(...) + end + return + end + finished = true + local valid, fn = token.valid(), token.callback + retire(token) + if not admitted or not live() then + return + end + -- Native cleanup still runs after the originating mod has been unloaded. + local called, err = pcall(ow.afterBattle, ow, (...), battle) + if not called then + Runtime.reportError(owner, "world battle cleanup failed: " .. text(err)) + return + end + if valid and licensed() and live() and fn then + local previous = Runtime.currentMod + Runtime.currentMod = owner + called, err = pcall(fn, ...) + Runtime.currentMod = previous + if not called then + Runtime.reportError(owner, "world battle callback failed: " .. text(err)) + end + end + end + pending[token] = true + local called, accepted, err = pcall(Edits.run, game, function() + battle = req("src.battle.BattleState").newWild(game, key, level) + if not battle or battle.dead then + return nil, "battle failed" + end + battle.onFinish = finish + local value, detail = ow:pushBattle(battle) + if value == false or value == nil and detail ~= nil then + return nil, detail or "battle failed" + end + return true + end) + admitted = called and accepted == true + if not admitted then + retire(token) + return nil, called and (err or "battle failed") or "world operation failed: " .. text(accepted) + end + if deferred then + finish(unpack(deferred, 1, deferred.n)) + end + return true + end +end +return B diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index c24867e06..c2ccabb47 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -14,6 +14,7 @@ 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 Api3WildBattle = require("src.mods.Api3WildBattle") local AssetTransform = require("src.mods.AssetTransform") local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") @@ -1264,6 +1265,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) + Api3WildBattle.cancelAll(self, modId) Api3NPC.cancelAll(self, modId) Api3Warp.cancelAll(self, modId) PortableScripts.cancelAll(self, modId, "mod rolled back") @@ -1381,6 +1383,7 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + Api3WildBattle.cancelAll() Api3NPC.cancelAll() Api3Warp.cancelAll() PortableScripts.cancelAll(nil, nil, "mods reloaded") @@ -1727,6 +1730,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() + Api3WildBattle.cancelAll() Api3NPC.cancelAll() Api3Warp.cancelAll() PortableScripts.cancelAll(nil, nil, "mods unloaded") diff --git a/tests/api3_contract/mods/wild_battle/main.lua b/tests/api3_contract/mods/wild_battle/main.lua new file mode 100644 index 000000000..ae198c01d --- /dev/null +++ b/tests/api3_contract/mods/wild_battle/main.lua @@ -0,0 +1,16 @@ +local mod = ... +local world = mod.world +assert(mod:supports("world", "startWildBattle")) +local ok, why = world:startWildBattle("MEW", 5) +assert(ok == nil and why == "no game") +local calls, args = 0 +mod.exports.request = function(...) + return world:startWildBattle(...) +end +mod.exports.notify = 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/wild_battle/manifest.json b/tests/api3_contract/mods/wild_battle/manifest.json new file mode 100644 index 000000000..492c88378 --- /dev/null +++ b/tests/api3_contract/mods/wild_battle/manifest.json @@ -0,0 +1 @@ +{"id":"wild_battle","name":"Wild battle contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/wild_battle.lua b/tests/api3_contract/wild_battle.lua new file mode 100644 index 000000000..bbfa86a1f --- /dev/null +++ b/tests/api3_contract/wild_battle.lua @@ -0,0 +1,128 @@ +-- Production public facades/loaders; native battle entry/timing seams only. +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/wild_battle%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "wild_battle") + love = love or require("tests.love_stub") + f.game.save.party = { { species = "MEW", hp = 20, level = 10, stats = { hp = 20 } } } + f.game.save.pokedex = { seen = {}, caught = {} } + f.constructed, f.pushed, f.after = 0, 0, 0 + function f.ow:afterBattle(result, battle) + f.after = f.after + 1 + f.lastResult, f.cleanedBattle = result, battle + if f.cleanupError then + error("native cleanup probe", 0) + end + end + function f:admit(battle, callback, nativeCleanup) + self.pushed = self.pushed + 1 + self.battle, self.finish, self.nativeCleanup = battle, callback, nativeCleanup + self.busy = true + self.game.stack.states[2] = battle + if self.synchronous then + self:complete("win", nil, "tail") + end + if self.pushFailure then + return false, "battle failed" + end + -- Native host admission is void on success. + end + function f:complete(...) + self.busy = false + self.game.stack.states[2] = nil + if self.nativeCleanup and not self.cleaned then + self.cleaned = true + self.ow:afterBattle((...), self.battle) + end + return self.finish(...) + end + local original = package.loaded["src.battle.BattleState"] + local function construct(game, species, level) + f.constructed = f.constructed + 1 + game.save.pokedex.seen[species] = true + if f.constructError then + error("native constructor probe", 0) + end + return { kind = "wild", species = species, level = level } + end + package.loaded["src.battle.BattleState"] = { newWild = construct } + f.restore = function() + package.loaded["src.battle.BattleState"] = original + end + function f.ow:pushBattle(battle) + return f:admit(battle, battle.onFinish, false) + end + if backend == "gen1" and f.gen == 2 then + package.loaded["src.battle.gen2.Mon"] = { + new = function(data, species, level) + return construct(f.game, species, level) + end, + } + function f.ow:startBattle(spec, onDone) + local result, reason = f:admit(spec.wild, onDone or function() end, true) + if result == false then + return result, reason + end + return true + end + elseif backend == "gen1" and f.gen == 3 then + local pokemon = require("src.core.game3.pokemon") + for key, value in pairs(f.game.data.gen3Pokemon) do + pokemon[key] = value + end + pokemon._byName = { MEW = 151 } + package.loaded["src.core.game3.battle_bridge"] = { + startWild = function(_, game, spec, opts) + local b = construct(game, spec.species, spec.level) + local result, reason = f:admit(b, opts and opts.done or function() end, true) + if result == false then + return result, reason + end + return true + end, + } + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local report = { invalid = {} } + for _, args in ipairs({ + { "", 5 }, + { "NO_SUCH_SPECIES", 5 }, + { "MEW", 0 }, + { "MEW", 1.5 }, + { "MEW", 101 }, + { "MEW", 5, false }, + }) do + local value, why = f.exports.request(unpack(args)) + assert(value == nil, "invalid battle accepted") + report.invalid[#report.invalid + 1] = why + assert(f.constructed == 0 and f.pushed == 0 and next(f.game.save.pokedex.seen) == nil) + end + f.game.save.party[1].hp = 0 + local value, why = f.exports.request("MEW", 5) + assert(value == nil and why == "no healthy party") + report.empty = why + f.game.save.party[1].hp = 20 + assert(f.exports.request("MEW", 5, f.exports.notify)) + assert(f.battle.level == 5 and f.pushed == 1) + value, why = f.exports.request("MEW", 5) + assert(value == nil and why == "world is busy") + f:complete("win", nil, "tail") + assert(f.after == 1) + f:complete("lose") + local r = f.exports.result() + assert(r.calls == 1 and r.args.n == 3 and r.args.third == "tail") + report.complete = r + f.restore() + f = M.fixture(root, backend, version) + f.synchronous = true + assert(f.exports.request("MEW", 100, f.exports.notify)) + assert(f.exports.result().calls == 1 and f.after == 1) + report.synchronous = f.exports.result() + f.restore() + 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 b288ae8c1..94879f89b 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -13,11 +13,14 @@ assert( or mode == "warp" or mode == "world_edits" or mode == "npcs" + or mode == "wild_battle" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "npcs" then +if mode == "wild_battle" then + dofile(codeRoot .. "/wild_battle.lua").run(root, backend, version) +elseif 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) diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua index 35e00aece..4c8f8ac29 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({ + "startWildBattle", "spawnNpc", "removeNpc", "npc", @@ -151,7 +152,6 @@ do T.eq(m:supports("world", name), true, "implemented world method " .. name) end for _, name in ipairs({ - "startWildBattle", "invalidateMap", }) do local available, why = m:supports("world", name) diff --git a/tests/engine/api3_wild_battle.lua b/tests/engine/api3_wild_battle.lua new file mode 100644 index 000000000..71bafc967 --- /dev/null +++ b/tests/engine/api3_wild_battle.lua @@ -0,0 +1,179 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.wild_battle") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local Battle = require("src.battle.BattleState") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +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 battle contract: " .. tostring(why)) +end +for _, level in ipairs({ -1, 0, 1.5, 101, math.huge, 0 / 0, "5", false, {} }) do + local f = fixture() + T.eq(f.exports.request("MEW", level), nil, "invalid level refused") + T.eq(f.constructed, 0, "invalid level never constructs/marks seen") + f.restore() +end +for _, lock in ipairs({ "overlay", "moving", "script", "parallel", "link", "transition", "no_save" }) do + local f = fixture() + if lock == "overlay" then + f.game.stack.states[2] = {} + elseif lock == "moving" then + f.player.moving = true + elseif lock == "script" then + f.ow.runner.isRunning = function() + return true + end + elseif lock == "parallel" then + f.ow.parallelQueue = { {} } + elseif lock == "link" then + f.game.linkSession = {} + elseif lock == "transition" then + f.ow.transitioning = true + else + f.game.save = nil + end + T.eq(f.exports.request("MEW", 5), nil, lock .. " blocks battle") + T.eq(f.constructed, 0, lock .. " leaves dex/RNG untouched") + f.restore() +end +for _, change in ipairs({ "game", "save", "data", "stack", "world", "player", "version", "bus" }) do + local f = fixture() + T.check(f.exports.request("MEW", 5, f.exports.notify), "start before " .. change) + local restore + if change == "game" then + f.live = {} + restore = function() + f.live = f.game + end + elseif change == "world" then + f.game.overworld = {} + restore = function() + f.game.overworld = f.ow + end + elseif change == "version" then + Version.set("blue") + restore = function() + Version.set("red") + end + elseif change == "bus" then + local old = Runtime.events + Runtime.events = {} + restore = function() + Runtime.events = old + end + else + local object = change == "player" and f.ow or f.game + local old = object[change] + object[change] = {} + restore = function() + object[change] = old + end + end + f.loader.hooks:call("input.step", function() end) + restore() + f:complete("win") + T.eq(f.exports.result().calls, 0, "observed replacement permanently retires callback") + T.eq(f.after, 1, "restored native session still completes cleanup") + f.restore() +end +for _, action in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + T.check(f.exports.request("MEW", 5, f.exports.notify), "start before " .. action) + if action == "rollback" then + f.loader:_rollback("wild_battle") + elseif action == "reload" then + local nextFixture = fixture() + nextFixture.restore() + else + Loader.teardown() + end + f:complete("caught") + T.eq(f.after, 1, "unloading mod preserves native cleanup") + T.eq(f.exports.result().calls, 0, "unloading retires callback") + T.eq(f.exports.request("MEW", 5), nil, "unloaded facade retired") + f.restore() +end +do + local f = fixture() + T.check(f.exports.request("MEW", 5, f.exports.notify), "start before foreign save") + f.game.save = {} + f:complete("lose") + T.eq(f.after, 0, "late result cannot run cleanup against replaced save") + T.eq(f.exports.result().calls, 0, "late result cannot call mod on replaced save") + f.restore() +end +for _, mode in ipairs({ "constructError", "pushFailure", "cleanupError", "callbackError", "syncRefusal" }) do + local f = fixture() + local callback = f.exports.notify + if mode == "callbackError" then + callback = function() + error("authored callback probe") + end + elseif mode == "syncRefusal" then + f.synchronous, f.pushFailure = true, true + else + f[mode] = true + end + local value = f.exports.request("MEW", 5, callback) + if mode == "constructError" or mode == "pushFailure" or mode == "syncRefusal" then + T.eq(value, nil, "failed admission returns nil") + T.eq(f.after, 0, "failed admission never invokes native completion") + T.eq(f.exports.result().calls, 0, "failed admission never calls mod") + else + T.check(value, "completion-error battle admitted") + T.check(pcall(f.complete, f, "win"), "completion errors contained") + T.eq(#f.loader.errors, 1, "completion error attributed once") + f:complete("win") + T.eq(#f.loader.errors, 1, "replay cannot repeat error") + end + f.restore() +end +do + local f = fixture() + f.synchronous = true + T.check( + f.exports.request("MEW", 5, function(...) + T.eq(Runtime.currentMod, "wild_battle", "completion owner restored") + f.exports.notify(...) + f.synchronous = false + T.check(f.exports.request("MEW", 6), "synchronous completion releases admission guard") + end), + "synchronous battle admitted" + ) + T.eq(f.pushed, 2, "callback can start next battle") + f:complete("run") + f.restore() +end +-- Real native BattleState/Pokemon construction with fixture data. The entry +-- transition and cleanup seams are independent from the public adapter. +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + f.restore() + local data = T.fixtures.fresh() + data.pokemon.MEW = require("src.mods.Merge").deepCopy(data.pokemon.FIXMON_A) + data.pokemon.MEW.id, data.pokemon.MEW.name = "MEW", "MEW" + data.constants.gen = Version.generation(version) + f.game.data = data + f.game.save = require("src.core.SaveData").newGame() + f.game.session = f.game.save + f.game.save.party = { require("src.pokemon.Pokemon").new(data, "FIXMON_A", 15) } + require("src.render.Font").load(data) + T.check(f.exports.request("MEW", 5, f.exports.notify), version .. " native constructor admitted") + T.eq(f.battle.kind, "wild", "native battle kind") + T.eq(f.battle.enemy.mon.species, "MEW", "native enemy species") + T.eq(f.battle.enemy.mon.level, 5, "native level") + T.eq(f.game.save.pokedex.seen.MEW, true, "native constructor marks seen") + f:complete("caught") + T.eq(f.after, 1, "native battle retains cleanup") + T.eq(f.exports.result().calls, 1, "native battle completes public callback") +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 public wild battle") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index fdaef60d3..98b44713d 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", "npcs"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From b191fdcc3d904e51619e250f0c984236e8980aed Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:54:24 +0200 Subject: [PATCH 17/42] Add guarded API 3 native map invalidation --- docs/api3-invalidate-map.md | 57 ++++++++ docs/api3-portability.md | 19 +-- docs/modding.md | 3 +- src/mods/Api3.lua | 4 +- src/mods/Api3WorldEdits.lua | 22 ++- tests/api3_contract/invalidate.lua | 54 ++++++++ tests/api3_contract/mods/invalidate/main.lua | 15 +++ .../mods/invalidate/manifest.json | 1 + tests/api3_contract/worker.lua | 5 +- tests/engine/api3_compat.lua | 13 +- tests/engine/api3_invalidate.lua | 126 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 12 files changed, 295 insertions(+), 26 deletions(-) create mode 100644 docs/api3-invalidate-map.md create mode 100644 tests/api3_contract/invalidate.lua create mode 100644 tests/api3_contract/mods/invalidate/main.lua create mode 100644 tests/api3_contract/mods/invalidate/manifest.json create mode 100644 tests/engine/api3_invalidate.lua diff --git a/docs/api3-invalidate-map.md b/docs/api3-invalidate-map.md new file mode 100644 index 000000000..a815db481 --- /dev/null +++ b/docs/api3-invalidate-map.md @@ -0,0 +1,57 @@ +# API-3 map invalidation + +`mod.world:invalidateMap(mapId)` requests the host's native cache invalidation +and reload for generations 1–3. Capture `mod.world` at entry and call later from +an idle world. Return `true` or `nil, reason`; query availability through +`mod:supports("world", "invalidateMap")`. Gen4/5 decline. API-1/2 behavior and +the portable script vocabulary are unchanged. + +```lua +local mod = ... +local world = mod.world +local function refresh(mapId) + return world:invalidateMap(mapId) +end +``` + +A known map ID is required: a nonempty string up to 256 bytes without control +characters. Invalid or unknown IDs return `nil, "unknown map"`, matching Gen1's +API-3 preflight. Gen3 also resolves unprefixed names using the current version's +FR_/EM_/RU_/SA_ prefix when no exact ID exists. Map IDs remain dataset-specific. + +This shares NPC/object/block mutation admission and reentry protection. A save +and topmost idle overworld are required. Scripts, queued/parallel work, player +movement, transitions, menus, link sessions and controller locks refuse before +cache writes. Missing native reload support declines. Reading the facade never +constructs Game. Rollback/reload/teardown retire retained mutation facades. + +The host's native reloadMap invalidates the target and resident neighbor cache +entries. For the active map it uses setMap with seamless reload, current player +cell/facing and keepMusic. If the retained player cell no longer fits the new +geometry, native reload sends the player to the heal point. Runtime map/actor +instance identity can change, so obtain fresh NPC handles after a reload. +Inactive invalidation does not move the player or force an unrelated active +map reload. Rendering and map lifecycle events remain native, not a new common +renderer/cache implementation. Successful host invalidation emits map.reloaded +with mapId and reason="invalidate". Pinned Gen1's Gen2 backend does not emit +that event for inactive invalidation; this native difference is retained. + +This is a cache/instance operation, not a content-write API. Permanent map +changes belong in content definitions. A native failure returns nil with its +reason but may occur after cache invalidation; already entered native effects +are not transactionally rolled back. + +## Verification + +Run `luajit tests/engine/api3_invalidate.lua` and the pinned checker documented +in [API-3 portability](api3-portability.md). Eight identical public mod cases +extend the full checker to 64 comparisons / 128 isolated loader runs. Real +loaders/native facades compare preflight, active reload, position retention, +notification and inactive-map acceptance; reload timing is a driver seam. + +Host tests additionally run actual OverworldState.reloadMap and +MapLoader.invalidate against a synthetic cache, with the setMap/render entry +instrumented. Verify target/neighbor eviction, seamless/music options, valid +player retention, heal-point fallback, reentry, failure/guard release, busy +worlds and retired owners. No ROM import or rendered gameplay equivalence is +claimed. diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 2e11c84cd..f7ff88609 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -82,14 +82,15 @@ The common world calls currently enabled for generations 1–3 are: 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; -- [`startWildBattle`](api3-wild-battle.md) for validated native encounters. +- [`startWildBattle`](api3-wild-battle.md) for validated native encounters; +- [`invalidateMap`](api3-invalidate-map.md) for guarded native cache/reload actions. -Check `mod:supports("world", name)` before relying on a call. The other -Gen1 world method names remain callable but explicitly return -`nil, "unsupported: ..."`: `invalidateMap` has 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. +Check `mod:supports("world", name)` before relying on a call. All 24 world +method names in the pinned Gen1 API-3 facade now have host adapters for Gen1–3. +This describes API availability, not identical native internals or full +cross-engine behavioral coverage. Native flag mappings and `mod.datasets:open` +remain unavailable; private mod flags are supported. Gen4/5 do not claim this +shared world surface. Private flags use `save.modData[modId].worldFlags`, independently of native flags and other mods. A read does not create a bucket. Clearing removes the @@ -133,8 +134,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 and eight NPC cases extend this to 56 comparisons -and 112 isolated loader runs (including eight wild-battle cases). It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 64 comparisons +and 128 isolated loader runs (including eight wild-battle and eight map-invalidation cases). 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 diff --git a/docs/modding.md b/docs/modding.md index 0c610a201..e2838031f 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -26,7 +26,8 @@ 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, [API-3 object/block edits](api3-world-edits.md) for native visibility and terrain changes, [API-3 NPC actions](api3-npc.md) for runtime actors and movement handles, -and [API-3 wild encounters](api3-wild-battle.md) for native battle admission. +[API-3 wild encounters](api3-wild-battle.md) for native battle admission, +and [API-3 map invalidation](api3-invalidate-map.md) for native cache/reload. ## Feature queries (`mod:supports`) diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index 9ac146e65..b64882e93 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -16,7 +16,7 @@ local SUPPORTED = {} for name in ([[overworld current activeBlockAt party canReorderParty reorderParty availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters - setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock spawnNpc removeNpc npc startWildBattle]]):gmatch( + setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock spawnNpc removeNpc npc startWildBattle invalidateMap]]):gmatch( "%S+" ) do @@ -103,7 +103,7 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) if name == "warpTo" then return warp(...) end - local edit = name == "toggleObject" or name == "replaceBlock" + local edit = name == "toggleObject" or name == "replaceBlock" or name == "invalidateMap" if edit and not licensed() then return nil, "world facade is retired" end diff --git a/src/mods/Api3WorldEdits.lua b/src/mods/Api3WorldEdits.lua index c21586e25..4b48c180d 100644 --- a/src/mods/Api3WorldEdits.lua +++ b/src/mods/Api3WorldEdits.lua @@ -130,6 +130,26 @@ 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 +local function invalidate(game, ow, api, gen, req, mapId) + if not identifier(mapId) then + return nil, "unknown map" + 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 + if not (maps and maps[id]) then + return nil, "unknown map" + end + if type(ow.reloadMap) ~= "function" then + return nil, "map reload unavailable" + end + return api:invalidateMap(id) +end -- Shared admission/commit guard for synchronous API-3 world mutations. function E.ready(game, ow) local ok, reason = Actions.ready(game, ow) @@ -159,7 +179,7 @@ function E.call(game, api, generation, req, name, ...) if not ok then return nil, reason end - local operation = name == "toggleObject" and toggle or replace + local operation = name == "toggleObject" and toggle or name == "invalidateMap" and invalidate or replace return E.run(game, operation, game, ow, api, generation, req, ...) end return E diff --git a/tests/api3_contract/invalidate.lua b/tests/api3_contract/invalidate.lua new file mode 100644 index 000000000..56b11041a --- /dev/null +++ b/tests/api3_contract/invalidate.lua @@ -0,0 +1,54 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/invalidate%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "invalidate") + local Runtime = require("src.mods.Runtime") + f.reloads, f.loads = 0, {} + function f:reload(id, why) + self.reloads = self.reloads + 1 + self.loads[#self.loads + 1] = { id, why } + if self.reloadError then + error("reload probe", 0) + end + if self.ow.map.id == id then + self.ow.map = { id = id, def = self.game.data.maps[id] } + end + end + function f.ow:reloadMap(id, why) + f:reload(id, why) + Runtime.emit("map.reloaded", { mapId = id, reason = why }) + end + function f.ow:reloadMapBadWarp() + f:reload(self.map.id, "invalidate") + end + if backend == "gen1" and f.gen == 3 then + function f.map.load(_, game, id, opts) + assert(game == f.game and opts.x == f.player.cellX and opts.facing == f.player.facing) + f:reload(id, "invalidate") + end + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local report = { invalid = {} } + for _, id in ipairs({ "", "NO_SUCH_MAP", "HOME\n", 42, false }) do + local value, why = f.exports.request(id) + assert(value == nil and why == "unknown map") + assert(f.reloads == 0 and #f.exports.events() == 0) + report.invalid[#report.invalid + 1] = why + end + local old = f.ow.map + assert(f.exports.request(f.home)) + assert(f.reloads == 1 and f.ow.map ~= old) + assert(f.player.cellX == 0 and f.player.cellY == 0 and f.player.facing == "left") + assert(#f.exports.events() == 1 and f.exports.events()[1].mapId == f.home) + report.active = f.exports.events() + f = M.fixture(root, backend, version) + assert(f.exports.request(f.dest)) + assert(f.ow.map.id == f.home) + report.inactive = true -- Native inactive notification differences are not normalized. + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/mods/invalidate/main.lua b/tests/api3_contract/mods/invalidate/main.lua new file mode 100644 index 000000000..efed5c918 --- /dev/null +++ b/tests/api3_contract/mods/invalidate/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local world = mod.world +assert(mod:supports("world", "invalidateMap")) +local value, why = world:invalidateMap("HOME") +assert(value == nil and why == "no game") +local events = {} +mod.events:on("map.reloaded", function(ev) + events[#events + 1] = { mapId = ev.mapId, reason = ev.reason } +end) +mod.exports.request = function(...) + return world:invalidateMap(...) +end +mod.exports.events = function() + return events +end diff --git a/tests/api3_contract/mods/invalidate/manifest.json b/tests/api3_contract/mods/invalidate/manifest.json new file mode 100644 index 000000000..4a1728907 --- /dev/null +++ b/tests/api3_contract/mods/invalidate/manifest.json @@ -0,0 +1 @@ +{"id":"invalidate","name":"Map invalidation contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index 94879f89b..42bcaf08b 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -14,11 +14,14 @@ assert( or mode == "world_edits" or mode == "npcs" or mode == "wild_battle" + or mode == "invalidate" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "wild_battle" then +if mode == "invalidate" then + dofile(codeRoot .. "/invalidate.lua").run(root, backend, version) +elseif mode == "wild_battle" then dofile(codeRoot .. "/wild_battle.lua").run(root, backend, version) elseif mode == "npcs" then dofile(codeRoot .. "/npcs.lua").run(root, backend, version) diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua index 4c8f8ac29..5bc2d8895 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({ + "invalidateMap", "startWildBattle", "spawnNpc", "removeNpc", @@ -151,16 +152,6 @@ do }) do T.eq(m:supports("world", name), true, "implemented world method " .. name) end - for _, name in ipairs({ - "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") @@ -217,7 +208,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) - for _, name in ipairs({ "spawnNpc", "removeNpc", "npc" }) do + for _, name in ipairs({ "spawnNpc", "removeNpc", "npc", "startWildBattle", "invalidateMap" }) 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) diff --git a/tests/engine/api3_invalidate.lua b/tests/engine/api3_invalidate.lua new file mode 100644 index 000000000..ee295e14f --- /dev/null +++ b/tests/engine/api3_invalidate.lua @@ -0,0 +1,126 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.invalidate") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local OW = require("src.world.OverworldController") +local MapLoader = require("src.world.MapLoader") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +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 invalidation contract: " .. tostring(why)) +end +for _, lock in ipairs({ "overlay", "moving", "script", "parallel", "link", "transition", "no_save" }) do + local f = fixture() + if lock == "overlay" then + f.game.stack.states[2] = {} + elseif lock == "moving" then + f.player.moving = true + elseif lock == "script" then + f.ow.runner.isRunning = function() + return true + end + elseif lock == "parallel" then + f.ow.parallelQueue = { {} } + elseif lock == "link" then + f.game.linkSession = {} + elseif lock == "transition" then + f.ow.transitioning = true + else + f.game.save = nil + end + T.eq(f.exports.request(f.home), nil, lock .. " refuses reload") + T.eq(f.reloads, 0, lock .. " has no native effects") +end +for _, bad in ipairs({ {}, 1, 0 / 0, math.huge, false, string.rep("x", 257), "bad\0id" }) do + local f = fixture() + T.eq(f.exports.request(bad), nil, "malformed ID refused") + T.eq(f.reloads, 0, "malformed ID not dispatched") +end +do + local f = fixture() + f.reloadError = true + T.eq(f.exports.request(f.home), nil, "native failure returns refusal") + T.eq(#f.exports.events(), 0, "failed reload emits no success event") + f.reloadError = false + T.check(f.exports.request(f.home), "native exception releases guard") + local native = f.ow.reloadMap + function f.ow:reloadMap(...) + local value, why = f.exports.request(f.home) + T.eq(value, nil, "native callback cannot reenter") + T.eq(why, "world is busy", "reload reentry diagnosed") + return native(self, ...) + end + T.check(f.exports.request(f.home), "outer reload accepted") +end +for _, action in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + if action == "rollback" then + f.loader:_rollback("invalidate") + elseif action == "reload" then + fixture() + else + Loader.teardown() + end + T.eq(f.exports.request(f.home), nil, "retired facade refuses after " .. action) + T.eq(f.reloads, 0, "retired facade makes no native calls") +end +local function upvalue(fn, wanted) + for i = 1, 100 do + local name, value = debug.getupvalue(fn, i) + if name == wanted then + return value, i + end + if not name then + break + end + end + error("native upvalue missing: " .. wanted) +end +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + for _, outside in ipairs({ false, true }) do + local f = fixture(version) + local old, index = upvalue(OW.reloadMap, "Game") + debug.setupvalue(OW.reloadMap, index, f.game) + f.ow.reloadMap = OW.reloadMap + f.ow.neighbors = { { map = { id = f.dest } } } + local cache = upvalue(MapLoader.invalidate, "cache") + cache[f.home], cache[f.dest] = {}, {} + local loads = {} + function f.ow:setMap(id, x, y, facing, opts) + loads[#loads + 1] = { id = id, x = x, y = y, facing = facing, opts = opts } + self.map = { + id = id, + def = f.game.data.maps[id], + inBounds = function() + return not outside + end, + } + end + function f.ow:healPoint() + return { map = f.dest, x = 1, y = 2 } + end + T.check(f.exports.request(f.home), "native reload accepted") + T.eq(cache[f.home], nil, "native target cache evicted") + T.eq(cache[f.dest], nil, "native neighbor cache evicted") + T.eq(loads[1].x, 0, "native reload preserves x") + T.eq(loads[1].facing, "left", "native reload preserves facing") + T.eq(loads[1].opts.keepMusic, true, "native seamless reload keeps music") + T.eq(loads[1].opts.seamless, true, "native seamless reload selected") + T.eq(#loads, outside and 2 or 1, "native out-of-bounds fallback count") + if outside then + T.eq(loads[2].id, f.dest, "invalidated geometry uses heal map") + T.eq(loads[2].facing, "down", "heal fallback orientation") + end + T.eq(#f.exports.events(), 1, "one native reload notification") + debug.setupvalue(OW.reloadMap, index, old) + end +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 map invalidation") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 98b44713d..290c3c3a8 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", "npcs", "wild_battle"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 569b78980a0586b1ed8ee9662e0a94da134f254a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:06:59 +0200 Subject: [PATCH 18/42] Map API 3 native flags to version-correct VM storage --- .github/workflows/api3-contract.yml | 5 + docs/api3-flags.md | 75 + docs/api3-portability.md | 10 +- docs/modding.md | 3 +- src/mods/Api3.lua | 41 +- src/mods/Api3Flags.lua | 188 +++ src/mods/flag_names/emerald.lua | 1545 ++++++++++++++++++ src/mods/flag_names/firered.lua | 1477 +++++++++++++++++ src/mods/flag_names/red.lua | 514 ++++++ src/mods/flag_names/yellow.lua | 529 ++++++ tests/api3_contract/flags.lua | 72 + tests/api3_contract/mods/flags/main.lua | 17 + tests/api3_contract/mods/flags/manifest.json | 1 + tests/api3_contract/worker.lua | 5 +- tests/engine/api3_compat.lua | 2 +- tests/engine/api3_flags.lua | 113 ++ tools/api3-contract-check/check.py | 2 +- tools/flag-catalog-check/generate.py | 83 + 18 files changed, 4634 insertions(+), 48 deletions(-) create mode 100644 docs/api3-flags.md create mode 100644 src/mods/Api3Flags.lua create mode 100644 src/mods/flag_names/emerald.lua create mode 100644 src/mods/flag_names/firered.lua create mode 100644 src/mods/flag_names/red.lua create mode 100644 src/mods/flag_names/yellow.lua create mode 100644 tests/api3_contract/flags.lua create mode 100644 tests/api3_contract/mods/flags/main.lua create mode 100644 tests/api3_contract/mods/flags/manifest.json create mode 100644 tests/engine/api3_flags.lua create mode 100644 tools/flag-catalog-check/generate.py diff --git a/.github/workflows/api3-contract.yml b/.github/workflows/api3-contract.yml index fe5b47952..f448f8484 100644 --- a/.github/workflows/api3-contract.yml +++ b/.github/workflows/api3-contract.yml @@ -7,6 +7,7 @@ on: - 'tests/api3_contract/**' - 'tests/engine/api3_*.lua' - 'tools/api3-contract-check/**' + - 'tools/flag-catalog-check/**' push: branches: [main, dev] paths: @@ -15,6 +16,7 @@ on: - 'tests/api3_contract/**' - 'tests/engine/api3_*.lua' - 'tools/api3-contract-check/**' + - 'tools/flag-catalog-check/**' permissions: contents: read concurrency: @@ -32,6 +34,7 @@ jobs: data tests tools/api3-contract-check + tools/flag-catalog-check - uses: actions/checkout@v7 with: repository: bryanthaboi/gen1recomp @@ -44,6 +47,8 @@ jobs: assets/fonts - name: install LuaJIT run: sudo apt-get update && sudo apt-get install -y luajit + - name: native flag catalog provenance + run: python3 gen2/tools/flag-catalog-check/generate.py --gen1 gen1-reference - name: identical mod contracts on both engines run: >- python3 gen2/tools/api3-contract-check/check.py diff --git a/docs/api3-flags.md b/docs/api3-flags.md new file mode 100644 index 000000000..3eea204a9 --- /dev/null +++ b/docs/api3-flags.md @@ -0,0 +1,75 @@ +# API-3 native and private flags + +`mod.world:getFlag(reference)` returns a boolean or `nil, reason`. +`setFlag(reference, boolean)` returns `true` or `nil, reason`. Real booleans are +required. The facade follows the current Game/save without bootstrapping one. +Flag reads/writes require a save but can run during menus/scripts; they do not +start an idle-world action. Writes from a rolled-back/reloaded/unloaded owner +are retired. Gen4/5 remain outside the shared API-3 world surface. + +## Private flags + +`mod:` and `mod..` address the same private flag in +`save.modData[modId].worldFlags`. A foreign `mod..*` reference is +refused. Keys are nonempty strings up to 256 bytes without control characters. +A read does not create storage. A clear removes the key. Malformed stores are +refused without overwriting them. The existing change-only `flag.changed` +payload remains `{name="mod..", value=boolean}`. + +## Cartridge flags + +| Version | Public references | Host storage | +| --- | --- | --- | +| Red/Blue | Known event symbol or its named bit index | Existing `EVENT_*` save.flags key | +| Yellow | Yellow event symbol or its named bit index | Existing `EVENT_*` save.flags key, with Yellow's own bit catalog | +| Gold/Silver | EVENT symbol or whole bit index 0–2047 | `EVENT_G2_%04d`, as used by the VM/save converter | +| Crystal | Whole bit index 0–2047 | Same native numbered namespace; symbolic references explicitly decline, matching the pinned Gen1 API | +| FireRed/Emerald | Known native flag symbol/alias, or a named numeric flag in that version's range | `FLAG_G3_%04X`, as used by the VM/save converter | + +Native IDs/symbols are cartridge-specific; no equivalence between the same bit +number in different games is invented. Unknown, fractional, non-finite, +negative, malformed or out-of-range references decline before writes. Special +variables are not a new flag namespace. Prism/Polished Crystal native mappings +are not guessed from Gold; private flags remain available there. + +Clearing a native host flag retains `false`, matching native object visibility's +distinction between "cleared" and "untouched". Gen3 synchronizes active object +templates carrying that exact native flag through the host's targeted native +visibility entry; unrelated actors/templates are left alone. Native failures +there can happen after the flag write and are not transactional rollback. + +Native `flag.changed` fires only on a true boolean transition. Its name is the +Gen1 symbol, Gen2 bit number, or Gen3 version-catalog name (falling back to the +numeric ID); Gen3 also provides `id`. For Gen3 cross-engine listeners, use +`event.id` for native identity: the pinned Gen1 implementation's native emitter +uses its base FireRed name catalog even on Emerald, so `event.name` is not a +portable label there. The shared fixture consumes `event.id or event.name` on +both engines. This preserves meaningful native identity rather than claiming +all label strings are identical. Reentrant writes from an API-3 flag event or +native visibility callback decline with "world is busy"; reads remain usable. + +## Catalog provenance and verification + +Generated Red/Yellow/FireRed/Emerald identifier catalogs contain names/numbers +only. No Gen1 engine implementation or ROM is vendored. Gold/Silver reuse the +host's existing Gen2Flags table. The reference is pinned to clean +`bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. + +```sh +luajit tests/engine/api3_flags.lua +python3 tools/flag-catalog-check/generate.py --gen1 /clean/pinned/gen1 --lua luajit +# Add --write to regenerate the deterministic catalogs. +``` + +The provenance checker verifies every generated row and all 1235 pinned Gold +symbol mappings against the host. CI checks provenance alongside the shared +mod suite. Eight identical public-only mod cases bring that suite to +72 comparisons / 144 isolated loader runs; compare preflight, numeric/symbolic +aliases, booleans, no-op event suppression, public event identity/value and +private ownership. The reference uses actual flag backends with synthetic +world/store setup. + +Local checks cover every shipped catalog alias/named numeric entry, the Gen3 +VM key conversion and all 2048 Gen2 bits, plus malformed inputs/stores, +owner retirement, reentry and scoped visibility dispatch. These are ROM-free +runtime/store tests, not cartridge story progression or visual gameplay tests. diff --git a/docs/api3-portability.md b/docs/api3-portability.md index f7ff88609..4a15a97c3 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -75,7 +75,7 @@ The common world calls currently enabled for generations 1–3 are: `effectiveEncounters`; - `canReorderParty`, `reorderParty`, `availableFieldActions`, `useFieldAction`, `canFly`, `flyTo`; -- `getFlag`/`setFlag` for private `mod:` boolean flags; +- [`getFlag`/`setFlag`](api3-flags.md) for private and validated native flags; - `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 @@ -88,8 +88,8 @@ The common world calls currently enabled for generations 1–3 are: Check `mod:supports("world", name)` before relying on a call. All 24 world method names in the pinned Gen1 API-3 facade now have host adapters for Gen1–3. This describes API availability, not identical native internals or full -cross-engine behavioral coverage. Native flag mappings and `mod.datasets:open` -remain unavailable; private mod flags are supported. Gen4/5 do not claim this +cross-engine behavioral coverage. Native flags have version-specific validated mappings; `mod.datasets:open` +remains unavailable. Gen4/5 do not claim this shared world surface. Private flags use `save.modData[modId].worldFlags`, independently of native @@ -134,8 +134,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 and eight NPC cases extend this to 64 comparisons -and 128 isolated loader runs (including eight wild-battle and eight map-invalidation cases). It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 72 comparisons +and 144 isolated loader runs (including wild-battle, map-invalidation and flag cases). 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 diff --git a/docs/modding.md b/docs/modding.md index e2838031f..79e0fb9cc 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -27,7 +27,8 @@ See [API-3 world warps](api3-warp.md) for validated map transitions and callback [API-3 object/block edits](api3-world-edits.md) for native visibility and terrain changes, [API-3 NPC actions](api3-npc.md) for runtime actors and movement handles, [API-3 wild encounters](api3-wild-battle.md) for native battle admission, -and [API-3 map invalidation](api3-invalidate-map.md) for native cache/reload. +[API-3 map invalidation](api3-invalidate-map.md) for native cache/reload, +and [API-3 flags](api3-flags.md) for validated native references and private flags. ## Feature queries (`mod:supports`) diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index b64882e93..906983463 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -6,6 +6,7 @@ local Warp = require("src.mods.Api3Warp") local Edits = require("src.mods.Api3WorldEdits") local NPC = require("src.mods.Api3NPC") local Wild = require("src.mods.Api3WildBattle") +local Flags = require("src.mods.Api3Flags") local copy = require("src.mods.Merge").deepCopy local A = {} local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty @@ -115,46 +116,8 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) 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" - 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 + return Flags.call(game, api.id, gen, key, value, name == "setFlag", licensed, native) end if name == "queueScript" then return scripts:queueScript(...) diff --git a/src/mods/Api3Flags.lua b/src/mods/Api3Flags.lua new file mode 100644 index 000000000..d619025b2 --- /dev/null +++ b/src/mods/Api3Flags.lua @@ -0,0 +1,188 @@ +-- Portable references mapped to the flag stores the host VM/save converter use. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local F = {} +local catalogs, writing = {}, setmetatable({}, { __mode = "k" }) +local function integer(value, minimum, maximum) + return type(value) == "number" + and value == value + and value % 1 == 0 + and value >= minimum + and 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 catalog(version) + local key = version == "blue" and "red" or version + if catalogs[key] then + return catalogs[key] + end + local data = require("src.mods.flag_names." .. key) + local out = { names = {}, ids = {}, limit = data.limit } + for id, row in pairs(data.records) do + if row[1] then + out.names[id] = row[1] + end + for _, name in ipairs(row) do + if name then + out.ids[name] = id + end + end + end + catalogs[key] = out + return out +end +function F.resolve(generation, version, name) + local supported = ({ + red = 1, + blue = 1, + yellow = 1, + gold = 2, + silver = 2, + crystal = 2, + firered = 3, + emerald = 3, + })[version] + if supported ~= generation then + return nil, "native flag mapping unavailable for selected version" + end + if generation == 1 then + local t = catalog(version) + local id = integer(name, 0, 65535) and name or identifier(name) and t.ids[name] + if id ~= nil and t.names[id] then + return t.names[id], t.names[id], id + end + elseif generation == 2 then + local native = require("src.script.Gen2Flags") + local id = name + if identifier(name) then + if version == "crystal" then + return nil, "symbolic event flags unavailable for Crystal; use native ids" + end + id = native.EVENT_FLAG_INDEX[name] + end + if integer(id, 0, 2047) then + return native.eventFlag(id), id, id + end + elseif generation == 3 then + local t = catalog(version) + local id + if type(name) == "number" then + if integer(name, 1, t.limit - 1) and t.names[name] then + id = name + end + elseif identifier(name) then + id = t.ids[name] + end + if id and integer(id, 1, t.limit - 1) then + return ("FLAG_G3_%04X"):format(id), t.names[id] or id, id + end + end + return nil, "invalid or unknown flag" +end +function F.call(game, owner, gen, name, value, write, licensed, native) + if write and not licensed() then + return nil, "world facade is retired" + end + if write and type(value) ~= "boolean" then + return nil, "flag value must be boolean" + end + local save = game.save + if not save then + return nil, "no save" + end + local key + if type(name) == "string" then + if name:sub(1, 4) == "mod:" then + key = name:sub(5) + elseif name:sub(1, 4) == "mod." then + local prefix = "mod." .. owner .. "." + if name:sub(1, #prefix) ~= prefix then + return nil, "foreign mod flag" + end + key = name:sub(#prefix + 1) + end + end + if key then + if not identifier(key) then + return nil, "invalid mod flag" + end + local store = save.modData + if store ~= nil and type(store) ~= "table" then + return nil, "invalid mod save store" + end + local bucket = store and store[owner] + 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 not write then + return flags ~= nil and flags[key] == true + end + if writing[save] then + return nil, "world is busy" + end + local before = flags ~= nil and flags[key] == true + save.modData = store or {} + bucket = bucket or {} + save.modData[owner] = bucket + bucket.worldFlags = flags or {} + bucket.worldFlags[key] = value or nil + if before ~= value then + writing[save] = true + local ok, err = + pcall(Runtime.emit, "flag.changed", { name = "mod." .. owner .. "." .. key, value = value }) + writing[save] = nil + if not ok then + error(err, 0) + end + end + return true + end + local storage, public, number = F.resolve(gen, Version.get(), name) + if not storage then + return nil, public + end + if type(save.flags) ~= "table" then + return nil, "invalid flag store" + end + if not write then + return save.flags[storage] == true + end + if writing[save] then + return nil, "world is busy" + end + writing[save] = true + local ok, err = pcall(function() + local before = save.flags[storage] == true + -- Native clear keeps false: visibility distinguishes cleared from untouched. + save.flags[storage] = value + if gen == 3 then + local ow = native(game):overworld() + if ow and ow.syncObjectVisibility then + for _, object in ipairs(ow.map and ow.map.def and ow.map.def.objects or {}) do + if object.eventFlag == storage then + ow:syncObjectVisibility(object) + end + end + end + end + if before ~= value then + local event = { name = public, value = value } + if gen == 3 then + event.id = number + end + Runtime.emit("flag.changed", event) + end + end) + writing[save] = nil + if not ok then + error(err, 0) + end + return true +end +return F diff --git a/src/mods/flag_names/emerald.lua b/src/mods/flag_names/emerald.lua new file mode 100644 index 000000000..815b89f80 --- /dev/null +++ b/src/mods/flag_names/emerald.lua @@ -0,0 +1,1545 @@ +-- Generated flag identifiers only; no engine implementation. +-- Source: bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 +-- Regenerate with tools/flag-catalog-check/generate.py. +return { + limit = 2400, + records = { + [1] = { "FLAG_TEMP_1", "FLAG_TEMP_SKIP_GABBY_INTERVIEW", "TEMP_1", "TEMP_SKIP_GABBY_INTERVIEW" }, + [2] = { "FLAG_TEMP_2", "FLAG_TEMP_REGICE_PUZZLE_STARTED", "TEMP_2", "TEMP_REGICE_PUZZLE_STARTED" }, + [3] = { "FLAG_TEMP_3", "FLAG_TEMP_REGICE_PUZZLE_FAILED", "TEMP_3", "TEMP_REGICE_PUZZLE_FAILED" }, + [4] = { "FLAG_TEMP_4", "TEMP_4" }, + [5] = { "FLAG_TEMP_5", "TEMP_5" }, + [6] = { "FLAG_TEMP_6", "TEMP_6" }, + [7] = { "FLAG_TEMP_7", "TEMP_7" }, + [8] = { "FLAG_TEMP_8", "NUM_BADGES", "TEMP_8" }, + [9] = { "FLAG_TEMP_9", "TEMP_9" }, + [10] = { "FLAG_TEMP_A", "TEMP_A" }, + [11] = { "FLAG_TEMP_B", "TEMP_B" }, + [12] = { "FLAG_TEMP_C", "TEMP_C" }, + [13] = { "FLAG_TEMP_D", "TEMP_D" }, + [14] = { "FLAG_TEMP_E", "TEMP_E" }, + [15] = { "FLAG_TEMP_F", "TEMP_F" }, + [16] = { "FLAG_TEMP_10", "NUM_TEMP_VARS", "TEMP_10" }, + [17] = { "FLAG_TEMP_11", "FLAG_TEMP_HIDE_MIRAGE_ISLAND_BERRY_TREE", "TEMP_11", "TEMP_HIDE_MIRAGE_ISLAND_BERRY_TREE" }, + [18] = { "FLAG_TEMP_12", "TEMP_12" }, + [19] = { "FLAG_TEMP_13", "TEMP_13" }, + [20] = { "FLAG_TEMP_14", "NUM_WONDER_CARD_FLAGS", "TEMP_14" }, + [21] = { "FLAG_TEMP_15", "TEMP_15" }, + [22] = { "FLAG_TEMP_16", "TEMP_16" }, + [23] = { "FLAG_TEMP_17", "TEMP_17" }, + [24] = { "FLAG_TEMP_18", "TEMP_18" }, + [25] = { "FLAG_TEMP_19", "TEMP_19" }, + [26] = { "FLAG_TEMP_1A", "TEMP_1A" }, + [27] = { "FLAG_TEMP_1B", "TEMP_1B" }, + [28] = { "FLAG_TEMP_1C", "TEMP_1C" }, + [29] = { "FLAG_TEMP_1D", "TEMP_1D" }, + [30] = { "FLAG_TEMP_1E", "TEMP_1E" }, + [31] = { "FLAG_TEMP_1F", "TEMP_1F", "TEMP_FLAGS_END" }, + [32] = { "FLAG_UNUSED_0x020", "NUM_TEMP_FLAGS", "UNUSED_0x020" }, + [33] = { "FLAG_UNUSED_0x021", "UNUSED_0x021" }, + [34] = { "FLAG_UNUSED_0x022", "UNUSED_0x022" }, + [35] = { "FLAG_UNUSED_0x023", "UNUSED_0x023" }, + [36] = { "FLAG_UNUSED_0x024", "UNUSED_0x024" }, + [37] = { "FLAG_UNUSED_0x025", "UNUSED_0x025" }, + [38] = { "FLAG_UNUSED_0x026", "UNUSED_0x026" }, + [39] = { "FLAG_UNUSED_0x027", "UNUSED_0x027" }, + [40] = { "FLAG_UNUSED_0x028", "UNUSED_0x028" }, + [41] = { "FLAG_UNUSED_0x029", "UNUSED_0x029" }, + [42] = { "FLAG_UNUSED_0x02A", "UNUSED_0x02A" }, + [43] = { "FLAG_UNUSED_0x02B", "UNUSED_0x02B" }, + [44] = { "FLAG_UNUSED_0x02C", "UNUSED_0x02C" }, + [45] = { "FLAG_UNUSED_0x02D", "UNUSED_0x02D" }, + [46] = { "FLAG_UNUSED_0x02E", "UNUSED_0x02E" }, + [47] = { "FLAG_UNUSED_0x02F", "UNUSED_0x02F" }, + [48] = { "FLAG_UNUSED_0x030", "UNUSED_0x030" }, + [49] = { "FLAG_UNUSED_0x031", "UNUSED_0x031" }, + [50] = { "FLAG_UNUSED_0x032", "UNUSED_0x032" }, + [51] = { "FLAG_UNUSED_0x033", "UNUSED_0x033" }, + [52] = { "FLAG_UNUSED_0x034", "UNUSED_0x034" }, + [53] = { "FLAG_UNUSED_0x035", "UNUSED_0x035" }, + [54] = { "FLAG_UNUSED_0x036", "UNUSED_0x036" }, + [55] = { "FLAG_UNUSED_0x037", "UNUSED_0x037" }, + [56] = { "FLAG_UNUSED_0x038", "UNUSED_0x038" }, + [57] = { "FLAG_UNUSED_0x039", "UNUSED_0x039" }, + [58] = { "FLAG_UNUSED_0x03A", "UNUSED_0x03A" }, + [59] = { "FLAG_UNUSED_0x03B", "UNUSED_0x03B" }, + [60] = { "FLAG_UNUSED_0x03C", "UNUSED_0x03C" }, + [61] = { "FLAG_UNUSED_0x03D", "UNUSED_0x03D" }, + [62] = { "FLAG_UNUSED_0x03E", "UNUSED_0x03E" }, + [63] = { "FLAG_UNUSED_0x03F", "UNUSED_0x03F" }, + [64] = { "FLAG_UNUSED_0x040", "NUM_DAILY_FLAGS", "UNUSED_0x040" }, + [65] = { "FLAG_UNUSED_0x041", "UNUSED_0x041" }, + [66] = { "FLAG_UNUSED_0x042", "UNUSED_0x042" }, + [67] = { "FLAG_UNUSED_0x043", "UNUSED_0x043" }, + [68] = { "FLAG_UNUSED_0x044", "UNUSED_0x044" }, + [69] = { "FLAG_UNUSED_0x045", "UNUSED_0x045" }, + [70] = { "FLAG_UNUSED_0x046", "UNUSED_0x046" }, + [71] = { "FLAG_UNUSED_0x047", "UNUSED_0x047" }, + [72] = { "FLAG_UNUSED_0x048", "UNUSED_0x048" }, + [73] = { "FLAG_UNUSED_0x049", "UNUSED_0x049" }, + [74] = { "FLAG_UNUSED_0x04A", "UNUSED_0x04A" }, + [75] = { "FLAG_UNUSED_0x04B", "UNUSED_0x04B" }, + [76] = { "FLAG_UNUSED_0x04C", "UNUSED_0x04C" }, + [77] = { "FLAG_UNUSED_0x04D", "UNUSED_0x04D" }, + [78] = { "FLAG_UNUSED_0x04E", "UNUSED_0x04E" }, + [79] = { "FLAG_UNUSED_0x04F", "UNUSED_0x04F" }, + [80] = { "FLAG_HIDE_SKY_PILLAR_TOP_RAYQUAZA_STILL", "HIDE_SKY_PILLAR_TOP_RAYQUAZA_STILL" }, + [81] = { "FLAG_SET_WALL_CLOCK", "SET_WALL_CLOCK" }, + [82] = { "FLAG_RESCUED_BIRCH", "RESCUED_BIRCH" }, + [83] = { "FLAG_LEGENDARIES_IN_SOOTOPOLIS", "LEGENDARIES_IN_SOOTOPOLIS" }, + [84] = { "FLAG_UNUSED_0x054", "UNUSED_0x054" }, + [85] = { "FLAG_UNUSED_0x055", "UNUSED_0x055" }, + [86] = { "FLAG_HIDE_CONTEST_POKE_BALL", "HIDE_CONTEST_POKE_BALL" }, + [87] = { "FLAG_MET_RIVAL_MOM", "MET_RIVAL_MOM" }, + [88] = { "FLAG_BIRCH_AIDE_MET", "BIRCH_AIDE_MET" }, + [89] = { "FLAG_DECLINED_BIKE", "DECLINED_BIKE" }, + [90] = { "FLAG_RECEIVED_BIKE", "RECEIVED_BIKE" }, + [91] = { "FLAG_WATTSON_REMATCH_AVAILABLE", "WATTSON_REMATCH_AVAILABLE" }, + [92] = { "FLAG_COLLECTED_ALL_SILVER_SYMBOLS", "COLLECTED_ALL_SILVER_SYMBOLS" }, + [93] = { "FLAG_GOOD_LUCK_SAFARI_ZONE", "GOOD_LUCK_SAFARI_ZONE" }, + [94] = { "FLAG_RECEIVED_WAILMER_PAIL", "RECEIVED_WAILMER_PAIL" }, + [95] = { "FLAG_RECEIVED_POKEBLOCK_CASE", "RECEIVED_POKEBLOCK_CASE" }, + [96] = { "FLAG_RECEIVED_SECRET_POWER", "RECEIVED_SECRET_POWER" }, + [97] = { "FLAG_MET_TEAM_AQUA_HARBOR", "MET_TEAM_AQUA_HARBOR" }, + [98] = { "FLAG_TV_EXPLAINED", "TV_EXPLAINED" }, + [99] = { "FLAG_MAUVILLE_GYM_BARRIERS_STATE", "MAUVILLE_GYM_BARRIERS_STATE" }, + [100] = { "FLAG_MOSSDEEP_GYM_SWITCH_1", "MOSSDEEP_GYM_SWITCH_1" }, + [101] = { "FLAG_MOSSDEEP_GYM_SWITCH_2", "MOSSDEEP_GYM_SWITCH_2" }, + [102] = { "FLAG_MOSSDEEP_GYM_SWITCH_3", "MOSSDEEP_GYM_SWITCH_3" }, + [103] = { "FLAG_MOSSDEEP_GYM_SWITCH_4", "MOSSDEEP_GYM_SWITCH_4" }, + [104] = { "FLAG_UNUSED_0x068", "UNUSED_0x068" }, + [105] = { "FLAG_OCEANIC_MUSEUM_MET_REPORTER", "OCEANIC_MUSEUM_MET_REPORTER" }, + [106] = { "FLAG_RECEIVED_HM_STRENGTH", "RECEIVED_HM_STRENGTH" }, + [107] = { "FLAG_RECEIVED_HM_ROCK_SMASH", "RECEIVED_HM_ROCK_SMASH" }, + [108] = { "FLAG_WHITEOUT_TO_LAVARIDGE", "WHITEOUT_TO_LAVARIDGE" }, + [109] = { "FLAG_RECEIVED_HM_FLASH", "RECEIVED_HM_FLASH" }, + [110] = { "FLAG_RECEIVED_HM_FLY", "RECEIVED_HM_FLY" }, + [111] = { "FLAG_GROUDON_AWAKENED_MAGMA_HIDEOUT", "GROUDON_AWAKENED_MAGMA_HIDEOUT" }, + [112] = { "FLAG_TEAM_AQUA_ESCAPED_IN_SUBMARINE", "TEAM_AQUA_ESCAPED_IN_SUBMARINE" }, + [113] = { "FLAG_UNUSED_RS_LEGENDARY_BATTLE_DONE", "UNUSED_RS_LEGENDARY_BATTLE_DONE" }, + [114] = { "FLAG_SCOTT_CALL_BATTLE_FRONTIER", "SCOTT_CALL_BATTLE_FRONTIER" }, + [115] = { "FLAG_RECEIVED_METEORITE", "RECEIVED_METEORITE" }, + [116] = { "FLAG_ADVENTURE_STARTED", "ADVENTURE_STARTED" }, + [117] = { "FLAG_DEFEATED_MAGMA_SPACE_CENTER", "DEFEATED_MAGMA_SPACE_CENTER" }, + [118] = { "FLAG_MET_HIDDEN_POWER_GIVER", "MET_HIDDEN_POWER_GIVER" }, + [119] = { "FLAG_CANCEL_BATTLE_ROOM_CHALLENGE", "CANCEL_BATTLE_ROOM_CHALLENGE" }, + [120] = { "FLAG_LANDMARK_MIRAGE_TOWER", "LANDMARK_MIRAGE_TOWER" }, + [121] = { "FLAG_RECEIVED_TM_BRICK_BREAK", "RECEIVED_TM_BRICK_BREAK" }, + [122] = { "FLAG_RECEIVED_HM_SURF", "RECEIVED_HM_SURF" }, + [123] = { "FLAG_RECEIVED_HM_DIVE", "RECEIVED_HM_DIVE" }, + [124] = { "FLAG_REGISTER_RIVAL_POKENAV", "REGISTER_RIVAL_POKENAV" }, + [125] = { "FLAG_DEFEATED_RIVAL_ROUTE_104", "DEFEATED_RIVAL_ROUTE_104" }, + [126] = { "FLAG_DEFEATED_WALLY_VICTORY_ROAD", "DEFEATED_WALLY_VICTORY_ROAD" }, + [127] = { "FLAG_MET_PRETTY_PETAL_SHOP_OWNER", "MET_PRETTY_PETAL_SHOP_OWNER" }, + [128] = { "FLAG_ENABLE_ROXANNE_FIRST_CALL", "ENABLE_ROXANNE_FIRST_CALL", "NUM_SPECIAL_FLAGS" }, + [129] = { "FLAG_KYOGRE_ESCAPED_SEAFLOOR_CAVERN", "KYOGRE_ESCAPED_SEAFLOOR_CAVERN" }, + [130] = { "FLAG_DEFEATED_RIVAL_ROUTE103", "DEFEATED_RIVAL_ROUTE103" }, + [131] = { "FLAG_RECEIVED_DOLL_LANETTE", "RECEIVED_DOLL_LANETTE" }, + [132] = { "FLAG_RECEIVED_POTION_OLDALE", "RECEIVED_POTION_OLDALE" }, + [133] = { "FLAG_RECEIVED_AMULET_COIN", "RECEIVED_AMULET_COIN" }, + [134] = { "FLAG_PENDING_DAYCARE_EGG", "PENDING_DAYCARE_EGG" }, + [135] = { "FLAG_THANKED_FOR_PLAYING_WITH_WALLY", "THANKED_FOR_PLAYING_WITH_WALLY" }, + [136] = { "FLAG_ENABLE_FIRST_WALLY_POKENAV_CALL", "ENABLE_FIRST_WALLY_POKENAV_CALL" }, + [137] = { "FLAG_RECEIVED_HM_CUT", "RECEIVED_HM_CUT" }, + [138] = { "FLAG_SCOTT_CALL_FORTREE_GYM", "SCOTT_CALL_FORTREE_GYM" }, + [139] = { "FLAG_DEFEATED_EVIL_TEAM_MT_CHIMNEY", "DEFEATED_EVIL_TEAM_MT_CHIMNEY" }, + [140] = { "FLAG_RECEIVED_6_SODA_POP", "RECEIVED_6_SODA_POP" }, + [141] = { "FLAG_DEFEATED_SEASHORE_HOUSE", "DEFEATED_SEASHORE_HOUSE" }, + [142] = { "FLAG_DEVON_GOODS_STOLEN", "DEVON_GOODS_STOLEN" }, + [143] = { "FLAG_RECOVERED_DEVON_GOODS", "RECOVERED_DEVON_GOODS" }, + [144] = { "FLAG_RETURNED_DEVON_GOODS", "RETURNED_DEVON_GOODS" }, + [145] = { "FLAG_CAUGHT_LUGIA", "CAUGHT_LUGIA" }, + [146] = { "FLAG_CAUGHT_HO_OH", "CAUGHT_HO_OH" }, + [147] = { "FLAG_MR_BRINEY_SAILING_INTRO", "MR_BRINEY_SAILING_INTRO" }, + [148] = { "FLAG_DOCK_REJECTED_DEVON_GOODS", "DOCK_REJECTED_DEVON_GOODS" }, + [149] = { "FLAG_DELIVERED_DEVON_GOODS", "DELIVERED_DEVON_GOODS" }, + [150] = { "FLAG_RECEIVED_CONTEST_PASS", "RECEIVED_CONTEST_PASS" }, + [151] = { "FLAG_RECEIVED_CASTFORM", "RECEIVED_CASTFORM" }, + [152] = { "FLAG_RECEIVED_SUPER_ROD", "RECEIVED_SUPER_ROD" }, + [153] = { "FLAG_RUSTBORO_NPC_TRADE_COMPLETED", "RUSTBORO_NPC_TRADE_COMPLETED" }, + [154] = { "FLAG_PACIFIDLOG_NPC_TRADE_COMPLETED", "PACIFIDLOG_NPC_TRADE_COMPLETED" }, + [155] = { "FLAG_FORTREE_NPC_TRADE_COMPLETED", "FORTREE_NPC_TRADE_COMPLETED" }, + [156] = { "FLAG_BATTLE_FRONTIER_TRADE_DONE", "BATTLE_FRONTIER_TRADE_DONE" }, + [157] = { "FLAG_FORCE_MIRAGE_TOWER_VISIBLE", "FORCE_MIRAGE_TOWER_VISIBLE" }, + [158] = { "FLAG_SOOTOPOLIS_ARCHIE_MAXIE_LEAVE", "SOOTOPOLIS_ARCHIE_MAXIE_LEAVE" }, + [159] = { "FLAG_INTERACTED_WITH_DEVON_EMPLOYEE_GOODS_STOLEN", "INTERACTED_WITH_DEVON_EMPLOYEE_GOODS_STOLEN" }, + [160] = { "FLAG_COOL_PAINTING_MADE", "COOL_PAINTING_MADE" }, + [161] = { "FLAG_BEAUTY_PAINTING_MADE", "BEAUTY_PAINTING_MADE" }, + [162] = { "FLAG_CUTE_PAINTING_MADE", "CUTE_PAINTING_MADE" }, + [163] = { "FLAG_SMART_PAINTING_MADE", "SMART_PAINTING_MADE" }, + [164] = { "FLAG_TOUGH_PAINTING_MADE", "TOUGH_PAINTING_MADE" }, + [165] = { "FLAG_RECEIVED_TM_ROCK_TOMB", "RECEIVED_TM_ROCK_TOMB" }, + [166] = { "FLAG_RECEIVED_TM_BULK_UP", "RECEIVED_TM_BULK_UP" }, + [167] = { "FLAG_RECEIVED_TM_SHOCK_WAVE", "RECEIVED_TM_SHOCK_WAVE" }, + [168] = { "FLAG_RECEIVED_TM_OVERHEAT", "RECEIVED_TM_OVERHEAT" }, + [169] = { "FLAG_RECEIVED_TM_FACADE", "RECEIVED_TM_FACADE" }, + [170] = { "FLAG_RECEIVED_TM_AERIAL_ACE", "RECEIVED_TM_AERIAL_ACE" }, + [171] = { "FLAG_RECEIVED_TM_CALM_MIND", "RECEIVED_TM_CALM_MIND" }, + [172] = { "FLAG_RECEIVED_TM_WATER_PULSE", "RECEIVED_TM_WATER_PULSE" }, + [173] = { "FLAG_HIDE_SECRET_BASE_TRAINER", "HIDE_SECRET_BASE_TRAINER" }, + [174] = { "FLAG_DECORATION_1", "DECORATION_1" }, + [175] = { "FLAG_DECORATION_2", "DECORATION_2" }, + [176] = { "FLAG_DECORATION_3", "DECORATION_3" }, + [177] = { "FLAG_DECORATION_4", "DECORATION_4" }, + [178] = { "FLAG_DECORATION_5", "DECORATION_5" }, + [179] = { "FLAG_DECORATION_6", "DECORATION_6" }, + [180] = { "FLAG_DECORATION_7", "DECORATION_7" }, + [181] = { "FLAG_DECORATION_8", "DECORATION_8" }, + [182] = { "FLAG_DECORATION_9", "DECORATION_9" }, + [183] = { "FLAG_DECORATION_10", "DECORATION_10" }, + [184] = { "FLAG_DECORATION_11", "DECORATION_11" }, + [185] = { "FLAG_DECORATION_12", "DECORATION_12" }, + [186] = { "FLAG_DECORATION_13", "DECORATION_13" }, + [187] = { "FLAG_DECORATION_14", "DECORATION_14" }, + [188] = { "FLAG_RECEIVED_POKENAV", "RECEIVED_POKENAV" }, + [189] = { "FLAG_DELIVERED_STEVEN_LETTER", "DELIVERED_STEVEN_LETTER" }, + [190] = { "FLAG_DEFEATED_WALLY_MAUVILLE", "DEFEATED_WALLY_MAUVILLE" }, + [191] = { "FLAG_DEFEATED_GRUNT_SPACE_CENTER_1F", "DEFEATED_GRUNT_SPACE_CENTER_1F" }, + [192] = { "FLAG_RECEIVED_SUN_STONE_MOSSDEEP", "RECEIVED_SUN_STONE_MOSSDEEP" }, + [193] = { "FLAG_WALLY_SPEECH", "WALLY_SPEECH" }, + [194] = { "FLAG_TRICK_HOUSE_PUZZLE_7_SWITCH_1", "TRICK_HOUSE_PUZZLE_7_SWITCH_1" }, + [195] = { "FLAG_TRICK_HOUSE_PUZZLE_7_SWITCH_2", "TRICK_HOUSE_PUZZLE_7_SWITCH_2" }, + [196] = { "FLAG_TRICK_HOUSE_PUZZLE_7_SWITCH_3", "TRICK_HOUSE_PUZZLE_7_SWITCH_3" }, + [197] = { "FLAG_TRICK_HOUSE_PUZZLE_7_SWITCH_4", "TRICK_HOUSE_PUZZLE_7_SWITCH_4" }, + [198] = { "FLAG_TRICK_HOUSE_PUZZLE_7_SWITCH_5", "TRICK_HOUSE_PUZZLE_7_SWITCH_5" }, + [199] = { "FLAG_RUSTURF_TUNNEL_OPENED", "RUSTURF_TUNNEL_OPENED" }, + [200] = { "FLAG_RECEIVED_RED_SCARF", "RECEIVED_RED_SCARF" }, + [201] = { "FLAG_RECEIVED_BLUE_SCARF", "RECEIVED_BLUE_SCARF" }, + [202] = { "FLAG_RECEIVED_PINK_SCARF", "RECEIVED_PINK_SCARF" }, + [203] = { "FLAG_RECEIVED_GREEN_SCARF", "RECEIVED_GREEN_SCARF" }, + [204] = { "FLAG_RECEIVED_YELLOW_SCARF", "RECEIVED_YELLOW_SCARF" }, + [205] = { "FLAG_INTERACTED_WITH_STEVEN_SPACE_CENTER", "INTERACTED_WITH_STEVEN_SPACE_CENTER" }, + [206] = { "FLAG_ENCOUNTERED_LATIAS_OR_LATIOS", "ENCOUNTERED_LATIAS_OR_LATIOS" }, + [207] = { "FLAG_MET_ARCHIE_METEOR_FALLS", "MET_ARCHIE_METEOR_FALLS" }, + [208] = { "FLAG_GOT_BASEMENT_KEY_FROM_WATTSON", "GOT_BASEMENT_KEY_FROM_WATTSON" }, + [209] = { "FLAG_GOT_TM_THUNDERBOLT_FROM_WATTSON", "GOT_TM_THUNDERBOLT_FROM_WATTSON" }, + [210] = { "FLAG_FAN_CLUB_STRENGTH_SHARED", "FAN_CLUB_STRENGTH_SHARED" }, + [211] = { "FLAG_DEFEATED_RIVAL_RUSTBORO", "DEFEATED_RIVAL_RUSTBORO" }, + [212] = { "FLAG_RECEIVED_RED_OR_BLUE_ORB", "RECEIVED_RED_OR_BLUE_ORB" }, + [213] = { "FLAG_RECEIVED_PREMIER_BALL_RUSTBORO", "RECEIVED_PREMIER_BALL_RUSTBORO" }, + [214] = { "FLAG_ENABLE_WALLY_MATCH_CALL", "ENABLE_WALLY_MATCH_CALL" }, + [215] = { "FLAG_ENABLE_SCOTT_MATCH_CALL", "ENABLE_SCOTT_MATCH_CALL" }, + [216] = { "FLAG_ENABLE_MOM_MATCH_CALL", "ENABLE_MOM_MATCH_CALL" }, + [217] = { "FLAG_MET_DIVING_TREASURE_HUNTER", "MET_DIVING_TREASURE_HUNTER" }, + [218] = { "FLAG_MET_WAILMER_TRAINER", "MET_WAILMER_TRAINER" }, + [219] = { "FLAG_EVIL_LEADER_PLEASE_STOP", "EVIL_LEADER_PLEASE_STOP" }, + [220] = { "FLAG_NEVER_SET_0x0DC", "NEVER_SET_0x0DC" }, + [221] = { "FLAG_RECEIVED_GO_GOGGLES", "RECEIVED_GO_GOGGLES" }, + [222] = { "FLAG_WINGULL_SENT_ON_ERRAND", "WINGULL_SENT_ON_ERRAND" }, + [223] = { "FLAG_RECEIVED_MENTAL_HERB", "RECEIVED_MENTAL_HERB" }, + [224] = { "FLAG_WINGULL_DELIVERED_MAIL", "WINGULL_DELIVERED_MAIL" }, + [225] = { "FLAG_RECEIVED_20_COINS", "RECEIVED_20_COINS" }, + [226] = { "FLAG_RECEIVED_STARTER_DOLL", "RECEIVED_STARTER_DOLL" }, + [227] = { "FLAG_RECEIVED_GOOD_ROD", "RECEIVED_GOOD_ROD" }, + [228] = { "FLAG_REGI_DOORS_OPENED", "REGI_DOORS_OPENED" }, + [229] = { "FLAG_RECEIVED_TM_RETURN", "RECEIVED_TM_RETURN" }, + [230] = { "FLAG_RECEIVED_TM_SLUDGE_BOMB", "RECEIVED_TM_SLUDGE_BOMB" }, + [231] = { "FLAG_RECEIVED_TM_ROAR", "RECEIVED_TM_ROAR" }, + [232] = { "FLAG_RECEIVED_TM_GIGA_DRAIN", "RECEIVED_TM_GIGA_DRAIN" }, + [233] = { "FLAG_UNUSED_0x0E9", "UNUSED_0x0E9" }, + [234] = { "FLAG_RECEIVED_TM_REST", "RECEIVED_TM_REST" }, + [235] = { "FLAG_RECEIVED_TM_ATTRACT", "RECEIVED_TM_ATTRACT" }, + [236] = { "FLAG_RECEIVED_GLASS_ORNAMENT", "RECEIVED_GLASS_ORNAMENT" }, + [237] = { "FLAG_RECEIVED_SILVER_SHIELD", "RECEIVED_SILVER_SHIELD" }, + [238] = { "FLAG_RECEIVED_GOLD_SHIELD", "RECEIVED_GOLD_SHIELD" }, + [239] = { "FLAG_USED_STORAGE_KEY", "USED_STORAGE_KEY" }, + [240] = { "FLAG_USED_ROOM_1_KEY", "USED_ROOM_1_KEY" }, + [241] = { "FLAG_USED_ROOM_2_KEY", "USED_ROOM_2_KEY" }, + [242] = { "FLAG_USED_ROOM_4_KEY", "USED_ROOM_4_KEY" }, + [243] = { "FLAG_USED_ROOM_6_KEY", "USED_ROOM_6_KEY" }, + [244] = { "FLAG_MET_PROF_COZMO", "MET_PROF_COZMO" }, + [245] = { "FLAG_RECEIVED_WAILMER_DOLL", "RECEIVED_WAILMER_DOLL" }, + [246] = { "FLAG_RECEIVED_CHESTO_BERRY_ROUTE_104", "RECEIVED_CHESTO_BERRY_ROUTE_104" }, + [247] = { "FLAG_DEFEATED_SS_TIDAL_TRAINERS", "DEFEATED_SS_TIDAL_TRAINERS" }, + [248] = { "FLAG_RECEIVED_SPELON_BERRY", "RECEIVED_SPELON_BERRY" }, + [249] = { "FLAG_RECEIVED_PAMTRE_BERRY", "RECEIVED_PAMTRE_BERRY" }, + [250] = { "FLAG_RECEIVED_WATMEL_BERRY", "RECEIVED_WATMEL_BERRY" }, + [251] = { "FLAG_RECEIVED_DURIN_BERRY", "RECEIVED_DURIN_BERRY" }, + [252] = { "FLAG_RECEIVED_BELUE_BERRY", "RECEIVED_BELUE_BERRY" }, + [253] = { "FLAG_ENABLE_RIVAL_MATCH_CALL", "ENABLE_RIVAL_MATCH_CALL" }, + [254] = { "FLAG_RECEIVED_CHARCOAL", "RECEIVED_CHARCOAL" }, + [255] = { "FLAG_LATIOS_OR_LATIAS_ROAMING", "LATIOS_OR_LATIAS_ROAMING" }, + [256] = { "FLAG_RECEIVED_REPEAT_BALL", "RECEIVED_REPEAT_BALL", "VARS_COUNT" }, + [257] = { "FLAG_RECEIVED_OLD_ROD", "RECEIVED_OLD_ROD" }, + [258] = { "FLAG_RECEIVED_COIN_CASE", "RECEIVED_COIN_CASE" }, + [259] = { "FLAG_RETURNED_RED_OR_BLUE_ORB", "RETURNED_RED_OR_BLUE_ORB" }, + [260] = { "FLAG_RECEIVED_TM_SNATCH", "RECEIVED_TM_SNATCH" }, + [261] = { "FLAG_RECEIVED_TM_DIG", "RECEIVED_TM_DIG" }, + [262] = { "FLAG_RECEIVED_TM_BULLET_SEED", "RECEIVED_TM_BULLET_SEED" }, + [263] = { "FLAG_ENTERED_ELITE_FOUR", "ENTERED_ELITE_FOUR" }, + [264] = { "FLAG_RECEIVED_TM_HIDDEN_POWER", "RECEIVED_TM_HIDDEN_POWER" }, + [265] = { "FLAG_RECEIVED_TM_TORMENT", "RECEIVED_TM_TORMENT" }, + [266] = { "FLAG_RECEIVED_LAVARIDGE_EGG", "RECEIVED_LAVARIDGE_EGG" }, + [267] = { "FLAG_RECEIVED_REVIVED_FOSSIL_MON", "RECEIVED_REVIVED_FOSSIL_MON" }, + [268] = { "FLAG_SECRET_BASE_REGISTRY_ENABLED", "SECRET_BASE_REGISTRY_ENABLED" }, + [269] = { "FLAG_RECEIVED_TM_THIEF", "RECEIVED_TM_THIEF" }, + [270] = { "FLAG_CONTEST_SKETCH_CREATED", "CONTEST_SKETCH_CREATED" }, + [271] = { "FLAG_EVIL_TEAM_ESCAPED_STERN_SPOKE", "EVIL_TEAM_ESCAPED_STERN_SPOKE" }, + [272] = { "FLAG_RECEIVED_EXP_SHARE", "RECEIVED_EXP_SHARE" }, + [273] = { "FLAG_POKERUS_EXPLAINED", "POKERUS_EXPLAINED" }, + [274] = { "FLAG_RECEIVED_RUNNING_SHOES", "RECEIVED_RUNNING_SHOES" }, + [275] = { "FLAG_RECEIVED_QUICK_CLAW", "RECEIVED_QUICK_CLAW" }, + [276] = { "FLAG_RECEIVED_KINGS_ROCK", "RECEIVED_KINGS_ROCK" }, + [277] = { "FLAG_RECEIVED_MACHO_BRACE", "RECEIVED_MACHO_BRACE" }, + [278] = { "FLAG_RECEIVED_SOOTHE_BELL", "RECEIVED_SOOTHE_BELL" }, + [279] = { "FLAG_RECEIVED_WHITE_HERB", "RECEIVED_WHITE_HERB" }, + [280] = { "FLAG_RECEIVED_SOFT_SAND", "RECEIVED_SOFT_SAND" }, + [281] = { "FLAG_ENABLE_PROF_BIRCH_MATCH_CALL", "ENABLE_PROF_BIRCH_MATCH_CALL" }, + [282] = { "FLAG_RECEIVED_CLEANSE_TAG", "RECEIVED_CLEANSE_TAG" }, + [283] = { "FLAG_RECEIVED_FOCUS_BAND", "RECEIVED_FOCUS_BAND" }, + [284] = { "FLAG_DECLINED_WALLY_BATTLE_MAUVILLE", "DECLINED_WALLY_BATTLE_MAUVILLE" }, + [285] = { "FLAG_RECEIVED_DEVON_SCOPE", "RECEIVED_DEVON_SCOPE" }, + [286] = { "FLAG_DECLINED_RIVAL_BATTLE_LILYCOVE", "DECLINED_RIVAL_BATTLE_LILYCOVE" }, + [287] = { "FLAG_MET_DEVON_EMPLOYEE", "MET_DEVON_EMPLOYEE" }, + [288] = { "FLAG_MET_RIVAL_RUSTBORO", "MET_RIVAL_RUSTBORO" }, + [289] = { "FLAG_RECEIVED_SILK_SCARF", "RECEIVED_SILK_SCARF" }, + [290] = { "FLAG_NOT_READY_FOR_BATTLE_ROUTE_120", "NOT_READY_FOR_BATTLE_ROUTE_120" }, + [291] = { "FLAG_RECEIVED_SS_TICKET", "RECEIVED_SS_TICKET" }, + [292] = { "FLAG_MET_RIVAL_LILYCOVE", "MET_RIVAL_LILYCOVE" }, + [293] = { "FLAG_MET_RIVAL_IN_HOUSE_AFTER_LILYCOVE", "MET_RIVAL_IN_HOUSE_AFTER_LILYCOVE" }, + [294] = { "FLAG_EXCHANGED_SCANNER", "EXCHANGED_SCANNER" }, + [295] = { "FLAG_KECLEON_FLED_FORTREE", "KECLEON_FLED_FORTREE" }, + [296] = { "FLAG_PETALBURG_MART_EXPANDED_ITEMS", "PETALBURG_MART_EXPANDED_ITEMS" }, + [297] = { "FLAG_RECEIVED_MIRACLE_SEED", "RECEIVED_MIRACLE_SEED" }, + [298] = { "FLAG_RECEIVED_BELDUM", "RECEIVED_BELDUM" }, + [299] = { "FLAG_RECEIVED_FANCLUB_TM_THIS_WEEK", "RECEIVED_FANCLUB_TM_THIS_WEEK" }, + [300] = { "FLAG_MET_FANCLUB_YOUNGER_BROTHER", "MET_FANCLUB_YOUNGER_BROTHER" }, + [301] = { "FLAG_RIVAL_LEFT_FOR_ROUTE103", "RIVAL_LEFT_FOR_ROUTE103" }, + [302] = { "FLAG_OMIT_DIVE_FROM_STEVEN_LETTER", "OMIT_DIVE_FROM_STEVEN_LETTER" }, + [303] = { "FLAG_HAS_MATCH_CALL", "HAS_MATCH_CALL" }, + [304] = { "FLAG_ADDED_MATCH_CALL_TO_POKENAV", "ADDED_MATCH_CALL_TO_POKENAV" }, + [305] = { "FLAG_REGISTERED_STEVEN_POKENAV", "REGISTERED_STEVEN_POKENAV" }, + [306] = { "FLAG_ENABLE_NORMAN_MATCH_CALL", "ENABLE_NORMAN_MATCH_CALL" }, + [307] = { "FLAG_STEVEN_GUIDES_TO_CAVE_OF_ORIGIN", "STEVEN_GUIDES_TO_CAVE_OF_ORIGIN" }, + [308] = { "FLAG_MET_ARCHIE_SOOTOPOLIS", "MET_ARCHIE_SOOTOPOLIS" }, + [309] = { "FLAG_MET_MAXIE_SOOTOPOLIS", "MET_MAXIE_SOOTOPOLIS" }, + [310] = { "FLAG_MET_SCOTT_RUSTBORO", "MET_SCOTT_RUSTBORO" }, + [311] = { "FLAG_WALLACE_GOES_TO_SKY_PILLAR", "WALLACE_GOES_TO_SKY_PILLAR" }, + [312] = { "FLAG_RECEIVED_HM_WATERFALL", "RECEIVED_HM_WATERFALL" }, + [313] = { "FLAG_BEAT_MAGMA_GRUNT_JAGGED_PASS", "BEAT_MAGMA_GRUNT_JAGGED_PASS" }, + [314] = { "FLAG_RECEIVED_AURORA_TICKET", "RECEIVED_AURORA_TICKET" }, + [315] = { "FLAG_RECEIVED_MYSTIC_TICKET", "RECEIVED_MYSTIC_TICKET" }, + [316] = { "FLAG_RECEIVED_OLD_SEA_MAP", "RECEIVED_OLD_SEA_MAP" }, + [317] = { "FLAG_WONDER_CARD_UNUSED_1", "WONDER_CARD_UNUSED_1" }, + [318] = { "FLAG_WONDER_CARD_UNUSED_2", "WONDER_CARD_UNUSED_2" }, + [319] = { "FLAG_WONDER_CARD_UNUSED_3", "WONDER_CARD_UNUSED_3" }, + [320] = { "FLAG_WONDER_CARD_UNUSED_4", "WONDER_CARD_UNUSED_4" }, + [321] = { "FLAG_WONDER_CARD_UNUSED_5", "WONDER_CARD_UNUSED_5" }, + [322] = { "FLAG_WONDER_CARD_UNUSED_6", "WONDER_CARD_UNUSED_6" }, + [323] = { "FLAG_WONDER_CARD_UNUSED_7", "WONDER_CARD_UNUSED_7" }, + [324] = { "FLAG_WONDER_CARD_UNUSED_8", "WONDER_CARD_UNUSED_8" }, + [325] = { "FLAG_WONDER_CARD_UNUSED_9", "WONDER_CARD_UNUSED_9" }, + [326] = { "FLAG_WONDER_CARD_UNUSED_10", "WONDER_CARD_UNUSED_10" }, + [327] = { "FLAG_WONDER_CARD_UNUSED_11", "WONDER_CARD_UNUSED_11" }, + [328] = { "FLAG_WONDER_CARD_UNUSED_12", "WONDER_CARD_UNUSED_12" }, + [329] = { "FLAG_WONDER_CARD_UNUSED_13", "WONDER_CARD_UNUSED_13" }, + [330] = { "FLAG_WONDER_CARD_UNUSED_14", "WONDER_CARD_UNUSED_14" }, + [331] = { "FLAG_WONDER_CARD_UNUSED_15", "WONDER_CARD_UNUSED_15" }, + [332] = { "FLAG_WONDER_CARD_UNUSED_16", "WONDER_CARD_UNUSED_16" }, + [333] = { "FLAG_WONDER_CARD_UNUSED_17", "WONDER_CARD_UNUSED_17" }, + [334] = { "FLAG_MIRAGE_TOWER_VISIBLE", "MIRAGE_TOWER_VISIBLE" }, + [335] = { "FLAG_CHOSE_ROOT_FOSSIL", "CHOSE_ROOT_FOSSIL" }, + [336] = { "FLAG_CHOSE_CLAW_FOSSIL", "CHOSE_CLAW_FOSSIL" }, + [337] = { "FLAG_RECEIVED_POWDER_JAR", "RECEIVED_POWDER_JAR" }, + [338] = { "FLAG_CHOSEN_MULTI_BATTLE_NPC_PARTNER", "CHOSEN_MULTI_BATTLE_NPC_PARTNER" }, + [339] = { "FLAG_MET_BATTLE_FRONTIER_BREEDER", "MET_BATTLE_FRONTIER_BREEDER" }, + [340] = { "FLAG_MET_BATTLE_FRONTIER_MANIAC", "MET_BATTLE_FRONTIER_MANIAC" }, + [341] = { "FLAG_ENTERED_CONTEST", "ENTERED_CONTEST" }, + [342] = { "FLAG_MET_SLATEPORT_FANCLUB_CHAIRMAN", "MET_SLATEPORT_FANCLUB_CHAIRMAN" }, + [343] = { "FLAG_MET_BATTLE_FRONTIER_GAMBLER", "MET_BATTLE_FRONTIER_GAMBLER" }, + [344] = { "FLAG_ENABLE_MR_STONE_POKENAV", "ENABLE_MR_STONE_POKENAV" }, + [345] = { "FLAG_NURSE_MENTIONS_GOLD_CARD", "NURSE_MENTIONS_GOLD_CARD" }, + [346] = { "FLAG_MET_FRONTIER_BEAUTY_MOVE_TUTOR", "MET_FRONTIER_BEAUTY_MOVE_TUTOR" }, + [347] = { "FLAG_MET_FRONTIER_SWIMMER_MOVE_TUTOR", "MET_FRONTIER_SWIMMER_MOVE_TUTOR" }, + [348] = { "FLAG_REGISTERED_ROSE", "REGISTERED_ROSE", "TRAINER_REGISTERED_FLAGS_START" }, + [349] = { "FLAG_REGISTERED_ANDRES", "REGISTERED_ANDRES" }, + [350] = { "FLAG_REGISTERED_DUSTY", "REGISTERED_DUSTY" }, + [351] = { "FLAG_REGISTERED_LOLA", "REGISTERED_LOLA" }, + [352] = { "FLAG_REGISTERED_RICKY", "REGISTERED_RICKY" }, + [353] = { "FLAG_REGISTERED_LILA_AND_ROY", "REGISTERED_LILA_AND_ROY" }, + [354] = { "FLAG_REGISTERED_CRISTIN", "REGISTERED_CRISTIN" }, + [355] = { "FLAG_REGISTERED_BROOKE", "REGISTERED_BROOKE" }, + [356] = { "FLAG_REGISTERED_WILTON", "REGISTERED_WILTON" }, + [357] = { "FLAG_REGISTERED_VALERIE", "REGISTERED_VALERIE" }, + [358] = { "FLAG_REGISTERED_CINDY", "REGISTERED_CINDY" }, + [359] = { "FLAG_REGISTERED_THALIA", "REGISTERED_THALIA" }, + [360] = { "FLAG_REGISTERED_JESSICA", "REGISTERED_JESSICA" }, + [361] = { "FLAG_REGISTERED_WINSTON", "REGISTERED_WINSTON" }, + [362] = { "FLAG_REGISTERED_STEVE", "REGISTERED_STEVE" }, + [363] = { "FLAG_REGISTERED_TONY", "REGISTERED_TONY" }, + [364] = { "FLAG_REGISTERED_NOB", "REGISTERED_NOB" }, + [365] = { "FLAG_REGISTERED_KOJI", "REGISTERED_KOJI" }, + [366] = { "FLAG_REGISTERED_FERNANDO", "REGISTERED_FERNANDO" }, + [367] = { "FLAG_REGISTERED_DALTON", "REGISTERED_DALTON" }, + [368] = { "FLAG_REGISTERED_BERNIE", "REGISTERED_BERNIE" }, + [369] = { "FLAG_REGISTERED_ETHAN", "REGISTERED_ETHAN" }, + [370] = { "FLAG_REGISTERED_JOHN_AND_JAY", "REGISTERED_JOHN_AND_JAY" }, + [371] = { "FLAG_REGISTERED_JEFFREY", "REGISTERED_JEFFREY" }, + [372] = { "FLAG_REGISTERED_CAMERON", "REGISTERED_CAMERON" }, + [373] = { "FLAG_REGISTERED_JACKI", "REGISTERED_JACKI" }, + [374] = { "FLAG_REGISTERED_WALTER", "REGISTERED_WALTER" }, + [375] = { "FLAG_REGISTERED_KAREN", "REGISTERED_KAREN" }, + [376] = { "FLAG_REGISTERED_JERRY", "REGISTERED_JERRY" }, + [377] = { "FLAG_REGISTERED_ANNA_AND_MEG", "REGISTERED_ANNA_AND_MEG" }, + [378] = { "FLAG_REGISTERED_ISABEL", "REGISTERED_ISABEL" }, + [379] = { "FLAG_REGISTERED_MIGUEL", "REGISTERED_MIGUEL" }, + [380] = { "FLAG_REGISTERED_TIMOTHY", "REGISTERED_TIMOTHY" }, + [381] = { "FLAG_REGISTERED_SHELBY", "REGISTERED_SHELBY" }, + [382] = { "FLAG_REGISTERED_CALVIN", "REGISTERED_CALVIN" }, + [383] = { "FLAG_REGISTERED_ELLIOT", "REGISTERED_ELLIOT" }, + [384] = { "FLAG_REGISTERED_ISAIAH", "REGISTERED_ISAIAH" }, + [385] = { "FLAG_REGISTERED_MARIA", "REGISTERED_MARIA" }, + [386] = { "FLAG_REGISTERED_ABIGAIL", "REGISTERED_ABIGAIL" }, + [387] = { "FLAG_REGISTERED_DYLAN", "REGISTERED_DYLAN" }, + [388] = { "FLAG_REGISTERED_KATELYN", "REGISTERED_KATELYN" }, + [389] = { "FLAG_REGISTERED_BENJAMIN", "REGISTERED_BENJAMIN" }, + [390] = { "FLAG_REGISTERED_PABLO", "REGISTERED_PABLO" }, + [391] = { "FLAG_REGISTERED_NICOLAS", "REGISTERED_NICOLAS" }, + [392] = { "FLAG_REGISTERED_ROBERT", "REGISTERED_ROBERT" }, + [393] = { "FLAG_REGISTERED_LAO", "REGISTERED_LAO" }, + [394] = { "FLAG_REGISTERED_CYNDY", "REGISTERED_CYNDY" }, + [395] = { "FLAG_REGISTERED_MADELINE", "REGISTERED_MADELINE" }, + [396] = { "FLAG_REGISTERED_JENNY", "REGISTERED_JENNY" }, + [397] = { "FLAG_REGISTERED_DIANA", "REGISTERED_DIANA" }, + [398] = { "FLAG_REGISTERED_AMY_AND_LIV", "REGISTERED_AMY_AND_LIV" }, + [399] = { "FLAG_REGISTERED_ERNEST", "REGISTERED_ERNEST" }, + [400] = { "FLAG_REGISTERED_CORY", "REGISTERED_CORY" }, + [401] = { "FLAG_REGISTERED_EDWIN", "REGISTERED_EDWIN" }, + [402] = { "FLAG_REGISTERED_LYDIA", "REGISTERED_LYDIA" }, + [403] = { "FLAG_REGISTERED_ISAAC", "REGISTERED_ISAAC" }, + [404] = { "FLAG_REGISTERED_GABRIELLE", "REGISTERED_GABRIELLE" }, + [405] = { "FLAG_REGISTERED_CATHERINE", "REGISTERED_CATHERINE" }, + [406] = { "FLAG_REGISTERED_JACKSON", "REGISTERED_JACKSON" }, + [407] = { "FLAG_REGISTERED_HALEY", "REGISTERED_HALEY" }, + [408] = { "FLAG_REGISTERED_JAMES", "REGISTERED_JAMES" }, + [409] = { "FLAG_REGISTERED_TRENT", "REGISTERED_TRENT" }, + [410] = { "FLAG_REGISTERED_SAWYER", "REGISTERED_SAWYER" }, + [411] = { "FLAG_REGISTERED_KIRA_AND_DAN", "REGISTERED_KIRA_AND_DAN" }, + [412] = { "FLAG_REGISTERED_WALLY", "REGISTERED_WALLY" }, + [413] = { "FLAG_REGISTERED_ROXANNE", "REGISTERED_ROXANNE" }, + [414] = { "FLAG_REGISTERED_BRAWLY", "REGISTERED_BRAWLY" }, + [415] = { "FLAG_REGISTERED_WATTSON", "REGISTERED_WATTSON" }, + [416] = { "FLAG_REGISTERED_FLANNERY", "REGISTERED_FLANNERY" }, + [417] = { "FLAG_REGISTERED_NORMAN", "REGISTERED_NORMAN" }, + [418] = { "FLAG_REGISTERED_WINONA", "REGISTERED_WINONA" }, + [419] = { "FLAG_REGISTERED_TATE_AND_LIZA", "REGISTERED_TATE_AND_LIZA" }, + [420] = { "FLAG_REGISTERED_JUAN", "REGISTERED_JUAN" }, + [421] = { "FLAG_REGISTERED_SIDNEY", "REGISTERED_SIDNEY" }, + [422] = { "FLAG_REGISTERED_PHOEBE", "REGISTERED_PHOEBE" }, + [423] = { "FLAG_REGISTERED_GLACIA", "REGISTERED_GLACIA" }, + [424] = { "FLAG_REGISTERED_DRAKE", "REGISTERED_DRAKE" }, + [425] = { "FLAG_REGISTERED_WALLACE", "REGISTERED_WALLACE" }, + [426] = { "FLAG_UNUSED_0x1AA", "UNUSED_0x1AA" }, + [427] = { "FLAG_UNUSED_0x1AB", "UNUSED_0x1AB" }, + [428] = { "FLAG_DEFEATED_DEOXYS", "DEFEATED_DEOXYS" }, + [429] = { "FLAG_BATTLED_DEOXYS", "BATTLED_DEOXYS" }, + [430] = { "FLAG_SHOWN_EON_TICKET", "SHOWN_EON_TICKET" }, + [431] = { "FLAG_SHOWN_AURORA_TICKET", "SHOWN_AURORA_TICKET" }, + [432] = { "FLAG_SHOWN_OLD_SEA_MAP", "SHOWN_OLD_SEA_MAP" }, + [433] = { "FLAG_MOVE_TUTOR_TAUGHT_SWAGGER", "MOVE_TUTOR_TAUGHT_SWAGGER" }, + [434] = { "FLAG_MOVE_TUTOR_TAUGHT_ROLLOUT", "MOVE_TUTOR_TAUGHT_ROLLOUT" }, + [435] = { "FLAG_MOVE_TUTOR_TAUGHT_FURY_CUTTER", "MOVE_TUTOR_TAUGHT_FURY_CUTTER" }, + [436] = { "FLAG_MOVE_TUTOR_TAUGHT_MIMIC", "MOVE_TUTOR_TAUGHT_MIMIC" }, + [437] = { "FLAG_MOVE_TUTOR_TAUGHT_METRONOME", "MOVE_TUTOR_TAUGHT_METRONOME" }, + [438] = { "FLAG_MOVE_TUTOR_TAUGHT_SLEEP_TALK", "MOVE_TUTOR_TAUGHT_SLEEP_TALK" }, + [439] = { "FLAG_MOVE_TUTOR_TAUGHT_SUBSTITUTE", "MOVE_TUTOR_TAUGHT_SUBSTITUTE" }, + [440] = { "FLAG_MOVE_TUTOR_TAUGHT_DYNAMICPUNCH", "MOVE_TUTOR_TAUGHT_DYNAMICPUNCH" }, + [441] = { "FLAG_MOVE_TUTOR_TAUGHT_DOUBLE_EDGE", "MOVE_TUTOR_TAUGHT_DOUBLE_EDGE" }, + [442] = { "FLAG_MOVE_TUTOR_TAUGHT_EXPLOSION", "MOVE_TUTOR_TAUGHT_EXPLOSION" }, + [443] = { "FLAG_DEFEATED_REGIROCK", "DEFEATED_REGIROCK" }, + [444] = { "FLAG_DEFEATED_REGICE", "DEFEATED_REGICE" }, + [445] = { "FLAG_DEFEATED_REGISTEEL", "DEFEATED_REGISTEEL" }, + [446] = { "FLAG_DEFEATED_KYOGRE", "DEFEATED_KYOGRE" }, + [447] = { "FLAG_DEFEATED_GROUDON", "DEFEATED_GROUDON" }, + [448] = { "FLAG_DEFEATED_RAYQUAZA", "DEFEATED_RAYQUAZA" }, + [449] = { "FLAG_DEFEATED_VOLTORB_1_NEW_MAUVILLE", "DEFEATED_VOLTORB_1_NEW_MAUVILLE" }, + [450] = { "FLAG_DEFEATED_VOLTORB_2_NEW_MAUVILLE", "DEFEATED_VOLTORB_2_NEW_MAUVILLE" }, + [451] = { "FLAG_DEFEATED_VOLTORB_3_NEW_MAUVILLE", "DEFEATED_VOLTORB_3_NEW_MAUVILLE" }, + [452] = { "FLAG_DEFEATED_ELECTRODE_1_AQUA_HIDEOUT", "DEFEATED_ELECTRODE_1_AQUA_HIDEOUT" }, + [453] = { "FLAG_DEFEATED_ELECTRODE_2_AQUA_HIDEOUT", "DEFEATED_ELECTRODE_2_AQUA_HIDEOUT" }, + [454] = { "FLAG_DEFEATED_SUDOWOODO", "DEFEATED_SUDOWOODO" }, + [455] = { "FLAG_DEFEATED_MEW", "DEFEATED_MEW" }, + [456] = { "FLAG_DEFEATED_LATIAS_OR_LATIOS", "DEFEATED_LATIAS_OR_LATIOS" }, + [457] = { "FLAG_CAUGHT_LATIAS_OR_LATIOS", "CAUGHT_LATIAS_OR_LATIOS" }, + [458] = { "FLAG_CAUGHT_MEW", "CAUGHT_MEW" }, + [459] = { "FLAG_MET_SCOTT_AFTER_OBTAINING_STONE_BADGE", "MET_SCOTT_AFTER_OBTAINING_STONE_BADGE" }, + [460] = { "FLAG_MET_SCOTT_IN_VERDANTURF", "MET_SCOTT_IN_VERDANTURF" }, + [461] = { "FLAG_MET_SCOTT_IN_FALLARBOR", "MET_SCOTT_IN_FALLARBOR" }, + [462] = { "FLAG_MET_SCOTT_IN_LILYCOVE", "MET_SCOTT_IN_LILYCOVE" }, + [463] = { "FLAG_MET_SCOTT_IN_EVERGRANDE", "MET_SCOTT_IN_EVERGRANDE" }, + [464] = { "FLAG_MET_SCOTT_ON_SS_TIDAL", "MET_SCOTT_ON_SS_TIDAL" }, + [465] = { "FLAG_SCOTT_GIVES_BATTLE_POINTS", "SCOTT_GIVES_BATTLE_POINTS" }, + [466] = { "FLAG_COLLECTED_ALL_GOLD_SYMBOLS", "COLLECTED_ALL_GOLD_SYMBOLS" }, + [467] = { "FLAG_ENABLE_ROXANNE_MATCH_CALL", "ENABLE_ROXANNE_MATCH_CALL" }, + [468] = { "FLAG_ENABLE_BRAWLY_MATCH_CALL", "ENABLE_BRAWLY_MATCH_CALL" }, + [469] = { "FLAG_ENABLE_WATTSON_MATCH_CALL", "ENABLE_WATTSON_MATCH_CALL" }, + [470] = { "FLAG_ENABLE_FLANNERY_MATCH_CALL", "ENABLE_FLANNERY_MATCH_CALL" }, + [471] = { "FLAG_ENABLE_WINONA_MATCH_CALL", "ENABLE_WINONA_MATCH_CALL" }, + [472] = { "FLAG_ENABLE_TATE_AND_LIZA_MATCH_CALL", "ENABLE_TATE_AND_LIZA_MATCH_CALL" }, + [473] = { "FLAG_ENABLE_JUAN_MATCH_CALL", "ENABLE_JUAN_MATCH_CALL" }, + [474] = { "FLAG_UNUSED_0x1DA", "UNUSED_0x1DA" }, + [475] = { "FLAG_SHOWN_MYSTIC_TICKET", "SHOWN_MYSTIC_TICKET" }, + [476] = { "FLAG_DEFEATED_HO_OH", "DEFEATED_HO_OH" }, + [477] = { "FLAG_DEFEATED_LUGIA", "DEFEATED_LUGIA" }, + [478] = { "FLAG_UNUSED_0x1DE", "UNUSED_0x1DE" }, + [479] = { "FLAG_UNUSED_0x1DF", "UNUSED_0x1DF" }, + [480] = { "FLAG_UNUSED_0x1E0", "UNUSED_0x1E0" }, + [481] = { "FLAG_UNUSED_0x1E1", "UNUSED_0x1E1" }, + [482] = { "FLAG_UNUSED_0x1E2", "UNUSED_0x1E2" }, + [483] = { "FLAG_UNUSED_0x1E3", "UNUSED_0x1E3" }, + [484] = { "FLAG_MYSTERY_GIFT_DONE", "MYSTERY_GIFT_DONE" }, + [485] = { "FLAG_MYSTERY_GIFT_1", "MYSTERY_GIFT_1" }, + [486] = { "FLAG_MYSTERY_GIFT_2", "MYSTERY_GIFT_2" }, + [487] = { "FLAG_MYSTERY_GIFT_3", "MYSTERY_GIFT_3" }, + [488] = { "FLAG_MYSTERY_GIFT_4", "MYSTERY_GIFT_4" }, + [489] = { "FLAG_MYSTERY_GIFT_5", "MYSTERY_GIFT_5" }, + [490] = { "FLAG_MYSTERY_GIFT_6", "MYSTERY_GIFT_6" }, + [491] = { "FLAG_MYSTERY_GIFT_7", "MYSTERY_GIFT_7" }, + [492] = { "FLAG_MYSTERY_GIFT_8", "MYSTERY_GIFT_8" }, + [493] = { "FLAG_MYSTERY_GIFT_9", "MYSTERY_GIFT_9" }, + [494] = { "FLAG_MYSTERY_GIFT_10", "MYSTERY_GIFT_10" }, + [495] = { "FLAG_MYSTERY_GIFT_11", "MYSTERY_GIFT_11" }, + [496] = { "FLAG_MYSTERY_GIFT_12", "MYSTERY_GIFT_12" }, + [497] = { "FLAG_MYSTERY_GIFT_13", "MYSTERY_GIFT_13" }, + [498] = { "FLAG_MYSTERY_GIFT_14", "MYSTERY_GIFT_14" }, + [499] = { "FLAG_MYSTERY_GIFT_15", "MYSTERY_GIFT_15" }, + [500] = { "FLAG_HIDDEN_ITEM_LAVARIDGE_TOWN_ICE_HEAL", "FLAG_HIDDEN_ITEMS_START", "HIDDEN_ITEMS_START", "HIDDEN_ITEM_LAVARIDGE_TOWN_ICE_HEAL" }, + [501] = { "FLAG_HIDDEN_ITEM_TRICK_HOUSE_NUGGET", "HIDDEN_ITEM_TRICK_HOUSE_NUGGET" }, + [502] = { "FLAG_HIDDEN_ITEM_ROUTE_111_STARDUST", "HIDDEN_ITEM_ROUTE_111_STARDUST" }, + [503] = { "FLAG_HIDDEN_ITEM_ROUTE_113_ETHER", "HIDDEN_ITEM_ROUTE_113_ETHER" }, + [504] = { "FLAG_HIDDEN_ITEM_ROUTE_114_CARBOS", "HIDDEN_ITEM_ROUTE_114_CARBOS" }, + [505] = { "FLAG_HIDDEN_ITEM_ROUTE_119_CALCIUM", "HIDDEN_ITEM_ROUTE_119_CALCIUM" }, + [506] = { "FLAG_HIDDEN_ITEM_ROUTE_119_ULTRA_BALL", "HIDDEN_ITEM_ROUTE_119_ULTRA_BALL" }, + [507] = { "FLAG_HIDDEN_ITEM_ROUTE_123_SUPER_REPEL", "HIDDEN_ITEM_ROUTE_123_SUPER_REPEL" }, + [508] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_CARBOS", "HIDDEN_ITEM_UNDERWATER_124_CARBOS" }, + [509] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_GREEN_SHARD", "HIDDEN_ITEM_UNDERWATER_124_GREEN_SHARD" }, + [510] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_PEARL", "HIDDEN_ITEM_UNDERWATER_124_PEARL" }, + [511] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_BIG_PEARL", "HIDDEN_ITEM_UNDERWATER_124_BIG_PEARL" }, + [512] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_BLUE_SHARD", "HIDDEN_ITEM_UNDERWATER_126_BLUE_SHARD" }, + [513] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_HEART_SCALE_1", "HIDDEN_ITEM_UNDERWATER_124_HEART_SCALE_1" }, + [514] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_HEART_SCALE", "HIDDEN_ITEM_UNDERWATER_126_HEART_SCALE" }, + [515] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_ULTRA_BALL", "HIDDEN_ITEM_UNDERWATER_126_ULTRA_BALL" }, + [516] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_STARDUST", "HIDDEN_ITEM_UNDERWATER_126_STARDUST" }, + [517] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_PEARL", "HIDDEN_ITEM_UNDERWATER_126_PEARL" }, + [518] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_YELLOW_SHARD", "HIDDEN_ITEM_UNDERWATER_126_YELLOW_SHARD" }, + [519] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_IRON", "HIDDEN_ITEM_UNDERWATER_126_IRON" }, + [520] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_BIG_PEARL", "HIDDEN_ITEM_UNDERWATER_126_BIG_PEARL" }, + [521] = { "FLAG_HIDDEN_ITEM_UNDERWATER_127_STAR_PIECE", "HIDDEN_ITEM_UNDERWATER_127_STAR_PIECE" }, + [522] = { "FLAG_HIDDEN_ITEM_UNDERWATER_127_HP_UP", "HIDDEN_ITEM_UNDERWATER_127_HP_UP" }, + [523] = { "FLAG_HIDDEN_ITEM_UNDERWATER_127_HEART_SCALE", "HIDDEN_ITEM_UNDERWATER_127_HEART_SCALE" }, + [524] = { "FLAG_HIDDEN_ITEM_UNDERWATER_127_RED_SHARD", "HIDDEN_ITEM_UNDERWATER_127_RED_SHARD" }, + [525] = { "FLAG_HIDDEN_ITEM_UNDERWATER_128_PROTEIN", "HIDDEN_ITEM_UNDERWATER_128_PROTEIN" }, + [526] = { "FLAG_HIDDEN_ITEM_UNDERWATER_128_PEARL", "HIDDEN_ITEM_UNDERWATER_128_PEARL" }, + [527] = { "FLAG_HIDDEN_ITEM_LILYCOVE_CITY_HEART_SCALE", "HIDDEN_ITEM_LILYCOVE_CITY_HEART_SCALE" }, + [528] = { "FLAG_HIDDEN_ITEM_FALLARBOR_TOWN_NUGGET", "HIDDEN_ITEM_FALLARBOR_TOWN_NUGGET" }, + [529] = { "FLAG_HIDDEN_ITEM_MT_PYRE_EXTERIOR_ULTRA_BALL", "HIDDEN_ITEM_MT_PYRE_EXTERIOR_ULTRA_BALL" }, + [530] = { "FLAG_HIDDEN_ITEM_ROUTE_113_TM_DOUBLE_TEAM", "HIDDEN_ITEM_ROUTE_113_TM_DOUBLE_TEAM" }, + [531] = { "FLAG_HIDDEN_ITEM_ABANDONED_SHIP_RM_1_KEY", "HIDDEN_ITEM_ABANDONED_SHIP_RM_1_KEY" }, + [532] = { "FLAG_HIDDEN_ITEM_ABANDONED_SHIP_RM_2_KEY", "HIDDEN_ITEM_ABANDONED_SHIP_RM_2_KEY" }, + [533] = { "FLAG_HIDDEN_ITEM_ABANDONED_SHIP_RM_4_KEY", "HIDDEN_ITEM_ABANDONED_SHIP_RM_4_KEY" }, + [534] = { "FLAG_HIDDEN_ITEM_ABANDONED_SHIP_RM_6_KEY", "HIDDEN_ITEM_ABANDONED_SHIP_RM_6_KEY" }, + [535] = { "FLAG_HIDDEN_ITEM_SS_TIDAL_LOWER_DECK_LEFTOVERS", "HIDDEN_ITEM_SS_TIDAL_LOWER_DECK_LEFTOVERS" }, + [536] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_CALCIUM", "HIDDEN_ITEM_UNDERWATER_124_CALCIUM" }, + [537] = { "FLAG_HIDDEN_ITEM_ROUTE_104_POTION", "HIDDEN_ITEM_ROUTE_104_POTION" }, + [538] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_HEART_SCALE_2", "HIDDEN_ITEM_UNDERWATER_124_HEART_SCALE_2" }, + [539] = { "FLAG_HIDDEN_ITEM_ROUTE_121_HP_UP", "HIDDEN_ITEM_ROUTE_121_HP_UP" }, + [540] = { "FLAG_HIDDEN_ITEM_ROUTE_121_NUGGET", "HIDDEN_ITEM_ROUTE_121_NUGGET" }, + [541] = { "FLAG_HIDDEN_ITEM_ROUTE_123_REVIVE", "HIDDEN_ITEM_ROUTE_123_REVIVE" }, + [542] = { "FLAG_HIDDEN_ITEM_ROUTE_114_REVIVE", "HIDDEN_ITEM_ROUTE_114_REVIVE" }, + [543] = { "FLAG_HIDDEN_ITEM_LILYCOVE_CITY_PP_UP", "HIDDEN_ITEM_LILYCOVE_CITY_PP_UP" }, + [544] = { "FLAG_HIDDEN_ITEM_ROUTE_104_SUPER_POTION", "HIDDEN_ITEM_ROUTE_104_SUPER_POTION" }, + [545] = { "FLAG_HIDDEN_ITEM_ROUTE_116_SUPER_POTION", "HIDDEN_ITEM_ROUTE_116_SUPER_POTION" }, + [546] = { "FLAG_HIDDEN_ITEM_ROUTE_106_STARDUST", "HIDDEN_ITEM_ROUTE_106_STARDUST" }, + [547] = { "FLAG_HIDDEN_ITEM_ROUTE_106_HEART_SCALE", "HIDDEN_ITEM_ROUTE_106_HEART_SCALE" }, + [548] = { "FLAG_HIDDEN_ITEM_GRANITE_CAVE_B2F_EVERSTONE_1", "HIDDEN_ITEM_GRANITE_CAVE_B2F_EVERSTONE_1" }, + [549] = { "FLAG_HIDDEN_ITEM_GRANITE_CAVE_B2F_EVERSTONE_2", "HIDDEN_ITEM_GRANITE_CAVE_B2F_EVERSTONE_2" }, + [550] = { "FLAG_HIDDEN_ITEM_ROUTE_109_REVIVE", "HIDDEN_ITEM_ROUTE_109_REVIVE" }, + [551] = { "FLAG_HIDDEN_ITEM_ROUTE_109_GREAT_BALL", "HIDDEN_ITEM_ROUTE_109_GREAT_BALL" }, + [552] = { "FLAG_HIDDEN_ITEM_ROUTE_109_HEART_SCALE_1", "HIDDEN_ITEM_ROUTE_109_HEART_SCALE_1" }, + [553] = { "FLAG_HIDDEN_ITEM_ROUTE_110_GREAT_BALL", "HIDDEN_ITEM_ROUTE_110_GREAT_BALL" }, + [554] = { "FLAG_HIDDEN_ITEM_ROUTE_110_REVIVE", "HIDDEN_ITEM_ROUTE_110_REVIVE" }, + [555] = { "FLAG_HIDDEN_ITEM_ROUTE_110_FULL_HEAL", "HIDDEN_ITEM_ROUTE_110_FULL_HEAL" }, + [556] = { "FLAG_HIDDEN_ITEM_ROUTE_111_PROTEIN", "HIDDEN_ITEM_ROUTE_111_PROTEIN" }, + [557] = { "FLAG_HIDDEN_ITEM_ROUTE_111_RARE_CANDY", "HIDDEN_ITEM_ROUTE_111_RARE_CANDY" }, + [558] = { "FLAG_HIDDEN_ITEM_PETALBURG_WOODS_POTION", "HIDDEN_ITEM_PETALBURG_WOODS_POTION" }, + [559] = { "FLAG_HIDDEN_ITEM_PETALBURG_WOODS_TINY_MUSHROOM_1", "HIDDEN_ITEM_PETALBURG_WOODS_TINY_MUSHROOM_1" }, + [560] = { "FLAG_HIDDEN_ITEM_PETALBURG_WOODS_TINY_MUSHROOM_2", "HIDDEN_ITEM_PETALBURG_WOODS_TINY_MUSHROOM_2" }, + [561] = { "FLAG_HIDDEN_ITEM_PETALBURG_WOODS_POKE_BALL", "HIDDEN_ITEM_PETALBURG_WOODS_POKE_BALL" }, + [562] = { "FLAG_HIDDEN_ITEM_ROUTE_104_POKE_BALL", "HIDDEN_ITEM_ROUTE_104_POKE_BALL" }, + [563] = { "FLAG_HIDDEN_ITEM_ROUTE_106_POKE_BALL", "HIDDEN_ITEM_ROUTE_106_POKE_BALL" }, + [564] = { "FLAG_HIDDEN_ITEM_ROUTE_109_ETHER", "HIDDEN_ITEM_ROUTE_109_ETHER" }, + [565] = { "FLAG_HIDDEN_ITEM_ROUTE_110_POKE_BALL", "HIDDEN_ITEM_ROUTE_110_POKE_BALL" }, + [566] = { "FLAG_HIDDEN_ITEM_ROUTE_118_HEART_SCALE", "HIDDEN_ITEM_ROUTE_118_HEART_SCALE" }, + [567] = { "FLAG_HIDDEN_ITEM_ROUTE_118_IRON", "HIDDEN_ITEM_ROUTE_118_IRON" }, + [568] = { "FLAG_HIDDEN_ITEM_ROUTE_119_FULL_HEAL", "HIDDEN_ITEM_ROUTE_119_FULL_HEAL" }, + [569] = { "FLAG_HIDDEN_ITEM_ROUTE_120_RARE_CANDY_2", "HIDDEN_ITEM_ROUTE_120_RARE_CANDY_2" }, + [570] = { "FLAG_HIDDEN_ITEM_ROUTE_120_ZINC", "HIDDEN_ITEM_ROUTE_120_ZINC" }, + [571] = { "FLAG_HIDDEN_ITEM_ROUTE_120_RARE_CANDY_1", "HIDDEN_ITEM_ROUTE_120_RARE_CANDY_1" }, + [572] = { "FLAG_HIDDEN_ITEM_ROUTE_117_REPEL", "HIDDEN_ITEM_ROUTE_117_REPEL" }, + [573] = { "FLAG_HIDDEN_ITEM_ROUTE_121_FULL_HEAL", "HIDDEN_ITEM_ROUTE_121_FULL_HEAL" }, + [574] = { "FLAG_HIDDEN_ITEM_ROUTE_123_HYPER_POTION", "HIDDEN_ITEM_ROUTE_123_HYPER_POTION" }, + [575] = { "FLAG_HIDDEN_ITEM_LILYCOVE_CITY_POKE_BALL", "HIDDEN_ITEM_LILYCOVE_CITY_POKE_BALL" }, + [576] = { "FLAG_HIDDEN_ITEM_JAGGED_PASS_GREAT_BALL", "HIDDEN_ITEM_JAGGED_PASS_GREAT_BALL" }, + [577] = { "FLAG_HIDDEN_ITEM_JAGGED_PASS_FULL_HEAL", "HIDDEN_ITEM_JAGGED_PASS_FULL_HEAL" }, + [578] = { "FLAG_HIDDEN_ITEM_MT_PYRE_EXTERIOR_MAX_ETHER", "HIDDEN_ITEM_MT_PYRE_EXTERIOR_MAX_ETHER" }, + [579] = { "FLAG_HIDDEN_ITEM_MT_PYRE_SUMMIT_ZINC", "HIDDEN_ITEM_MT_PYRE_SUMMIT_ZINC" }, + [580] = { "FLAG_HIDDEN_ITEM_MT_PYRE_SUMMIT_RARE_CANDY", "HIDDEN_ITEM_MT_PYRE_SUMMIT_RARE_CANDY" }, + [581] = { "FLAG_HIDDEN_ITEM_VICTORY_ROAD_1F_ULTRA_BALL", "HIDDEN_ITEM_VICTORY_ROAD_1F_ULTRA_BALL" }, + [582] = { "FLAG_HIDDEN_ITEM_VICTORY_ROAD_B2F_ELIXIR", "HIDDEN_ITEM_VICTORY_ROAD_B2F_ELIXIR" }, + [583] = { "FLAG_HIDDEN_ITEM_VICTORY_ROAD_B2F_MAX_REPEL", "HIDDEN_ITEM_VICTORY_ROAD_B2F_MAX_REPEL" }, + [584] = { "FLAG_HIDDEN_ITEM_ROUTE_120_REVIVE", "HIDDEN_ITEM_ROUTE_120_REVIVE" }, + [585] = { "FLAG_HIDDEN_ITEM_ROUTE_104_ANTIDOTE", "HIDDEN_ITEM_ROUTE_104_ANTIDOTE" }, + [586] = { "FLAG_HIDDEN_ITEM_ROUTE_108_RARE_CANDY", "HIDDEN_ITEM_ROUTE_108_RARE_CANDY" }, + [587] = { "FLAG_HIDDEN_ITEM_ROUTE_119_MAX_ETHER", "HIDDEN_ITEM_ROUTE_119_MAX_ETHER" }, + [588] = { "FLAG_HIDDEN_ITEM_ROUTE_104_HEART_SCALE", "HIDDEN_ITEM_ROUTE_104_HEART_SCALE" }, + [589] = { "FLAG_HIDDEN_ITEM_ROUTE_105_HEART_SCALE", "HIDDEN_ITEM_ROUTE_105_HEART_SCALE" }, + [590] = { "FLAG_HIDDEN_ITEM_ROUTE_109_HEART_SCALE_2", "HIDDEN_ITEM_ROUTE_109_HEART_SCALE_2" }, + [591] = { "FLAG_HIDDEN_ITEM_ROUTE_109_HEART_SCALE_3", "HIDDEN_ITEM_ROUTE_109_HEART_SCALE_3" }, + [592] = { "FLAG_HIDDEN_ITEM_ROUTE_128_HEART_SCALE_1", "HIDDEN_ITEM_ROUTE_128_HEART_SCALE_1" }, + [593] = { "FLAG_HIDDEN_ITEM_ROUTE_128_HEART_SCALE_2", "HIDDEN_ITEM_ROUTE_128_HEART_SCALE_2" }, + [594] = { "FLAG_HIDDEN_ITEM_ROUTE_128_HEART_SCALE_3", "HIDDEN_ITEM_ROUTE_128_HEART_SCALE_3" }, + [595] = { "FLAG_HIDDEN_ITEM_PETALBURG_CITY_RARE_CANDY", "HIDDEN_ITEM_PETALBURG_CITY_RARE_CANDY" }, + [596] = { "FLAG_HIDDEN_ITEM_ROUTE_116_BLACK_GLASSES", "HIDDEN_ITEM_ROUTE_116_BLACK_GLASSES" }, + [597] = { "FLAG_HIDDEN_ITEM_ROUTE_115_HEART_SCALE", "HIDDEN_ITEM_ROUTE_115_HEART_SCALE" }, + [598] = { "FLAG_HIDDEN_ITEM_ROUTE_113_NUGGET", "HIDDEN_ITEM_ROUTE_113_NUGGET" }, + [599] = { "FLAG_HIDDEN_ITEM_ROUTE_123_PP_UP", "HIDDEN_ITEM_ROUTE_123_PP_UP" }, + [600] = { "FLAG_HIDDEN_ITEM_ROUTE_121_MAX_REVIVE", "HIDDEN_ITEM_ROUTE_121_MAX_REVIVE" }, + [601] = { "FLAG_HIDDEN_ITEM_ARTISAN_CAVE_B1F_CALCIUM", "HIDDEN_ITEM_ARTISAN_CAVE_B1F_CALCIUM" }, + [602] = { "FLAG_HIDDEN_ITEM_ARTISAN_CAVE_B1F_ZINC", "HIDDEN_ITEM_ARTISAN_CAVE_B1F_ZINC" }, + [603] = { "FLAG_HIDDEN_ITEM_ARTISAN_CAVE_B1F_PROTEIN", "HIDDEN_ITEM_ARTISAN_CAVE_B1F_PROTEIN" }, + [604] = { "FLAG_HIDDEN_ITEM_ARTISAN_CAVE_B1F_IRON", "HIDDEN_ITEM_ARTISAN_CAVE_B1F_IRON" }, + [605] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_SOUTH_EAST_FULL_RESTORE", "HIDDEN_ITEM_SAFARI_ZONE_SOUTH_EAST_FULL_RESTORE" }, + [606] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_NORTH_EAST_RARE_CANDY", "HIDDEN_ITEM_SAFARI_ZONE_NORTH_EAST_RARE_CANDY" }, + [607] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_NORTH_EAST_ZINC", "HIDDEN_ITEM_SAFARI_ZONE_NORTH_EAST_ZINC" }, + [608] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_SOUTH_EAST_PP_UP", "HIDDEN_ITEM_SAFARI_ZONE_SOUTH_EAST_PP_UP" }, + [609] = { "FLAG_HIDDEN_ITEM_NAVEL_ROCK_TOP_SACRED_ASH", "HIDDEN_ITEM_NAVEL_ROCK_TOP_SACRED_ASH" }, + [610] = { "FLAG_HIDDEN_ITEM_ROUTE_123_RARE_CANDY", "HIDDEN_ITEM_ROUTE_123_RARE_CANDY" }, + [611] = { "FLAG_HIDDEN_ITEM_ROUTE_105_BIG_PEARL", "HIDDEN_ITEM_ROUTE_105_BIG_PEARL" }, + [612] = { "FLAG_UNUSED_0x264", "UNUSED_0x264" }, + [613] = { "FLAG_UNUSED_0x265", "UNUSED_0x265" }, + [614] = { "FLAG_UNUSED_0x266", "UNUSED_0x266" }, + [615] = { "FLAG_UNUSED_0x267", "UNUSED_0x267" }, + [616] = { "FLAG_UNUSED_0x268", "UNUSED_0x268" }, + [617] = { "FLAG_UNUSED_0x269", "UNUSED_0x269" }, + [618] = { "FLAG_UNUSED_0x26A", "UNUSED_0x26A" }, + [619] = { "FLAG_UNUSED_0x26B", "UNUSED_0x26B" }, + [620] = { "FLAG_UNUSED_0x26C", "UNUSED_0x26C" }, + [621] = { "FLAG_UNUSED_0x26D", "UNUSED_0x26D" }, + [622] = { "FLAG_UNUSED_0x26E", "UNUSED_0x26E" }, + [623] = { "FLAG_UNUSED_0x26F", "UNUSED_0x26F" }, + [624] = { "FLAG_UNUSED_0x270", "UNUSED_0x270" }, + [625] = { "FLAG_UNUSED_0x271", "UNUSED_0x271" }, + [626] = { "FLAG_UNUSED_0x272", "UNUSED_0x272" }, + [627] = { "FLAG_UNUSED_0x273", "UNUSED_0x273" }, + [628] = { "FLAG_UNUSED_0x274", "UNUSED_0x274" }, + [629] = { "FLAG_UNUSED_0x275", "UNUSED_0x275" }, + [630] = { "FLAG_UNUSED_0x276", "UNUSED_0x276" }, + [631] = { "FLAG_UNUSED_0x277", "UNUSED_0x277" }, + [632] = { "FLAG_UNUSED_0x278", "UNUSED_0x278" }, + [633] = { "FLAG_UNUSED_0x279", "UNUSED_0x279" }, + [634] = { "FLAG_UNUSED_0x27A", "UNUSED_0x27A" }, + [635] = { "FLAG_UNUSED_0x27B", "UNUSED_0x27B" }, + [636] = { "FLAG_UNUSED_0x27C", "UNUSED_0x27C" }, + [637] = { "FLAG_UNUSED_0x27D", "UNUSED_0x27D" }, + [638] = { "FLAG_UNUSED_0x27E", "UNUSED_0x27E" }, + [639] = { "FLAG_UNUSED_0x27F", "UNUSED_0x27F" }, + [640] = { "FLAG_UNUSED_0x280", "UNUSED_0x280" }, + [641] = { "FLAG_UNUSED_0x281", "UNUSED_0x281" }, + [642] = { "FLAG_UNUSED_0x282", "UNUSED_0x282" }, + [643] = { "FLAG_UNUSED_0x283", "UNUSED_0x283" }, + [644] = { "FLAG_UNUSED_0x284", "UNUSED_0x284" }, + [645] = { "FLAG_UNUSED_0x285", "UNUSED_0x285" }, + [646] = { "FLAG_UNUSED_0x286", "UNUSED_0x286" }, + [647] = { "FLAG_UNUSED_0x287", "UNUSED_0x287" }, + [648] = { "FLAG_UNUSED_0x288", "UNUSED_0x288" }, + [649] = { "FLAG_UNUSED_0x289", "UNUSED_0x289" }, + [650] = { "FLAG_UNUSED_0x28A", "UNUSED_0x28A" }, + [651] = { "FLAG_UNUSED_0x28B", "UNUSED_0x28B" }, + [652] = { "FLAG_UNUSED_0x28C", "UNUSED_0x28C" }, + [653] = { "FLAG_UNUSED_0x28D", "UNUSED_0x28D" }, + [654] = { "FLAG_UNUSED_0x28E", "UNUSED_0x28E" }, + [655] = { "FLAG_UNUSED_0x28F", "UNUSED_0x28F" }, + [656] = { "FLAG_UNUSED_0x290", "UNUSED_0x290" }, + [657] = { "FLAG_UNUSED_0x291", "UNUSED_0x291" }, + [658] = { "FLAG_UNUSED_0x292", "UNUSED_0x292" }, + [659] = { "FLAG_UNUSED_0x293", "UNUSED_0x293" }, + [660] = { "FLAG_UNUSED_0x294", "UNUSED_0x294" }, + [661] = { "FLAG_UNUSED_0x295", "UNUSED_0x295" }, + [662] = { "FLAG_UNUSED_0x296", "UNUSED_0x296" }, + [663] = { "FLAG_UNUSED_0x297", "UNUSED_0x297" }, + [664] = { "FLAG_UNUSED_0x298", "UNUSED_0x298" }, + [665] = { "FLAG_UNUSED_0x299", "UNUSED_0x299" }, + [666] = { "FLAG_UNUSED_0x29A", "UNUSED_0x29A" }, + [667] = { "FLAG_UNUSED_0x29B", "UNUSED_0x29B" }, + [668] = { "FLAG_UNUSED_0x29C", "UNUSED_0x29C" }, + [669] = { "FLAG_UNUSED_0x29D", "UNUSED_0x29D" }, + [670] = { "FLAG_UNUSED_0x29E", "UNUSED_0x29E" }, + [671] = { "FLAG_UNUSED_0x29F", "UNUSED_0x29F" }, + [672] = { "FLAG_UNUSED_0x2A0", "UNUSED_0x2A0" }, + [673] = { "FLAG_UNUSED_0x2A1", "UNUSED_0x2A1" }, + [674] = { "FLAG_UNUSED_0x2A2", "UNUSED_0x2A2" }, + [675] = { "FLAG_UNUSED_0x2A3", "UNUSED_0x2A3" }, + [676] = { "FLAG_UNUSED_0x2A4", "UNUSED_0x2A4" }, + [677] = { "FLAG_UNUSED_0x2A5", "UNUSED_0x2A5" }, + [678] = { "FLAG_UNUSED_0x2A6", "UNUSED_0x2A6" }, + [679] = { "FLAG_UNUSED_0x2A7", "UNUSED_0x2A7" }, + [680] = { "FLAG_UNUSED_0x2A8", "UNUSED_0x2A8" }, + [681] = { "FLAG_UNUSED_0x2A9", "UNUSED_0x2A9" }, + [682] = { "FLAG_UNUSED_0x2AA", "UNUSED_0x2AA" }, + [683] = { "FLAG_UNUSED_0x2AB", "UNUSED_0x2AB" }, + [684] = { "FLAG_UNUSED_0x2AC", "UNUSED_0x2AC" }, + [685] = { "FLAG_UNUSED_0x2AD", "UNUSED_0x2AD" }, + [686] = { "FLAG_UNUSED_0x2AE", "UNUSED_0x2AE" }, + [687] = { "FLAG_UNUSED_0x2AF", "UNUSED_0x2AF" }, + [688] = { "FLAG_UNUSED_0x2B0", "UNUSED_0x2B0" }, + [689] = { "FLAG_UNUSED_0x2B1", "UNUSED_0x2B1" }, + [690] = { "FLAG_UNUSED_0x2B2", "UNUSED_0x2B2" }, + [691] = { "FLAG_UNUSED_0x2B3", "UNUSED_0x2B3" }, + [692] = { "FLAG_UNUSED_0x2B4", "UNUSED_0x2B4" }, + [693] = { "FLAG_UNUSED_0x2B5", "UNUSED_0x2B5" }, + [694] = { "FLAG_UNUSED_0x2B6", "UNUSED_0x2B6" }, + [695] = { "FLAG_UNUSED_0x2B7", "UNUSED_0x2B7" }, + [696] = { "FLAG_UNUSED_0x2B8", "UNUSED_0x2B8" }, + [697] = { "FLAG_UNUSED_0x2B9", "UNUSED_0x2B9" }, + [698] = { "FLAG_UNUSED_0x2BA", "UNUSED_0x2BA" }, + [699] = { "FLAG_UNUSED_0x2BB", "UNUSED_0x2BB" }, + [700] = { "FLAG_HIDE_ROUTE_101_BIRCH_STARTERS_BAG", "HIDE_ROUTE_101_BIRCH_STARTERS_BAG" }, + [701] = { "FLAG_HIDE_APPRENTICE", "HIDE_APPRENTICE" }, + [702] = { "FLAG_HIDE_POKEMON_CENTER_2F_MYSTERY_GIFT_MAN", "HIDE_POKEMON_CENTER_2F_MYSTERY_GIFT_MAN" }, + [703] = { "FLAG_HIDE_UNION_ROOM_PLAYER_1", "HIDE_UNION_ROOM_PLAYER_1" }, + [704] = { "FLAG_HIDE_UNION_ROOM_PLAYER_2", "HIDE_UNION_ROOM_PLAYER_2" }, + [705] = { "FLAG_HIDE_UNION_ROOM_PLAYER_3", "HIDE_UNION_ROOM_PLAYER_3" }, + [706] = { "FLAG_HIDE_UNION_ROOM_PLAYER_4", "HIDE_UNION_ROOM_PLAYER_4" }, + [707] = { "FLAG_HIDE_UNION_ROOM_PLAYER_5", "HIDE_UNION_ROOM_PLAYER_5" }, + [708] = { "FLAG_HIDE_UNION_ROOM_PLAYER_6", "HIDE_UNION_ROOM_PLAYER_6" }, + [709] = { "FLAG_HIDE_UNION_ROOM_PLAYER_7", "HIDE_UNION_ROOM_PLAYER_7" }, + [710] = { "FLAG_HIDE_UNION_ROOM_PLAYER_8", "HIDE_UNION_ROOM_PLAYER_8" }, + [711] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_1", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_1" }, + [712] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_2", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_2" }, + [713] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_3", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_3" }, + [714] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_4", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_4" }, + [715] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_5", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_5" }, + [716] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_6", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_6" }, + [717] = { "FLAG_HIDE_SAFARI_ZONE_SOUTH_CONSTRUCTION_WORKERS", "HIDE_SAFARI_ZONE_SOUTH_CONSTRUCTION_WORKERS" }, + [718] = { "FLAG_HIDE_MEW", "HIDE_MEW" }, + [719] = { "FLAG_HIDE_ROUTE_104_RIVAL", "HIDE_ROUTE_104_RIVAL" }, + [720] = { "FLAG_HIDE_ROUTE_101_BIRCH_ZIGZAGOON_BATTLE", "HIDE_ROUTE_101_BIRCH_ZIGZAGOON_BATTLE" }, + [721] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_BIRCH", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_BIRCH" }, + [722] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_BEDROOM", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_BEDROOM" }, + [723] = { "FLAG_HIDE_ROUTE_103_RIVAL", "HIDE_ROUTE_103_RIVAL" }, + [724] = { "FLAG_HIDE_PETALBURG_WOODS_DEVON_EMPLOYEE", "HIDE_PETALBURG_WOODS_DEVON_EMPLOYEE" }, + [725] = { "FLAG_HIDE_PETALBURG_WOODS_AQUA_GRUNT", "HIDE_PETALBURG_WOODS_AQUA_GRUNT" }, + [726] = { "FLAG_HIDE_PETALBURG_CITY_WALLY", "HIDE_PETALBURG_CITY_WALLY" }, + [727] = { "FLAG_HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_INVISIBLE_NINJA_BOY", "HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_INVISIBLE_NINJA_BOY" }, + [728] = { "FLAG_HIDE_PETALBURG_CITY_WALLYS_MOM", "HIDE_PETALBURG_CITY_WALLYS_MOM" }, + [729] = { "FLAG_UNUSED_0x2D9", "UNUSED_0x2D9" }, + [730] = { "FLAG_HIDE_LILYCOVE_FAN_CLUB_INTERVIEWER", "HIDE_LILYCOVE_FAN_CLUB_INTERVIEWER" }, + [731] = { "FLAG_HIDE_RUSTBORO_CITY_AQUA_GRUNT", "HIDE_RUSTBORO_CITY_AQUA_GRUNT" }, + [732] = { "FLAG_HIDE_RUSTBORO_CITY_DEVON_EMPLOYEE_1", "HIDE_RUSTBORO_CITY_DEVON_EMPLOYEE_1" }, + [733] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ROOM_9_KYOGRE_ASLEEP", "HIDE_SEAFLOOR_CAVERN_ROOM_9_KYOGRE_ASLEEP" }, + [734] = { "FLAG_HIDE_PLAYERS_HOUSE_DAD", "HIDE_PLAYERS_HOUSE_DAD" }, + [735] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_SIBLING", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_SIBLING" }, + [736] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_SIBLING", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_SIBLING" }, + [737] = { "FLAG_HIDE_MOSSDEEP_CITY_SPACE_CENTER_MAGMA_NOTE", "HIDE_MOSSDEEP_CITY_SPACE_CENTER_MAGMA_NOTE" }, + [738] = { "FLAG_HIDE_ROUTE_104_MR_BRINEY", "HIDE_ROUTE_104_MR_BRINEY" }, + [739] = { "FLAG_HIDE_BRINEYS_HOUSE_MR_BRINEY", "HIDE_BRINEYS_HOUSE_MR_BRINEY" }, + [740] = { "FLAG_HIDE_MR_BRINEY_DEWFORD_TOWN", "HIDE_MR_BRINEY_DEWFORD_TOWN" }, + [741] = { "FLAG_HIDE_ROUTE_109_MR_BRINEY", "HIDE_ROUTE_109_MR_BRINEY" }, + [742] = { "FLAG_HIDE_ROUTE_104_MR_BRINEY_BOAT", "HIDE_ROUTE_104_MR_BRINEY_BOAT" }, + [743] = { "FLAG_HIDE_MR_BRINEY_BOAT_DEWFORD_TOWN", "HIDE_MR_BRINEY_BOAT_DEWFORD_TOWN" }, + [744] = { "FLAG_HIDE_ROUTE_109_MR_BRINEY_BOAT", "HIDE_ROUTE_109_MR_BRINEY_BOAT" }, + [745] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_BRENDAN", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_BRENDAN" }, + [746] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_MAY", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_MAY" }, + [747] = { "FLAG_HIDE_SAFARI_ZONE_SOUTH_EAST_EXPANSION", "HIDE_SAFARI_ZONE_SOUTH_EAST_EXPANSION" }, + [748] = { "FLAG_HIDE_LILYCOVE_HARBOR_EVENT_TICKET_TAKER", "HIDE_LILYCOVE_HARBOR_EVENT_TICKET_TAKER" }, + [749] = { "FLAG_HIDE_SLATEPORT_CITY_SCOTT", "HIDE_SLATEPORT_CITY_SCOTT" }, + [750] = { "FLAG_HIDE_ROUTE_101_ZIGZAGOON", "HIDE_ROUTE_101_ZIGZAGOON" }, + [751] = { "FLAG_HIDE_VICTORY_ROAD_EXIT_WALLY", "HIDE_VICTORY_ROAD_EXIT_WALLY" }, + [752] = { "FLAG_HIDE_LITTLEROOT_TOWN_MOM_OUTSIDE", "HIDE_LITTLEROOT_TOWN_MOM_OUTSIDE" }, + [753] = { "FLAG_HIDE_MOSSDEEP_CITY_SPACE_CENTER_1F_STEVEN", "HIDE_MOSSDEEP_CITY_SPACE_CENTER_1F_STEVEN" }, + [754] = { "FLAG_HIDE_LITTLEROOT_TOWN_PLAYERS_HOUSE_VIGOROTH_1", "HIDE_LITTLEROOT_TOWN_PLAYERS_HOUSE_VIGOROTH_1" }, + [755] = { "FLAG_HIDE_LITTLEROOT_TOWN_PLAYERS_HOUSE_VIGOROTH_2", "HIDE_LITTLEROOT_TOWN_PLAYERS_HOUSE_VIGOROTH_2" }, + [756] = { "FLAG_HIDE_MOSSDEEP_CITY_SPACE_CENTER_1F_TEAM_MAGMA", "HIDE_MOSSDEEP_CITY_SPACE_CENTER_1F_TEAM_MAGMA" }, + [757] = { "FLAG_HIDE_LITTLEROOT_TOWN_PLAYERS_BEDROOM_MOM", "HIDE_LITTLEROOT_TOWN_PLAYERS_BEDROOM_MOM" }, + [758] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_MOM", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_MOM" }, + [759] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_MOM", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_MOM" }, + [760] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_BEDROOM", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_BEDROOM" }, + [761] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_TRUCK", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_TRUCK" }, + [762] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_TRUCK", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_TRUCK" }, + [763] = { "FLAG_HIDE_DEOXYS", "HIDE_DEOXYS" }, + [764] = { "FLAG_HIDE_BIRTH_ISLAND_DEOXYS_TRIANGLE", "HIDE_BIRTH_ISLAND_DEOXYS_TRIANGLE" }, + [765] = { "FLAG_HIDE_MAUVILLE_CITY_SCOTT", "HIDE_MAUVILLE_CITY_SCOTT" }, + [766] = { "FLAG_HIDE_VERDANTURF_TOWN_SCOTT", "HIDE_VERDANTURF_TOWN_SCOTT" }, + [767] = { "FLAG_HIDE_FALLARBOR_TOWN_BATTLE_TENT_SCOTT", "HIDE_FALLARBOR_TOWN_BATTLE_TENT_SCOTT" }, + [768] = { "FLAG_HIDE_ROUTE_111_VICTOR_WINSTRATE", "HIDE_ROUTE_111_VICTOR_WINSTRATE" }, + [769] = { "FLAG_HIDE_ROUTE_111_VICTORIA_WINSTRATE", "HIDE_ROUTE_111_VICTORIA_WINSTRATE" }, + [770] = { "FLAG_HIDE_ROUTE_111_VIVI_WINSTRATE", "HIDE_ROUTE_111_VIVI_WINSTRATE" }, + [771] = { "FLAG_HIDE_ROUTE_111_VICKY_WINSTRATE", "HIDE_ROUTE_111_VICKY_WINSTRATE" }, + [772] = { "FLAG_HIDE_PETALBURG_GYM_NORMAN", "HIDE_PETALBURG_GYM_NORMAN" }, + [773] = { "FLAG_HIDE_SKY_PILLAR_TOP_RAYQUAZA", "HIDE_SKY_PILLAR_TOP_RAYQUAZA" }, + [774] = { "FLAG_HIDE_LILYCOVE_CONTEST_HALL_CONTEST_ATTENDANT_1", "HIDE_LILYCOVE_CONTEST_HALL_CONTEST_ATTENDANT_1" }, + [775] = { "FLAG_HIDE_LILYCOVE_MUSEUM_CURATOR", "HIDE_LILYCOVE_MUSEUM_CURATOR" }, + [776] = { "FLAG_HIDE_LILYCOVE_MUSEUM_PATRON_1", "HIDE_LILYCOVE_MUSEUM_PATRON_1" }, + [777] = { "FLAG_HIDE_LILYCOVE_MUSEUM_PATRON_2", "HIDE_LILYCOVE_MUSEUM_PATRON_2" }, + [778] = { "FLAG_HIDE_LILYCOVE_MUSEUM_PATRON_3", "HIDE_LILYCOVE_MUSEUM_PATRON_3" }, + [779] = { "FLAG_HIDE_LILYCOVE_MUSEUM_PATRON_4", "HIDE_LILYCOVE_MUSEUM_PATRON_4" }, + [780] = { "FLAG_HIDE_LILYCOVE_MUSEUM_TOURISTS", "HIDE_LILYCOVE_MUSEUM_TOURISTS" }, + [781] = { "FLAG_HIDE_PETALBURG_GYM_GREETER", "HIDE_PETALBURG_GYM_GREETER" }, + [782] = { "FLAG_HIDE_MARINE_CAVE_KYOGRE", "HIDE_MARINE_CAVE_KYOGRE" }, + [783] = { "FLAG_HIDE_TERRA_CAVE_GROUDON", "HIDE_TERRA_CAVE_GROUDON" }, + [784] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_MOM", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_MOM" }, + [785] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_MOM", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_MOM" }, + [786] = { "FLAG_HIDE_ROUTE_119_SCOTT", "HIDE_ROUTE_119_SCOTT" }, + [787] = { "FLAG_HIDE_LILYCOVE_MOTEL_SCOTT", "HIDE_LILYCOVE_MOTEL_SCOTT" }, + [788] = { "FLAG_HIDE_MOSSDEEP_CITY_SCOTT", "HIDE_MOSSDEEP_CITY_SCOTT" }, + [789] = { "FLAG_HIDE_FANCLUB_OLD_LADY", "HIDE_FANCLUB_OLD_LADY" }, + [790] = { "FLAG_HIDE_FANCLUB_BOY", "HIDE_FANCLUB_BOY" }, + [791] = { "FLAG_HIDE_FANCLUB_LITTLE_BOY", "HIDE_FANCLUB_LITTLE_BOY" }, + [792] = { "FLAG_HIDE_FANCLUB_LADY", "HIDE_FANCLUB_LADY" }, + [793] = { "FLAG_HIDE_EVER_GRANDE_POKEMON_CENTER_1F_SCOTT", "HIDE_EVER_GRANDE_POKEMON_CENTER_1F_SCOTT" }, + [794] = { "FLAG_HIDE_LITTLEROOT_TOWN_RIVAL", "HIDE_LITTLEROOT_TOWN_RIVAL" }, + [795] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCH", "HIDE_LITTLEROOT_TOWN_BIRCH" }, + [796] = { "FLAG_HIDE_ROUTE_111_GABBY_AND_TY_1", "HIDE_ROUTE_111_GABBY_AND_TY_1" }, + [797] = { "FLAG_HIDE_ROUTE_118_GABBY_AND_TY_1", "HIDE_ROUTE_118_GABBY_AND_TY_1" }, + [798] = { "FLAG_HIDE_ROUTE_120_GABBY_AND_TY_1", "HIDE_ROUTE_120_GABBY_AND_TY_1" }, + [799] = { "FLAG_HIDE_ROUTE_111_GABBY_AND_TY_2", "HIDE_ROUTE_111_GABBY_AND_TY_2" }, + [800] = { "FLAG_HIDE_LUGIA", "HIDE_LUGIA" }, + [801] = { "FLAG_HIDE_HO_OH", "HIDE_HO_OH" }, + [802] = { "FLAG_HIDE_LILYCOVE_CONTEST_HALL_REPORTER", "HIDE_LILYCOVE_CONTEST_HALL_REPORTER" }, + [803] = { "FLAG_HIDE_SLATEPORT_CITY_CONTEST_REPORTER", "HIDE_SLATEPORT_CITY_CONTEST_REPORTER" }, + [804] = { "FLAG_HIDE_MAUVILLE_CITY_WALLY", "HIDE_MAUVILLE_CITY_WALLY" }, + [805] = { "FLAG_HIDE_MAUVILLE_CITY_WALLYS_UNCLE", "HIDE_MAUVILLE_CITY_WALLYS_UNCLE" }, + [806] = { "FLAG_HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WALLY", "HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WALLY" }, + [807] = { "FLAG_HIDE_RUSTURF_TUNNEL_WANDAS_BOYFRIEND", "HIDE_RUSTURF_TUNNEL_WANDAS_BOYFRIEND" }, + [808] = { "FLAG_HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WANDAS_BOYFRIEND", "HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WANDAS_BOYFRIEND" }, + [809] = { "FLAG_HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WALLYS_UNCLE", "HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WALLYS_UNCLE" }, + [810] = { "FLAG_HIDE_SS_TIDAL_CORRIDOR_SCOTT", "HIDE_SS_TIDAL_CORRIDOR_SCOTT" }, + [811] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_CYNDAQUIL", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_CYNDAQUIL" }, + [812] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_TOTODILE", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_TOTODILE" }, + [813] = { "FLAG_HIDE_ROUTE_116_DROPPED_GLASSES_MAN", "HIDE_ROUTE_116_DROPPED_GLASSES_MAN" }, + [814] = { "FLAG_HIDE_RUSTBORO_CITY_RIVAL", "HIDE_RUSTBORO_CITY_RIVAL" }, + [815] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F_SWABLU_DOLL", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F_SWABLU_DOLL" }, + [816] = { "FLAG_HIDE_SOOTOPOLIS_CITY_WALLACE", "HIDE_SOOTOPOLIS_CITY_WALLACE" }, + [817] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F_POKE_BALL", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F_POKE_BALL" }, + [818] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_2F_POKE_BALL", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_2F_POKE_BALL" }, + [819] = { "FLAG_HIDE_ROUTE_112_TEAM_MAGMA", "HIDE_ROUTE_112_TEAM_MAGMA" }, + [820] = { "FLAG_HIDE_CAVE_OF_ORIGIN_B1F_WALLACE", "HIDE_CAVE_OF_ORIGIN_B1F_WALLACE" }, + [821] = { "FLAG_HIDE_AQUA_HIDEOUT_1F_GRUNT_1_BLOCKING_ENTRANCE", "HIDE_AQUA_HIDEOUT_1F_GRUNT_1_BLOCKING_ENTRANCE" }, + [822] = { "FLAG_HIDE_AQUA_HIDEOUT_1F_GRUNT_2_BLOCKING_ENTRANCE", "HIDE_AQUA_HIDEOUT_1F_GRUNT_2_BLOCKING_ENTRANCE" }, + [823] = { "FLAG_HIDE_MOSSDEEP_CITY_TEAM_MAGMA", "HIDE_MOSSDEEP_CITY_TEAM_MAGMA" }, + [824] = { "FLAG_HIDE_PETALBURG_GYM_WALLYS_DAD", "HIDE_PETALBURG_GYM_WALLYS_DAD" }, + [825] = { "FLAG_HIDE_LEGEND_MON_CAVE_OF_ORIGIN", "HIDE_LEGEND_MON_CAVE_OF_ORIGIN" }, + [826] = { "FLAG_HIDE_SOOTOPOLIS_CITY_ARCHIE", "HIDE_SOOTOPOLIS_CITY_ARCHIE" }, + [827] = { "FLAG_HIDE_SOOTOPOLIS_CITY_MAXIE", "HIDE_SOOTOPOLIS_CITY_MAXIE" }, + [828] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ROOM_9_ARCHIE", "HIDE_SEAFLOOR_CAVERN_ROOM_9_ARCHIE" }, + [829] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ROOM_9_MAXIE", "HIDE_SEAFLOOR_CAVERN_ROOM_9_MAXIE" }, + [830] = { "FLAG_HIDE_PETALBURG_CITY_WALLYS_DAD", "HIDE_PETALBURG_CITY_WALLYS_DAD" }, + [831] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ROOM_9_MAGMA_GRUNTS", "HIDE_SEAFLOOR_CAVERN_ROOM_9_MAGMA_GRUNTS" }, + [832] = { "FLAG_HIDE_LILYCOVE_CONTEST_HALL_BLEND_MASTER", "HIDE_LILYCOVE_CONTEST_HALL_BLEND_MASTER" }, + [833] = { "FLAG_HIDE_GRANITE_CAVE_STEVEN", "HIDE_GRANITE_CAVE_STEVEN" }, + [834] = { "FLAG_HIDE_ROUTE_128_STEVEN", "HIDE_ROUTE_128_STEVEN" }, + [835] = { "FLAG_HIDE_SLATEPORT_CITY_GABBY_AND_TY", "HIDE_SLATEPORT_CITY_GABBY_AND_TY" }, + [836] = { "FLAG_HIDE_BATTLE_FRONTIER_RECEPTION_GATE_SCOTT", "HIDE_BATTLE_FRONTIER_RECEPTION_GATE_SCOTT" }, + [837] = { "FLAG_HIDE_ROUTE_110_BIRCH", "HIDE_ROUTE_110_BIRCH" }, + [838] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_CHIKORITA", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_CHIKORITA" }, + [839] = { "FLAG_HIDE_SOOTOPOLIS_CITY_MAN_1", "HIDE_SOOTOPOLIS_CITY_MAN_1" }, + [840] = { "FLAG_HIDE_SLATEPORT_CITY_CAPTAIN_STERN", "HIDE_SLATEPORT_CITY_CAPTAIN_STERN" }, + [841] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_CAPTAIN_STERN", "HIDE_SLATEPORT_CITY_HARBOR_CAPTAIN_STERN" }, + [842] = { "FLAG_HIDE_BATTLE_FRONTIER_SUDOWOODO", "HIDE_BATTLE_FRONTIER_SUDOWOODO" }, + [843] = { "FLAG_HIDE_ROUTE_111_ROCK_SMASH_TIP_GUY", "HIDE_ROUTE_111_ROCK_SMASH_TIP_GUY" }, + [844] = { "FLAG_HIDE_RUSTBORO_CITY_SCIENTIST", "HIDE_RUSTBORO_CITY_SCIENTIST" }, + [845] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_AQUA_GRUNT", "HIDE_SLATEPORT_CITY_HARBOR_AQUA_GRUNT" }, + [846] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_ARCHIE", "HIDE_SLATEPORT_CITY_HARBOR_ARCHIE" }, + [847] = { "FLAG_HIDE_JAGGED_PASS_MAGMA_GUARD", "HIDE_JAGGED_PASS_MAGMA_GUARD" }, + [848] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_SUBMARINE_SHADOW", "HIDE_SLATEPORT_CITY_HARBOR_SUBMARINE_SHADOW" }, + [849] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_2F_PICHU_DOLL", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_2F_PICHU_DOLL" }, + [850] = { "FLAG_HIDE_MAGMA_HIDEOUT_4F_GROUDON_ASLEEP", "HIDE_MAGMA_HIDEOUT_4F_GROUDON_ASLEEP" }, + [851] = { "FLAG_HIDE_ROUTE_119_RIVAL", "HIDE_ROUTE_119_RIVAL" }, + [852] = { "FLAG_HIDE_LILYCOVE_CITY_AQUA_GRUNTS", "HIDE_LILYCOVE_CITY_AQUA_GRUNTS" }, + [853] = { "FLAG_HIDE_MAGMA_HIDEOUT_4F_GROUDON", "HIDE_MAGMA_HIDEOUT_4F_GROUDON" }, + [854] = { "FLAG_HIDE_SOOTOPOLIS_CITY_RESIDENTS", "HIDE_SOOTOPOLIS_CITY_RESIDENTS" }, + [855] = { "FLAG_HIDE_SKY_PILLAR_WALLACE", "HIDE_SKY_PILLAR_WALLACE" }, + [856] = { "FLAG_HIDE_MT_PYRE_SUMMIT_MAXIE", "HIDE_MT_PYRE_SUMMIT_MAXIE" }, + [857] = { "FLAG_HIDE_MAGMA_HIDEOUT_GRUNTS", "HIDE_MAGMA_HIDEOUT_GRUNTS" }, + [858] = { "FLAG_HIDE_VICTORY_ROAD_ENTRANCE_WALLY", "HIDE_VICTORY_ROAD_ENTRANCE_WALLY" }, + [859] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ROOM_9_KYOGRE", "HIDE_SEAFLOOR_CAVERN_ROOM_9_KYOGRE" }, + [860] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_SS_TIDAL", "HIDE_SLATEPORT_CITY_HARBOR_SS_TIDAL" }, + [861] = { "FLAG_HIDE_LILYCOVE_HARBOR_SSTIDAL", "HIDE_LILYCOVE_HARBOR_SSTIDAL" }, + [862] = { "FLAG_HIDE_MOSSDEEP_CITY_SPACE_CENTER_2F_TEAM_MAGMA", "HIDE_MOSSDEEP_CITY_SPACE_CENTER_2F_TEAM_MAGMA" }, + [863] = { "FLAG_HIDE_MOSSDEEP_CITY_SPACE_CENTER_2F_STEVEN", "HIDE_MOSSDEEP_CITY_SPACE_CENTER_2F_STEVEN" }, + [864] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_ALT_1", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_ALT_1" }, + [865] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_ALT_2", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_ALT_2" }, + [866] = { "FLAG_HIDE_PETALBURG_GYM_WALLY", "HIDE_PETALBURG_GYM_WALLY" }, + [867] = { "FLAG_UNKNOWN_0x363", "UNKNOWN_0x363" }, + [868] = { "FLAG_HIDE_LITTLEROOT_TOWN_FAT_MAN", "HIDE_LITTLEROOT_TOWN_FAT_MAN" }, + [869] = { "FLAG_HIDE_SLATEPORT_CITY_STERNS_SHIPYARD_MR_BRINEY", "HIDE_SLATEPORT_CITY_STERNS_SHIPYARD_MR_BRINEY" }, + [870] = { "FLAG_HIDE_LANETTES_HOUSE_LANETTE", "HIDE_LANETTES_HOUSE_LANETTE" }, + [871] = { "FLAG_HIDE_FALLARBOR_POKEMON_CENTER_LANETTE", "HIDE_FALLARBOR_POKEMON_CENTER_LANETTE" }, + [872] = { "FLAG_HIDE_TRICK_HOUSE_ENTRANCE_MAN", "HIDE_TRICK_HOUSE_ENTRANCE_MAN" }, + [873] = { "FLAG_HIDE_LILYCOVE_CONTEST_HALL_BLEND_MASTER_REPLACEMENT", "HIDE_LILYCOVE_CONTEST_HALL_BLEND_MASTER_REPLACEMENT" }, + [874] = { "FLAG_HIDE_DESERT_UNDERPASS_FOSSIL", "HIDE_DESERT_UNDERPASS_FOSSIL" }, + [875] = { "FLAG_HIDE_ROUTE_111_PLAYER_DESCENT", "HIDE_ROUTE_111_PLAYER_DESCENT" }, + [876] = { "FLAG_HIDE_ROUTE_111_DESERT_FOSSIL", "HIDE_ROUTE_111_DESERT_FOSSIL" }, + [877] = { "FLAG_HIDE_MT_CHIMNEY_TRAINERS", "HIDE_MT_CHIMNEY_TRAINERS" }, + [878] = { "FLAG_HIDE_RUSTURF_TUNNEL_AQUA_GRUNT", "HIDE_RUSTURF_TUNNEL_AQUA_GRUNT" }, + [879] = { "FLAG_HIDE_RUSTURF_TUNNEL_BRINEY", "HIDE_RUSTURF_TUNNEL_BRINEY" }, + [880] = { "FLAG_HIDE_RUSTURF_TUNNEL_PEEKO", "HIDE_RUSTURF_TUNNEL_PEEKO" }, + [881] = { "FLAG_HIDE_BRINEYS_HOUSE_PEEKO", "HIDE_BRINEYS_HOUSE_PEEKO" }, + [882] = { "FLAG_HIDE_SLATEPORT_CITY_TEAM_AQUA", "HIDE_SLATEPORT_CITY_TEAM_AQUA" }, + [883] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_AQUA_GRUNTS", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_AQUA_GRUNTS" }, + [884] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_AQUA_GRUNT_1", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_AQUA_GRUNT_1" }, + [885] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_AQUA_GRUNT_2", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_AQUA_GRUNT_2" }, + [886] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_ARCHIE", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_ARCHIE" }, + [887] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_CAPTAIN_STERN", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_CAPTAIN_STERN" }, + [888] = { "FLAG_HIDE_BATTLE_TOWER_OPPONENT", "HIDE_BATTLE_TOWER_OPPONENT" }, + [889] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_RIVAL", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_RIVAL" }, + [890] = { "FLAG_HIDE_ROUTE_119_TEAM_AQUA", "HIDE_ROUTE_119_TEAM_AQUA" }, + [891] = { "FLAG_HIDE_ROUTE_116_MR_BRINEY", "HIDE_ROUTE_116_MR_BRINEY" }, + [892] = { "FLAG_HIDE_WEATHER_INSTITUTE_1F_WORKERS", "HIDE_WEATHER_INSTITUTE_1F_WORKERS" }, + [893] = { "FLAG_HIDE_WEATHER_INSTITUTE_2F_WORKERS", "HIDE_WEATHER_INSTITUTE_2F_WORKERS" }, + [894] = { "FLAG_HIDE_ROUTE_116_WANDAS_BOYFRIEND", "HIDE_ROUTE_116_WANDAS_BOYFRIEND" }, + [895] = { "FLAG_HIDE_LILYCOVE_CONTEST_HALL_CONTEST_ATTENDANT_2", "HIDE_LILYCOVE_CONTEST_HALL_CONTEST_ATTENDANT_2" }, + [896] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_UNKNOWN_0x380", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_UNKNOWN_0x380" }, + [897] = { "FLAG_HIDE_ROUTE_101_BIRCH", "HIDE_ROUTE_101_BIRCH" }, + [898] = { "FLAG_HIDE_ROUTE_103_BIRCH", "HIDE_ROUTE_103_BIRCH" }, + [899] = { "FLAG_HIDE_TRICK_HOUSE_END_MAN", "HIDE_TRICK_HOUSE_END_MAN" }, + [900] = { "FLAG_HIDE_ROUTE_110_TEAM_AQUA", "HIDE_ROUTE_110_TEAM_AQUA" }, + [901] = { "FLAG_HIDE_ROUTE_118_GABBY_AND_TY_2", "HIDE_ROUTE_118_GABBY_AND_TY_2" }, + [902] = { "FLAG_HIDE_ROUTE_120_GABBY_AND_TY_2", "HIDE_ROUTE_120_GABBY_AND_TY_2" }, + [903] = { "FLAG_HIDE_ROUTE_111_GABBY_AND_TY_3", "HIDE_ROUTE_111_GABBY_AND_TY_3" }, + [904] = { "FLAG_HIDE_ROUTE_118_GABBY_AND_TY_3", "HIDE_ROUTE_118_GABBY_AND_TY_3" }, + [905] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_PATRONS", "HIDE_SLATEPORT_CITY_HARBOR_PATRONS" }, + [906] = { "FLAG_HIDE_ROUTE_104_WHITE_HERB_FLORIST", "HIDE_ROUTE_104_WHITE_HERB_FLORIST" }, + [907] = { "FLAG_HIDE_FALLARBOR_AZURILL", "HIDE_FALLARBOR_AZURILL" }, + [908] = { "FLAG_HIDE_LILYCOVE_HARBOR_FERRY_ATTENDANT", "HIDE_LILYCOVE_HARBOR_FERRY_ATTENDANT" }, + [909] = { "FLAG_HIDE_LILYCOVE_HARBOR_FERRY_SAILOR", "HIDE_LILYCOVE_HARBOR_FERRY_SAILOR" }, + [910] = { "FLAG_HIDE_SOUTHERN_ISLAND_EON_STONE", "HIDE_SOUTHERN_ISLAND_EON_STONE" }, + [911] = { "FLAG_HIDE_SOUTHERN_ISLAND_UNCHOSEN_EON_DUO_MON", "HIDE_SOUTHERN_ISLAND_UNCHOSEN_EON_DUO_MON" }, + [912] = { "FLAG_HIDE_MAUVILLE_CITY_WATTSON", "HIDE_MAUVILLE_CITY_WATTSON" }, + [913] = { "FLAG_HIDE_MAUVILLE_GYM_WATTSON", "HIDE_MAUVILLE_GYM_WATTSON" }, + [914] = { "FLAG_HIDE_ROUTE_121_TEAM_AQUA_GRUNTS", "HIDE_ROUTE_121_TEAM_AQUA_GRUNTS" }, + [915] = { "FLAG_UNKNOWN_0x393", "UNKNOWN_0x393" }, + [916] = { "FLAG_HIDE_MT_PYRE_SUMMIT_ARCHIE", "HIDE_MT_PYRE_SUMMIT_ARCHIE" }, + [917] = { "FLAG_HIDE_MT_PYRE_SUMMIT_TEAM_AQUA", "HIDE_MT_PYRE_SUMMIT_TEAM_AQUA" }, + [918] = { "FLAG_HIDE_BATTLE_TOWER_REPORTER", "HIDE_BATTLE_TOWER_REPORTER" }, + [919] = { "FLAG_HIDE_ROUTE_110_RIVAL", "HIDE_ROUTE_110_RIVAL" }, + [920] = { "FLAG_HIDE_CHAMPIONS_ROOM_RIVAL", "HIDE_CHAMPIONS_ROOM_RIVAL" }, + [921] = { "FLAG_HIDE_CHAMPIONS_ROOM_BIRCH", "HIDE_CHAMPIONS_ROOM_BIRCH" }, + [922] = { "FLAG_HIDE_ROUTE_110_RIVAL_ON_BIKE", "HIDE_ROUTE_110_RIVAL_ON_BIKE" }, + [923] = { "FLAG_HIDE_ROUTE_119_RIVAL_ON_BIKE", "HIDE_ROUTE_119_RIVAL_ON_BIKE" }, + [924] = { "FLAG_HIDE_AQUA_HIDEOUT_GRUNTS", "HIDE_AQUA_HIDEOUT_GRUNTS" }, + [925] = { "FLAG_HIDE_LILYCOVE_MOTEL_GAME_DESIGNERS", "HIDE_LILYCOVE_MOTEL_GAME_DESIGNERS" }, + [926] = { "FLAG_HIDE_MT_CHIMNEY_TEAM_AQUA", "HIDE_MT_CHIMNEY_TEAM_AQUA" }, + [927] = { "FLAG_HIDE_MT_CHIMNEY_TEAM_MAGMA", "HIDE_MT_CHIMNEY_TEAM_MAGMA" }, + [928] = { "FLAG_HIDE_FALLARBOR_HOUSE_PROF_COZMO", "HIDE_FALLARBOR_HOUSE_PROF_COZMO" }, + [929] = { "FLAG_HIDE_LAVARIDGE_TOWN_RIVAL", "HIDE_LAVARIDGE_TOWN_RIVAL" }, + [930] = { "FLAG_HIDE_LAVARIDGE_TOWN_RIVAL_ON_BIKE", "HIDE_LAVARIDGE_TOWN_RIVAL_ON_BIKE" }, + [931] = { "FLAG_HIDE_RUSTURF_TUNNEL_ROCK_1", "HIDE_RUSTURF_TUNNEL_ROCK_1" }, + [932] = { "FLAG_HIDE_RUSTURF_TUNNEL_ROCK_2", "HIDE_RUSTURF_TUNNEL_ROCK_2" }, + [933] = { "FLAG_HIDE_FORTREE_CITY_HOUSE_4_WINGULL", "HIDE_FORTREE_CITY_HOUSE_4_WINGULL" }, + [934] = { "FLAG_HIDE_MOSSDEEP_CITY_HOUSE_2_WINGULL", "HIDE_MOSSDEEP_CITY_HOUSE_2_WINGULL" }, + [935] = { "FLAG_HIDE_REGIROCK", "HIDE_REGIROCK" }, + [936] = { "FLAG_HIDE_REGICE", "HIDE_REGICE" }, + [937] = { "FLAG_HIDE_REGISTEEL", "HIDE_REGISTEEL" }, + [938] = { "FLAG_HIDE_METEOR_FALLS_TEAM_AQUA", "HIDE_METEOR_FALLS_TEAM_AQUA" }, + [939] = { "FLAG_HIDE_METEOR_FALLS_TEAM_MAGMA", "HIDE_METEOR_FALLS_TEAM_MAGMA" }, + [940] = { "FLAG_HIDE_DEWFORD_HALL_SLUDGE_BOMB_MAN", "HIDE_DEWFORD_HALL_SLUDGE_BOMB_MAN" }, + [941] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ENTRANCE_AQUA_GRUNT", "HIDE_SEAFLOOR_CAVERN_ENTRANCE_AQUA_GRUNT" }, + [942] = { "FLAG_HIDE_METEOR_FALLS_1F_1R_COZMO", "HIDE_METEOR_FALLS_1F_1R_COZMO" }, + [943] = { "FLAG_HIDE_AQUA_HIDEOUT_B2F_SUBMARINE_SHADOW", "HIDE_AQUA_HIDEOUT_B2F_SUBMARINE_SHADOW" }, + [944] = { "FLAG_HIDE_ROUTE_128_ARCHIE", "HIDE_ROUTE_128_ARCHIE" }, + [945] = { "FLAG_HIDE_ROUTE_128_MAXIE", "HIDE_ROUTE_128_MAXIE" }, + [946] = { "FLAG_HIDE_SEAFLOOR_CAVERN_AQUA_GRUNTS", "HIDE_SEAFLOOR_CAVERN_AQUA_GRUNTS" }, + [947] = { "FLAG_HIDE_ROUTE_116_DEVON_EMPLOYEE", "HIDE_ROUTE_116_DEVON_EMPLOYEE" }, + [948] = { "FLAG_HIDE_SLATEPORT_CITY_TM_SALESMAN", "HIDE_SLATEPORT_CITY_TM_SALESMAN" }, + [949] = { "FLAG_HIDE_RUSTBORO_CITY_DEVON_CORP_3F_EMPLOYEE", "HIDE_RUSTBORO_CITY_DEVON_CORP_3F_EMPLOYEE" }, + [950] = { "FLAG_HIDE_SS_TIDAL_CORRIDOR_MR_BRINEY", "HIDE_SS_TIDAL_CORRIDOR_MR_BRINEY" }, + [951] = { "FLAG_HIDE_SS_TIDAL_ROOMS_SNATCH_GIVER", "HIDE_SS_TIDAL_ROOMS_SNATCH_GIVER" }, + [952] = { "FLAG_RECEIVED_SHOAL_SALT_1", "RECEIVED_SHOAL_SALT_1" }, + [953] = { "FLAG_RECEIVED_SHOAL_SALT_2", "RECEIVED_SHOAL_SALT_2" }, + [954] = { "FLAG_RECEIVED_SHOAL_SALT_3", "RECEIVED_SHOAL_SALT_3" }, + [955] = { "FLAG_RECEIVED_SHOAL_SALT_4", "RECEIVED_SHOAL_SALT_4" }, + [956] = { "FLAG_RECEIVED_SHOAL_SHELL_1", "RECEIVED_SHOAL_SHELL_1" }, + [957] = { "FLAG_RECEIVED_SHOAL_SHELL_2", "RECEIVED_SHOAL_SHELL_2" }, + [958] = { "FLAG_RECEIVED_SHOAL_SHELL_3", "RECEIVED_SHOAL_SHELL_3" }, + [959] = { "FLAG_RECEIVED_SHOAL_SHELL_4", "RECEIVED_SHOAL_SHELL_4" }, + [960] = { "FLAG_HIDE_ROUTE_111_SECRET_POWER_MAN", "HIDE_ROUTE_111_SECRET_POWER_MAN" }, + [961] = { "FLAG_HIDE_SLATEPORT_MUSEUM_POPULATION", "HIDE_SLATEPORT_MUSEUM_POPULATION" }, + [962] = { "FLAG_HIDE_LILYCOVE_DEPARTMENT_STORE_ROOFTOP_SALE_WOMAN", "HIDE_LILYCOVE_DEPARTMENT_STORE_ROOFTOP_SALE_WOMAN" }, + [963] = { "FLAG_HIDE_MIRAGE_TOWER_ROOT_FOSSIL", "HIDE_MIRAGE_TOWER_ROOT_FOSSIL" }, + [964] = { "FLAG_HIDE_MIRAGE_TOWER_CLAW_FOSSIL", "HIDE_MIRAGE_TOWER_CLAW_FOSSIL" }, + [965] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_FAMILIAR_AQUA_GRUNT", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_FAMILIAR_AQUA_GRUNT" }, + [966] = { "FLAG_HIDE_ROUTE_118_STEVEN", "HIDE_ROUTE_118_STEVEN" }, + [967] = { "FLAG_HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_STEVEN", "HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_STEVEN" }, + [968] = { "FLAG_HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_BELDUM_POKEBALL", "HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_BELDUM_POKEBALL" }, + [969] = { "FLAG_HIDE_FORTREE_CITY_KECLEON", "HIDE_FORTREE_CITY_KECLEON" }, + [970] = { "FLAG_HIDE_ROUTE_120_KECLEON_BRIDGE", "HIDE_ROUTE_120_KECLEON_BRIDGE" }, + [971] = { "FLAG_HIDE_LILYCOVE_CITY_RIVAL", "HIDE_LILYCOVE_CITY_RIVAL" }, + [972] = { "FLAG_HIDE_ROUTE_120_STEVEN", "HIDE_ROUTE_120_STEVEN" }, + [973] = { "FLAG_HIDE_SOOTOPOLIS_CITY_STEVEN", "HIDE_SOOTOPOLIS_CITY_STEVEN" }, + [974] = { "FLAG_HIDE_NEW_MAUVILLE_VOLTORB_1", "HIDE_NEW_MAUVILLE_VOLTORB_1" }, + [975] = { "FLAG_HIDE_NEW_MAUVILLE_VOLTORB_2", "HIDE_NEW_MAUVILLE_VOLTORB_2" }, + [976] = { "FLAG_HIDE_NEW_MAUVILLE_VOLTORB_3", "HIDE_NEW_MAUVILLE_VOLTORB_3" }, + [977] = { "FLAG_HIDE_AQUA_HIDEOUT_B1F_ELECTRODE_1", "HIDE_AQUA_HIDEOUT_B1F_ELECTRODE_1" }, + [978] = { "FLAG_HIDE_AQUA_HIDEOUT_B1F_ELECTRODE_2", "HIDE_AQUA_HIDEOUT_B1F_ELECTRODE_2" }, + [979] = { "FLAG_HIDE_OLDALE_TOWN_RIVAL", "HIDE_OLDALE_TOWN_RIVAL" }, + [980] = { "FLAG_HIDE_UNDERWATER_SEA_FLOOR_CAVERN_STOLEN_SUBMARINE", "HIDE_UNDERWATER_SEA_FLOOR_CAVERN_STOLEN_SUBMARINE" }, + [981] = { "FLAG_HIDE_ROUTE_120_KECLEON_BRIDGE_SHADOW", "HIDE_ROUTE_120_KECLEON_BRIDGE_SHADOW" }, + [982] = { "FLAG_HIDE_ROUTE_120_KECLEON_1", "HIDE_ROUTE_120_KECLEON_1" }, + [983] = { "FLAG_HIDE_RUSTURF_TUNNEL_WANDA", "HIDE_RUSTURF_TUNNEL_WANDA" }, + [984] = { "FLAG_HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WANDA", "HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WANDA" }, + [985] = { "FLAG_HIDE_ROUTE_120_KECLEON_2", "HIDE_ROUTE_120_KECLEON_2" }, + [986] = { "FLAG_HIDE_ROUTE_120_KECLEON_3", "HIDE_ROUTE_120_KECLEON_3" }, + [987] = { "FLAG_HIDE_ROUTE_120_KECLEON_4", "HIDE_ROUTE_120_KECLEON_4" }, + [988] = { "FLAG_HIDE_ROUTE_120_KECLEON_5", "HIDE_ROUTE_120_KECLEON_5" }, + [989] = { "FLAG_HIDE_ROUTE_119_KECLEON_1", "HIDE_ROUTE_119_KECLEON_1" }, + [990] = { "FLAG_HIDE_ROUTE_119_KECLEON_2", "HIDE_ROUTE_119_KECLEON_2" }, + [991] = { "FLAG_HIDE_ROUTE_101_BOY", "HIDE_ROUTE_101_BOY" }, + [992] = { "FLAG_HIDE_WEATHER_INSTITUTE_2F_AQUA_GRUNT_M", "HIDE_WEATHER_INSTITUTE_2F_AQUA_GRUNT_M" }, + [993] = { "FLAG_HIDE_LILYCOVE_POKEMON_CENTER_CONTEST_LADY_MON", "HIDE_LILYCOVE_POKEMON_CENTER_CONTEST_LADY_MON" }, + [994] = { "FLAG_HIDE_MT_CHIMNEY_LAVA_COOKIE_LADY", "HIDE_MT_CHIMNEY_LAVA_COOKIE_LADY" }, + [995] = { "FLAG_HIDE_PETALBURG_CITY_SCOTT", "HIDE_PETALBURG_CITY_SCOTT" }, + [996] = { "FLAG_HIDE_SOOTOPOLIS_CITY_RAYQUAZA", "HIDE_SOOTOPOLIS_CITY_RAYQUAZA" }, + [997] = { "FLAG_HIDE_SOOTOPOLIS_CITY_KYOGRE", "HIDE_SOOTOPOLIS_CITY_KYOGRE" }, + [998] = { "FLAG_HIDE_SOOTOPOLIS_CITY_GROUDON", "HIDE_SOOTOPOLIS_CITY_GROUDON" }, + [999] = { "FLAG_HIDE_RUSTBORO_CITY_POKEMON_SCHOOL_SCOTT", "HIDE_RUSTBORO_CITY_POKEMON_SCHOOL_SCOTT" }, + [1000] = { "FLAG_ITEM_ROUTE_102_POTION", "ITEM_ROUTE_102_POTION" }, + [1001] = { "FLAG_ITEM_ROUTE_116_X_SPECIAL", "ITEM_ROUTE_116_X_SPECIAL" }, + [1002] = { "FLAG_ITEM_ROUTE_104_PP_UP", "ITEM_ROUTE_104_PP_UP" }, + [1003] = { "FLAG_ITEM_ROUTE_105_IRON", "ITEM_ROUTE_105_IRON" }, + [1004] = { "FLAG_ITEM_ROUTE_106_PROTEIN", "ITEM_ROUTE_106_PROTEIN" }, + [1005] = { "FLAG_ITEM_ROUTE_109_PP_UP", "ITEM_ROUTE_109_PP_UP" }, + [1006] = { "FLAG_ITEM_ROUTE_110_RARE_CANDY", "ITEM_ROUTE_110_RARE_CANDY" }, + [1007] = { "FLAG_ITEM_ROUTE_110_DIRE_HIT", "ITEM_ROUTE_110_DIRE_HIT" }, + [1008] = { "FLAG_ITEM_ROUTE_111_TM_SANDSTORM", "ITEM_ROUTE_111_TM_SANDSTORM" }, + [1009] = { "FLAG_ITEM_ROUTE_111_STARDUST", "ITEM_ROUTE_111_STARDUST" }, + [1010] = { "FLAG_ITEM_ROUTE_111_HP_UP", "ITEM_ROUTE_111_HP_UP" }, + [1011] = { "FLAG_ITEM_ROUTE_112_NUGGET", "ITEM_ROUTE_112_NUGGET" }, + [1012] = { "FLAG_ITEM_ROUTE_113_MAX_ETHER", "ITEM_ROUTE_113_MAX_ETHER" }, + [1013] = { "FLAG_ITEM_ROUTE_113_SUPER_REPEL", "ITEM_ROUTE_113_SUPER_REPEL" }, + [1014] = { "FLAG_ITEM_ROUTE_114_RARE_CANDY", "ITEM_ROUTE_114_RARE_CANDY" }, + [1015] = { "FLAG_ITEM_ROUTE_114_PROTEIN", "ITEM_ROUTE_114_PROTEIN" }, + [1016] = { "FLAG_ITEM_ROUTE_115_SUPER_POTION", "ITEM_ROUTE_115_SUPER_POTION" }, + [1017] = { "FLAG_ITEM_ROUTE_115_TM_FOCUS_PUNCH", "ITEM_ROUTE_115_TM_FOCUS_PUNCH" }, + [1018] = { "FLAG_ITEM_ROUTE_115_IRON", "ITEM_ROUTE_115_IRON" }, + [1019] = { "FLAG_ITEM_ROUTE_116_ETHER", "ITEM_ROUTE_116_ETHER" }, + [1020] = { "FLAG_ITEM_ROUTE_116_REPEL", "ITEM_ROUTE_116_REPEL" }, + [1021] = { "FLAG_ITEM_ROUTE_116_HP_UP", "ITEM_ROUTE_116_HP_UP" }, + [1022] = { "FLAG_ITEM_ROUTE_117_GREAT_BALL", "ITEM_ROUTE_117_GREAT_BALL" }, + [1023] = { "FLAG_ITEM_ROUTE_117_REVIVE", "ITEM_ROUTE_117_REVIVE" }, + [1024] = { "FLAG_ITEM_ROUTE_119_SUPER_REPEL", "ITEM_ROUTE_119_SUPER_REPEL" }, + [1025] = { "FLAG_ITEM_ROUTE_119_ZINC", "ITEM_ROUTE_119_ZINC" }, + [1026] = { "FLAG_ITEM_ROUTE_119_ELIXIR_1", "ITEM_ROUTE_119_ELIXIR_1" }, + [1027] = { "FLAG_ITEM_ROUTE_119_LEAF_STONE", "ITEM_ROUTE_119_LEAF_STONE" }, + [1028] = { "FLAG_ITEM_ROUTE_119_RARE_CANDY", "ITEM_ROUTE_119_RARE_CANDY" }, + [1029] = { "FLAG_ITEM_ROUTE_119_HYPER_POTION_1", "ITEM_ROUTE_119_HYPER_POTION_1" }, + [1030] = { "FLAG_ITEM_ROUTE_120_NUGGET", "ITEM_ROUTE_120_NUGGET" }, + [1031] = { "FLAG_ITEM_ROUTE_120_FULL_HEAL", "ITEM_ROUTE_120_FULL_HEAL" }, + [1032] = { "FLAG_ITEM_ROUTE_123_CALCIUM", "ITEM_ROUTE_123_CALCIUM" }, + [1033] = { "FLAG_ITEM_ROUTE_123_RARE_CANDY", "ITEM_ROUTE_123_RARE_CANDY" }, + [1034] = { "FLAG_ITEM_ROUTE_127_ZINC", "ITEM_ROUTE_127_ZINC" }, + [1035] = { "FLAG_ITEM_ROUTE_127_CARBOS", "ITEM_ROUTE_127_CARBOS" }, + [1036] = { "FLAG_ITEM_ROUTE_132_RARE_CANDY", "ITEM_ROUTE_132_RARE_CANDY" }, + [1037] = { "FLAG_ITEM_ROUTE_133_BIG_PEARL", "ITEM_ROUTE_133_BIG_PEARL" }, + [1038] = { "FLAG_ITEM_ROUTE_133_STAR_PIECE", "ITEM_ROUTE_133_STAR_PIECE" }, + [1039] = { "FLAG_ITEM_PETALBURG_CITY_MAX_REVIVE", "ITEM_PETALBURG_CITY_MAX_REVIVE" }, + [1040] = { "FLAG_ITEM_PETALBURG_CITY_ETHER", "ITEM_PETALBURG_CITY_ETHER" }, + [1041] = { "FLAG_ITEM_RUSTBORO_CITY_X_DEFEND", "ITEM_RUSTBORO_CITY_X_DEFEND" }, + [1042] = { "FLAG_ITEM_LILYCOVE_CITY_MAX_REPEL", "ITEM_LILYCOVE_CITY_MAX_REPEL" }, + [1043] = { "FLAG_ITEM_MOSSDEEP_CITY_NET_BALL", "ITEM_MOSSDEEP_CITY_NET_BALL" }, + [1044] = { "FLAG_ITEM_METEOR_FALLS_1F_1R_TM_IRON_TAIL", "ITEM_METEOR_FALLS_1F_1R_TM_IRON_TAIL" }, + [1045] = { "FLAG_ITEM_METEOR_FALLS_1F_1R_FULL_HEAL", "ITEM_METEOR_FALLS_1F_1R_FULL_HEAL" }, + [1046] = { "FLAG_ITEM_METEOR_FALLS_1F_1R_MOON_STONE", "ITEM_METEOR_FALLS_1F_1R_MOON_STONE" }, + [1047] = { "FLAG_ITEM_METEOR_FALLS_1F_1R_PP_UP", "ITEM_METEOR_FALLS_1F_1R_PP_UP" }, + [1048] = { "FLAG_ITEM_RUSTURF_TUNNEL_POKE_BALL", "ITEM_RUSTURF_TUNNEL_POKE_BALL" }, + [1049] = { "FLAG_ITEM_RUSTURF_TUNNEL_MAX_ETHER", "ITEM_RUSTURF_TUNNEL_MAX_ETHER" }, + [1050] = { "FLAG_ITEM_GRANITE_CAVE_1F_ESCAPE_ROPE", "ITEM_GRANITE_CAVE_1F_ESCAPE_ROPE" }, + [1051] = { "FLAG_ITEM_GRANITE_CAVE_B1F_POKE_BALL", "ITEM_GRANITE_CAVE_B1F_POKE_BALL" }, + [1052] = { "FLAG_ITEM_MT_PYRE_5F_LAX_INCENSE", "ITEM_MT_PYRE_5F_LAX_INCENSE" }, + [1053] = { "FLAG_ITEM_GRANITE_CAVE_B2F_REPEL", "ITEM_GRANITE_CAVE_B2F_REPEL" }, + [1054] = { "FLAG_ITEM_GRANITE_CAVE_B2F_RARE_CANDY", "ITEM_GRANITE_CAVE_B2F_RARE_CANDY" }, + [1055] = { "FLAG_ITEM_PETALBURG_WOODS_X_ATTACK", "ITEM_PETALBURG_WOODS_X_ATTACK" }, + [1056] = { "FLAG_ITEM_PETALBURG_WOODS_GREAT_BALL", "ITEM_PETALBURG_WOODS_GREAT_BALL" }, + [1057] = { "FLAG_ITEM_ROUTE_104_POKE_BALL", "ITEM_ROUTE_104_POKE_BALL" }, + [1058] = { "FLAG_ITEM_PETALBURG_WOODS_ETHER", "ITEM_PETALBURG_WOODS_ETHER" }, + [1059] = { "FLAG_ITEM_MAGMA_HIDEOUT_3F_3R_ECAPE_ROPE", "ITEM_MAGMA_HIDEOUT_3F_3R_ECAPE_ROPE" }, + [1060] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_1_ORANGE_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_1_ORANGE_MAIL" }, + [1061] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_2_HARBOR_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_2_HARBOR_MAIL" }, + [1062] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_2_WAVE_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_2_WAVE_MAIL" }, + [1063] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_3_SHADOW_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_3_SHADOW_MAIL" }, + [1064] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_3_WOOD_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_3_WOOD_MAIL" }, + [1065] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_4_MECH_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_4_MECH_MAIL" }, + [1066] = { "FLAG_ITEM_ROUTE_124_YELLOW_SHARD", "ITEM_ROUTE_124_YELLOW_SHARD" }, + [1067] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_6_GLITTER_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_6_GLITTER_MAIL" }, + [1068] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_7_TROPIC_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_7_TROPIC_MAIL" }, + [1069] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_8_BEAD_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_8_BEAD_MAIL" }, + [1070] = { "FLAG_ITEM_JAGGED_PASS_BURN_HEAL", "ITEM_JAGGED_PASS_BURN_HEAL" }, + [1071] = { "FLAG_ITEM_AQUA_HIDEOUT_B1F_MAX_ELIXIR", "ITEM_AQUA_HIDEOUT_B1F_MAX_ELIXIR" }, + [1072] = { "FLAG_ITEM_AQUA_HIDEOUT_B2F_NEST_BALL", "ITEM_AQUA_HIDEOUT_B2F_NEST_BALL" }, + [1073] = { "FLAG_ITEM_MT_PYRE_EXTERIOR_MAX_POTION", "ITEM_MT_PYRE_EXTERIOR_MAX_POTION" }, + [1074] = { "FLAG_ITEM_MT_PYRE_EXTERIOR_TM_SKILL_SWAP", "ITEM_MT_PYRE_EXTERIOR_TM_SKILL_SWAP" }, + [1075] = { "FLAG_ITEM_NEW_MAUVILLE_ULTRA_BALL", "ITEM_NEW_MAUVILLE_ULTRA_BALL" }, + [1076] = { "FLAG_ITEM_NEW_MAUVILLE_ESCAPE_ROPE", "ITEM_NEW_MAUVILLE_ESCAPE_ROPE" }, + [1077] = { "FLAG_ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_6_LUXURY_BALL", "ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_6_LUXURY_BALL" }, + [1078] = { "FLAG_ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_2_SCANNER", "ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_2_SCANNER" }, + [1079] = { "FLAG_ITEM_SCORCHED_SLAB_TM_SUNNY_DAY", "ITEM_SCORCHED_SLAB_TM_SUNNY_DAY" }, + [1080] = { "FLAG_ITEM_METEOR_FALLS_B1F_2R_TM_DRAGON_CLAW", "ITEM_METEOR_FALLS_B1F_2R_TM_DRAGON_CLAW" }, + [1081] = { "FLAG_ITEM_SHOAL_CAVE_ENTRANCE_BIG_PEARL", "ITEM_SHOAL_CAVE_ENTRANCE_BIG_PEARL" }, + [1082] = { "FLAG_ITEM_SHOAL_CAVE_INNER_ROOM_RARE_CANDY", "ITEM_SHOAL_CAVE_INNER_ROOM_RARE_CANDY" }, + [1083] = { "FLAG_ITEM_SHOAL_CAVE_STAIRS_ROOM_ICE_HEAL", "ITEM_SHOAL_CAVE_STAIRS_ROOM_ICE_HEAL" }, + [1084] = { "FLAG_ITEM_VICTORY_ROAD_1F_MAX_ELIXIR", "ITEM_VICTORY_ROAD_1F_MAX_ELIXIR" }, + [1085] = { "FLAG_ITEM_VICTORY_ROAD_1F_PP_UP", "ITEM_VICTORY_ROAD_1F_PP_UP" }, + [1086] = { "FLAG_ITEM_VICTORY_ROAD_B1F_TM_PSYCHIC", "ITEM_VICTORY_ROAD_B1F_TM_PSYCHIC" }, + [1087] = { "FLAG_ITEM_VICTORY_ROAD_B1F_FULL_RESTORE", "ITEM_VICTORY_ROAD_B1F_FULL_RESTORE" }, + [1088] = { "FLAG_ITEM_VICTORY_ROAD_B2F_FULL_HEAL", "ITEM_VICTORY_ROAD_B2F_FULL_HEAL" }, + [1089] = { "FLAG_ITEM_MT_PYRE_6F_TM_SHADOW_BALL", "ITEM_MT_PYRE_6F_TM_SHADOW_BALL" }, + [1090] = { "FLAG_ITEM_SEAFLOOR_CAVERN_ROOM_9_TM_EARTHQUAKE", "ITEM_SEAFLOOR_CAVERN_ROOM_9_TM_EARTHQUAKE" }, + [1091] = { "FLAG_ITEM_FIERY_PATH_TM_TOXIC", "ITEM_FIERY_PATH_TM_TOXIC" }, + [1092] = { "FLAG_ITEM_ROUTE_124_RED_SHARD", "ITEM_ROUTE_124_RED_SHARD" }, + [1093] = { "FLAG_ITEM_ROUTE_124_BLUE_SHARD", "ITEM_ROUTE_124_BLUE_SHARD" }, + [1094] = { "FLAG_ITEM_SAFARI_ZONE_NORTH_WEST_TM_SOLAR_BEAM", "ITEM_SAFARI_ZONE_NORTH_WEST_TM_SOLAR_BEAM" }, + [1095] = { "FLAG_ITEM_ABANDONED_SHIP_ROOMS_1F_HARBOR_MAIL", "ITEM_ABANDONED_SHIP_ROOMS_1F_HARBOR_MAIL" }, + [1096] = { "FLAG_ITEM_ABANDONED_SHIP_ROOMS_B1F_ESCAPE_ROPE", "ITEM_ABANDONED_SHIP_ROOMS_B1F_ESCAPE_ROPE" }, + [1097] = { "FLAG_ITEM_ABANDONED_SHIP_ROOMS_2_B1F_DIVE_BALL", "ITEM_ABANDONED_SHIP_ROOMS_2_B1F_DIVE_BALL" }, + [1098] = { "FLAG_ITEM_ABANDONED_SHIP_ROOMS_B1F_TM_ICE_BEAM", "ITEM_ABANDONED_SHIP_ROOMS_B1F_TM_ICE_BEAM" }, + [1099] = { "FLAG_ITEM_ABANDONED_SHIP_ROOMS_2_1F_REVIVE", "ITEM_ABANDONED_SHIP_ROOMS_2_1F_REVIVE" }, + [1100] = { "FLAG_ITEM_ABANDONED_SHIP_CAPTAINS_OFFICE_STORAGE_KEY", "ITEM_ABANDONED_SHIP_CAPTAINS_OFFICE_STORAGE_KEY" }, + [1101] = { "FLAG_ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_3_WATER_STONE", "ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_3_WATER_STONE" }, + [1102] = { "FLAG_ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_1_TM_RAIN_DANCE", "ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_1_TM_RAIN_DANCE" }, + [1103] = { "FLAG_ITEM_ROUTE_121_CARBOS", "ITEM_ROUTE_121_CARBOS" }, + [1104] = { "FLAG_ITEM_ROUTE_123_ULTRA_BALL", "ITEM_ROUTE_123_ULTRA_BALL" }, + [1105] = { "FLAG_ITEM_ROUTE_126_GREEN_SHARD", "ITEM_ROUTE_126_GREEN_SHARD" }, + [1106] = { "FLAG_ITEM_ROUTE_119_HYPER_POTION_2", "ITEM_ROUTE_119_HYPER_POTION_2" }, + [1107] = { "FLAG_ITEM_ROUTE_120_HYPER_POTION", "ITEM_ROUTE_120_HYPER_POTION" }, + [1108] = { "FLAG_ITEM_ROUTE_120_NEST_BALL", "ITEM_ROUTE_120_NEST_BALL" }, + [1109] = { "FLAG_ITEM_ROUTE_123_ELIXIR", "ITEM_ROUTE_123_ELIXIR" }, + [1110] = { "FLAG_ITEM_NEW_MAUVILLE_THUNDER_STONE", "ITEM_NEW_MAUVILLE_THUNDER_STONE" }, + [1111] = { "FLAG_ITEM_FIERY_PATH_FIRE_STONE", "ITEM_FIERY_PATH_FIRE_STONE" }, + [1112] = { "FLAG_ITEM_SHOAL_CAVE_ICE_ROOM_TM_HAIL", "ITEM_SHOAL_CAVE_ICE_ROOM_TM_HAIL" }, + [1113] = { "FLAG_ITEM_SHOAL_CAVE_ICE_ROOM_NEVER_MELT_ICE", "ITEM_SHOAL_CAVE_ICE_ROOM_NEVER_MELT_ICE" }, + [1114] = { "FLAG_ITEM_ROUTE_103_GUARD_SPEC", "ITEM_ROUTE_103_GUARD_SPEC" }, + [1115] = { "FLAG_ITEM_ROUTE_104_X_ACCURACY", "ITEM_ROUTE_104_X_ACCURACY" }, + [1116] = { "FLAG_ITEM_MAUVILLE_CITY_X_SPEED", "ITEM_MAUVILLE_CITY_X_SPEED" }, + [1117] = { "FLAG_ITEM_PETALBURG_WOODS_PARALYZE_HEAL", "ITEM_PETALBURG_WOODS_PARALYZE_HEAL" }, + [1118] = { "FLAG_ITEM_ROUTE_115_GREAT_BALL", "ITEM_ROUTE_115_GREAT_BALL" }, + [1119] = { "FLAG_ITEM_SAFARI_ZONE_NORTH_CALCIUM", "ITEM_SAFARI_ZONE_NORTH_CALCIUM" }, + [1120] = { "FLAG_ITEM_MT_PYRE_3F_SUPER_REPEL", "ITEM_MT_PYRE_3F_SUPER_REPEL" }, + [1121] = { "FLAG_ITEM_ROUTE_118_HYPER_POTION", "ITEM_ROUTE_118_HYPER_POTION" }, + [1122] = { "FLAG_ITEM_NEW_MAUVILLE_FULL_HEAL", "ITEM_NEW_MAUVILLE_FULL_HEAL" }, + [1123] = { "FLAG_ITEM_NEW_MAUVILLE_PARALYZE_HEAL", "ITEM_NEW_MAUVILLE_PARALYZE_HEAL" }, + [1124] = { "FLAG_ITEM_AQUA_HIDEOUT_B1F_MASTER_BALL", "ITEM_AQUA_HIDEOUT_B1F_MASTER_BALL" }, + [1125] = { "FLAG_ITEM_OLD_MAGMA_HIDEOUT_B1F_MASTER_BALL", "ITEM_OLD_MAGMA_HIDEOUT_B1F_MASTER_BALL" }, + [1126] = { "FLAG_ITEM_OLD_MAGMA_HIDEOUT_B1F_MAX_ELIXIR", "ITEM_OLD_MAGMA_HIDEOUT_B1F_MAX_ELIXIR" }, + [1127] = { "FLAG_ITEM_OLD_MAGMA_HIDEOUT_B2F_NEST_BALL", "ITEM_OLD_MAGMA_HIDEOUT_B2F_NEST_BALL" }, + [1128] = { "FLAG_UNUSED_0x468", "UNUSED_0x468" }, + [1129] = { "FLAG_ITEM_MT_PYRE_2F_ULTRA_BALL", "ITEM_MT_PYRE_2F_ULTRA_BALL" }, + [1130] = { "FLAG_ITEM_MT_PYRE_4F_SEA_INCENSE", "ITEM_MT_PYRE_4F_SEA_INCENSE" }, + [1131] = { "FLAG_ITEM_SAFARI_ZONE_SOUTH_WEST_MAX_REVIVE", "ITEM_SAFARI_ZONE_SOUTH_WEST_MAX_REVIVE" }, + [1132] = { "FLAG_ITEM_AQUA_HIDEOUT_B1F_NUGGET", "ITEM_AQUA_HIDEOUT_B1F_NUGGET" }, + [1133] = { "FLAG_ITEM_MOSSDEEP_STEVENS_HOUSE_HM08", "ITEM_MOSSDEEP_STEVENS_HOUSE_HM08" }, + [1134] = { "FLAG_ITEM_ROUTE_119_NUGGET", "ITEM_ROUTE_119_NUGGET" }, + [1135] = { "FLAG_ITEM_ROUTE_104_POTION", "ITEM_ROUTE_104_POTION" }, + [1136] = { "FLAG_UNUSED_0x470", "UNUSED_0x470" }, + [1137] = { "FLAG_ITEM_ROUTE_103_PP_UP", "ITEM_ROUTE_103_PP_UP" }, + [1138] = { "FLAG_UNUSED_0x472", "UNUSED_0x472" }, + [1139] = { "FLAG_ITEM_ROUTE_108_STAR_PIECE", "ITEM_ROUTE_108_STAR_PIECE" }, + [1140] = { "FLAG_ITEM_ROUTE_109_POTION", "ITEM_ROUTE_109_POTION" }, + [1141] = { "FLAG_ITEM_ROUTE_110_ELIXIR", "ITEM_ROUTE_110_ELIXIR" }, + [1142] = { "FLAG_ITEM_ROUTE_111_ELIXIR", "ITEM_ROUTE_111_ELIXIR" }, + [1143] = { "FLAG_ITEM_ROUTE_113_HYPER_POTION", "ITEM_ROUTE_113_HYPER_POTION" }, + [1144] = { "FLAG_ITEM_ROUTE_115_HEAL_POWDER", "ITEM_ROUTE_115_HEAL_POWDER" }, + [1145] = { "FLAG_UNUSED_0x479", "UNUSED_0x479" }, + [1146] = { "FLAG_ITEM_ROUTE_116_POTION", "ITEM_ROUTE_116_POTION" }, + [1147] = { "FLAG_ITEM_ROUTE_119_ELIXIR_2", "ITEM_ROUTE_119_ELIXIR_2" }, + [1148] = { "FLAG_ITEM_ROUTE_120_REVIVE", "ITEM_ROUTE_120_REVIVE" }, + [1149] = { "FLAG_ITEM_ROUTE_121_REVIVE", "ITEM_ROUTE_121_REVIVE" }, + [1150] = { "FLAG_ITEM_ROUTE_121_ZINC", "ITEM_ROUTE_121_ZINC" }, + [1151] = { "FLAG_ITEM_MAGMA_HIDEOUT_1F_RARE_CANDY", "ITEM_MAGMA_HIDEOUT_1F_RARE_CANDY" }, + [1152] = { "FLAG_ITEM_ROUTE_123_PP_UP", "ITEM_ROUTE_123_PP_UP" }, + [1153] = { "FLAG_ITEM_ROUTE_123_REVIVAL_HERB", "ITEM_ROUTE_123_REVIVAL_HERB" }, + [1154] = { "FLAG_ITEM_ROUTE_125_BIG_PEARL", "ITEM_ROUTE_125_BIG_PEARL" }, + [1155] = { "FLAG_ITEM_ROUTE_127_RARE_CANDY", "ITEM_ROUTE_127_RARE_CANDY" }, + [1156] = { "FLAG_ITEM_ROUTE_132_PROTEIN", "ITEM_ROUTE_132_PROTEIN" }, + [1157] = { "FLAG_ITEM_ROUTE_133_MAX_REVIVE", "ITEM_ROUTE_133_MAX_REVIVE" }, + [1158] = { "FLAG_ITEM_ROUTE_134_CARBOS", "ITEM_ROUTE_134_CARBOS" }, + [1159] = { "FLAG_ITEM_ROUTE_134_STAR_PIECE", "ITEM_ROUTE_134_STAR_PIECE" }, + [1160] = { "FLAG_ITEM_ROUTE_114_ENERGY_POWDER", "ITEM_ROUTE_114_ENERGY_POWDER" }, + [1161] = { "FLAG_ITEM_ROUTE_115_PP_UP", "ITEM_ROUTE_115_PP_UP" }, + [1162] = { "FLAG_ITEM_ARTISAN_CAVE_B1F_HP_UP", "ITEM_ARTISAN_CAVE_B1F_HP_UP" }, + [1163] = { "FLAG_ITEM_ARTISAN_CAVE_1F_CARBOS", "ITEM_ARTISAN_CAVE_1F_CARBOS" }, + [1164] = { "FLAG_ITEM_MAGMA_HIDEOUT_2F_2R_MAX_ELIXIR", "ITEM_MAGMA_HIDEOUT_2F_2R_MAX_ELIXIR" }, + [1165] = { "FLAG_ITEM_MAGMA_HIDEOUT_2F_2R_FULL_RESTORE", "ITEM_MAGMA_HIDEOUT_2F_2R_FULL_RESTORE" }, + [1166] = { "FLAG_ITEM_MAGMA_HIDEOUT_3F_1R_NUGGET", "ITEM_MAGMA_HIDEOUT_3F_1R_NUGGET" }, + [1167] = { "FLAG_ITEM_MAGMA_HIDEOUT_3F_2R_PP_MAX", "ITEM_MAGMA_HIDEOUT_3F_2R_PP_MAX" }, + [1168] = { "FLAG_ITEM_MAGMA_HIDEOUT_4F_MAX_REVIVE", "ITEM_MAGMA_HIDEOUT_4F_MAX_REVIVE" }, + [1169] = { "FLAG_ITEM_SAFARI_ZONE_NORTH_EAST_NUGGET", "ITEM_SAFARI_ZONE_NORTH_EAST_NUGGET" }, + [1170] = { "FLAG_ITEM_SAFARI_ZONE_SOUTH_EAST_BIG_PEARL", "ITEM_SAFARI_ZONE_SOUTH_EAST_BIG_PEARL" }, + [1171] = { "FLAG_UNUSED_0x493", "UNUSED_0x493" }, + [1172] = { "FLAG_UNUSED_0x494", "UNUSED_0x494" }, + [1173] = { "FLAG_UNUSED_0x495", "UNUSED_0x495" }, + [1174] = { "FLAG_UNUSED_0x496", "UNUSED_0x496" }, + [1175] = { "FLAG_UNUSED_0x497", "UNUSED_0x497" }, + [1176] = { "FLAG_UNUSED_0x498", "UNUSED_0x498" }, + [1177] = { "FLAG_UNUSED_0x499", "UNUSED_0x499" }, + [1178] = { "FLAG_UNUSED_0x49A", "UNUSED_0x49A" }, + [1179] = { "FLAG_UNUSED_0x49B", "UNUSED_0x49B" }, + [1180] = { "FLAG_UNUSED_0x49C", "UNUSED_0x49C" }, + [1181] = { "FLAG_UNUSED_0x49D", "UNUSED_0x49D" }, + [1182] = { "FLAG_UNUSED_0x49E", "UNUSED_0x49E" }, + [1183] = { "FLAG_UNUSED_0x49F", "UNUSED_0x49F" }, + [1184] = { "FLAG_UNUSED_0x4A0", "UNUSED_0x4A0" }, + [1185] = { "FLAG_UNUSED_0x4A1", "UNUSED_0x4A1" }, + [1186] = { "FLAG_UNUSED_0x4A2", "UNUSED_0x4A2" }, + [1187] = { "FLAG_UNUSED_0x4A3", "UNUSED_0x4A3" }, + [1188] = { "FLAG_UNUSED_0x4A4", "UNUSED_0x4A4" }, + [1189] = { "FLAG_UNUSED_0x4A5", "UNUSED_0x4A5" }, + [1190] = { "FLAG_UNUSED_0x4A6", "UNUSED_0x4A6" }, + [1191] = { "FLAG_UNUSED_0x4A7", "UNUSED_0x4A7" }, + [1192] = { "FLAG_UNUSED_0x4A8", "UNUSED_0x4A8" }, + [1193] = { "FLAG_UNUSED_0x4A9", "UNUSED_0x4A9" }, + [1194] = { "FLAG_UNUSED_0x4AA", "UNUSED_0x4AA" }, + [1195] = { "FLAG_UNUSED_0x4AB", "UNUSED_0x4AB" }, + [1196] = { "FLAG_UNUSED_0x4AC", "UNUSED_0x4AC" }, + [1197] = { "FLAG_UNUSED_0x4AD", "UNUSED_0x4AD" }, + [1198] = { "FLAG_UNUSED_0x4AE", "UNUSED_0x4AE" }, + [1199] = { "FLAG_UNUSED_0x4AF", "UNUSED_0x4AF" }, + [1200] = { "FLAG_UNUSED_0x4B0", "UNUSED_0x4B0" }, + [1201] = { "FLAG_UNUSED_0x4B1", "UNUSED_0x4B1" }, + [1202] = { "FLAG_UNUSED_0x4B2", "UNUSED_0x4B2" }, + [1203] = { "FLAG_UNUSED_0x4B3", "UNUSED_0x4B3" }, + [1204] = { "FLAG_UNUSED_0x4B4", "UNUSED_0x4B4" }, + [1205] = { "FLAG_UNUSED_0x4B5", "UNUSED_0x4B5" }, + [1206] = { "FLAG_UNUSED_0x4B6", "UNUSED_0x4B6" }, + [1207] = { "FLAG_UNUSED_0x4B7", "UNUSED_0x4B7" }, + [1208] = { "FLAG_UNUSED_0x4B8", "UNUSED_0x4B8" }, + [1209] = { "FLAG_UNUSED_0x4B9", "UNUSED_0x4B9" }, + [1210] = { "FLAG_UNUSED_0x4BA", "UNUSED_0x4BA" }, + [1211] = { "FLAG_UNUSED_0x4BB", "UNUSED_0x4BB" }, + [1212] = { "FLAG_UNUSED_0x4BC", "UNUSED_0x4BC" }, + [1213] = { "FLAG_UNUSED_0x4BD", "UNUSED_0x4BD" }, + [1214] = { "FLAG_UNUSED_0x4BE", "UNUSED_0x4BE" }, + [1215] = { "FLAG_UNUSED_0x4BF", "UNUSED_0x4BF" }, + [1216] = { "FLAG_UNUSED_0x4C0", "UNUSED_0x4C0" }, + [1217] = { "FLAG_UNUSED_0x4C1", "UNUSED_0x4C1" }, + [1218] = { "FLAG_UNUSED_0x4C2", "UNUSED_0x4C2" }, + [1219] = { "FLAG_UNUSED_0x4C3", "UNUSED_0x4C3" }, + [1220] = { "FLAG_UNUSED_0x4C4", "UNUSED_0x4C4" }, + [1221] = { "FLAG_UNUSED_0x4C5", "UNUSED_0x4C5" }, + [1222] = { "FLAG_UNUSED_0x4C6", "UNUSED_0x4C6" }, + [1223] = { "FLAG_UNUSED_0x4C7", "UNUSED_0x4C7" }, + [1224] = { "FLAG_UNUSED_0x4C8", "UNUSED_0x4C8" }, + [1225] = { "FLAG_UNUSED_0x4C9", "UNUSED_0x4C9" }, + [1226] = { "FLAG_UNUSED_0x4CA", "UNUSED_0x4CA" }, + [1227] = { "FLAG_UNUSED_0x4CB", "UNUSED_0x4CB" }, + [1228] = { "FLAG_UNUSED_0x4CC", "UNUSED_0x4CC" }, + [1229] = { "FLAG_UNUSED_0x4CD", "UNUSED_0x4CD" }, + [1230] = { "FLAG_UNUSED_0x4CE", "UNUSED_0x4CE" }, + [1231] = { "FLAG_UNUSED_0x4CF", "UNUSED_0x4CF" }, + [1232] = { "FLAG_UNUSED_0x4D0", "UNUSED_0x4D0" }, + [1233] = { "FLAG_UNUSED_0x4D1", "UNUSED_0x4D1" }, + [1234] = { "FLAG_UNUSED_0x4D2", "UNUSED_0x4D2" }, + [1235] = { "FLAG_UNUSED_0x4D3", "UNUSED_0x4D3" }, + [1236] = { "FLAG_UNUSED_0x4D4", "UNUSED_0x4D4" }, + [1237] = { "FLAG_UNUSED_0x4D5", "UNUSED_0x4D5" }, + [1238] = { "FLAG_UNUSED_0x4D6", "UNUSED_0x4D6" }, + [1239] = { "FLAG_UNUSED_0x4D7", "UNUSED_0x4D7" }, + [1240] = { "FLAG_UNUSED_0x4D8", "UNUSED_0x4D8" }, + [1241] = { "FLAG_UNUSED_0x4D9", "UNUSED_0x4D9" }, + [1242] = { "FLAG_UNUSED_0x4DA", "UNUSED_0x4DA" }, + [1243] = { "FLAG_UNUSED_0x4DB", "UNUSED_0x4DB" }, + [1244] = { "FLAG_UNUSED_0x4DC", "UNUSED_0x4DC" }, + [1245] = { "FLAG_UNUSED_0x4DD", "UNUSED_0x4DD" }, + [1246] = { "FLAG_UNUSED_0x4DE", "UNUSED_0x4DE" }, + [1247] = { "FLAG_UNUSED_0x4DF", "UNUSED_0x4DF" }, + [1248] = { "FLAG_UNUSED_0x4E0", "UNUSED_0x4E0" }, + [1249] = { "FLAG_UNUSED_0x4E1", "UNUSED_0x4E1" }, + [1250] = { "FLAG_UNUSED_0x4E2", "UNUSED_0x4E2" }, + [1251] = { "FLAG_UNUSED_0x4E3", "UNUSED_0x4E3" }, + [1252] = { "FLAG_UNUSED_0x4E4", "UNUSED_0x4E4" }, + [1253] = { "FLAG_UNUSED_0x4E5", "UNUSED_0x4E5" }, + [1254] = { "FLAG_UNUSED_0x4E6", "UNUSED_0x4E6" }, + [1255] = { "FLAG_UNUSED_0x4E7", "UNUSED_0x4E7" }, + [1256] = { "FLAG_UNUSED_0x4E8", "UNUSED_0x4E8" }, + [1257] = { "FLAG_UNUSED_0x4E9", "UNUSED_0x4E9" }, + [1258] = { "FLAG_UNUSED_0x4EA", "UNUSED_0x4EA" }, + [1259] = { "FLAG_UNUSED_0x4EB", "UNUSED_0x4EB" }, + [1260] = { "FLAG_UNUSED_0x4EC", "UNUSED_0x4EC" }, + [1261] = { "FLAG_UNUSED_0x4ED", "UNUSED_0x4ED" }, + [1262] = { "FLAG_UNUSED_0x4EE", "UNUSED_0x4EE" }, + [1263] = { "FLAG_UNUSED_0x4EF", "UNUSED_0x4EF" }, + [1264] = { "FLAG_DEFEATED_RUSTBORO_GYM", "DEFEATED_RUSTBORO_GYM" }, + [1265] = { "FLAG_DEFEATED_DEWFORD_GYM", "DEFEATED_DEWFORD_GYM" }, + [1266] = { "FLAG_DEFEATED_MAUVILLE_GYM", "DEFEATED_MAUVILLE_GYM" }, + [1267] = { "FLAG_DEFEATED_LAVARIDGE_GYM", "DEFEATED_LAVARIDGE_GYM" }, + [1268] = { "FLAG_DEFEATED_PETALBURG_GYM", "DEFEATED_PETALBURG_GYM" }, + [1269] = { "FLAG_DEFEATED_FORTREE_GYM", "DEFEATED_FORTREE_GYM" }, + [1270] = { "FLAG_DEFEATED_MOSSDEEP_GYM", "DEFEATED_MOSSDEEP_GYM" }, + [1271] = { "FLAG_DEFEATED_SOOTOPOLIS_GYM", "DEFEATED_SOOTOPOLIS_GYM" }, + [1272] = { "FLAG_DEFEATED_METEOR_FALLS_STEVEN", "DEFEATED_METEOR_FALLS_STEVEN" }, + [1273] = { "FLAG_UNUSED_0x4F9", "UNUSED_0x4F9" }, + [1274] = { "FLAG_UNUSED_0x4FA", "UNUSED_0x4FA" }, + [1275] = { "FLAG_DEFEATED_ELITE_4_SIDNEY", "DEFEATED_ELITE_4_SIDNEY" }, + [1276] = { "FLAG_DEFEATED_ELITE_4_PHOEBE", "DEFEATED_ELITE_4_PHOEBE" }, + [1277] = { "FLAG_DEFEATED_ELITE_4_GLACIA", "DEFEATED_ELITE_4_GLACIA" }, + [1278] = { "FLAG_DEFEATED_ELITE_4_DRAKE", "DEFEATED_ELITE_4_DRAKE" }, + [1279] = { "FLAG_UNUSED_0x4FF", "UNUSED_0x4FF" }, + [1280] = { false, "TRAINER_FLAGS_START" }, + [2143] = { false, "TRAINER_FLAGS_END" }, + [2144] = { "FLAG_SYS_POKEMON_GET", "SYSTEM_FLAGS", "SYS_POKEMON_GET" }, + [2145] = { "FLAG_SYS_POKEDEX_GET", "SYS_POKEDEX_GET" }, + [2146] = { "FLAG_SYS_POKENAV_GET", "SYS_POKENAV_GET" }, + [2147] = { "FLAG_UNUSED_0x863", "UNUSED_0x863" }, + [2148] = { "FLAG_SYS_GAME_CLEAR", "SYS_GAME_CLEAR" }, + [2149] = { "FLAG_SYS_CHAT_USED", "SYS_CHAT_USED" }, + [2150] = { "FLAG_UNLOCKED_TRENDY_SAYINGS", "UNLOCKED_TRENDY_SAYINGS" }, + [2151] = { "FLAG_BADGE01_GET", "BADGE01_GET" }, + [2152] = { "FLAG_BADGE02_GET", "BADGE02_GET" }, + [2153] = { "FLAG_BADGE03_GET", "BADGE03_GET" }, + [2154] = { "FLAG_BADGE04_GET", "BADGE04_GET" }, + [2155] = { "FLAG_BADGE05_GET", "BADGE05_GET" }, + [2156] = { "FLAG_BADGE06_GET", "BADGE06_GET" }, + [2157] = { "FLAG_BADGE07_GET", "BADGE07_GET" }, + [2158] = { "FLAG_BADGE08_GET", "BADGE08_GET" }, + [2159] = { "FLAG_VISITED_LITTLEROOT_TOWN", "VISITED_LITTLEROOT_TOWN" }, + [2160] = { "FLAG_VISITED_OLDALE_TOWN", "VISITED_OLDALE_TOWN" }, + [2161] = { "FLAG_VISITED_DEWFORD_TOWN", "VISITED_DEWFORD_TOWN" }, + [2162] = { "FLAG_VISITED_LAVARIDGE_TOWN", "VISITED_LAVARIDGE_TOWN" }, + [2163] = { "FLAG_VISITED_FALLARBOR_TOWN", "VISITED_FALLARBOR_TOWN" }, + [2164] = { "FLAG_VISITED_VERDANTURF_TOWN", "VISITED_VERDANTURF_TOWN" }, + [2165] = { "FLAG_VISITED_PACIFIDLOG_TOWN", "VISITED_PACIFIDLOG_TOWN" }, + [2166] = { "FLAG_VISITED_PETALBURG_CITY", "VISITED_PETALBURG_CITY" }, + [2167] = { "FLAG_VISITED_SLATEPORT_CITY", "VISITED_SLATEPORT_CITY" }, + [2168] = { "FLAG_VISITED_MAUVILLE_CITY", "VISITED_MAUVILLE_CITY" }, + [2169] = { "FLAG_VISITED_RUSTBORO_CITY", "VISITED_RUSTBORO_CITY" }, + [2170] = { "FLAG_VISITED_FORTREE_CITY", "VISITED_FORTREE_CITY" }, + [2171] = { "FLAG_VISITED_LILYCOVE_CITY", "VISITED_LILYCOVE_CITY" }, + [2172] = { "FLAG_VISITED_MOSSDEEP_CITY", "VISITED_MOSSDEEP_CITY" }, + [2173] = { "FLAG_VISITED_SOOTOPOLIS_CITY", "VISITED_SOOTOPOLIS_CITY" }, + [2174] = { "FLAG_VISITED_EVER_GRANDE_CITY", "VISITED_EVER_GRANDE_CITY" }, + [2175] = { "FLAG_IS_CHAMPION", "IS_CHAMPION" }, + [2176] = { "FLAG_NURSE_UNION_ROOM_REMINDER", "NURSE_UNION_ROOM_REMINDER" }, + [2177] = { "FLAG_UNUSED_0x881", "UNUSED_0x881" }, + [2178] = { "FLAG_UNUSED_0x882", "UNUSED_0x882" }, + [2179] = { "FLAG_UNUSED_0x883", "UNUSED_0x883" }, + [2180] = { "FLAG_UNUSED_0x884", "UNUSED_0x884" }, + [2181] = { "FLAG_UNUSED_0x885", "UNUSED_0x885" }, + [2182] = { "FLAG_UNUSED_0x886", "UNUSED_0x886" }, + [2183] = { "FLAG_UNUSED_0x887", "UNUSED_0x887" }, + [2184] = { "FLAG_SYS_USE_FLASH", "SYS_USE_FLASH" }, + [2185] = { "FLAG_SYS_USE_STRENGTH", "SYS_USE_STRENGTH" }, + [2186] = { "FLAG_SYS_WEATHER_CTRL", "SYS_WEATHER_CTRL" }, + [2187] = { "FLAG_SYS_CYCLING_ROAD", "SYS_CYCLING_ROAD" }, + [2188] = { "FLAG_SYS_SAFARI_MODE", "SYS_SAFARI_MODE" }, + [2189] = { "FLAG_SYS_CRUISE_MODE", "SYS_CRUISE_MODE" }, + [2190] = { "FLAG_UNUSED_0x88E", "UNUSED_0x88E" }, + [2191] = { "FLAG_UNUSED_0x88F", "UNUSED_0x88F" }, + [2192] = { "FLAG_SYS_TV_HOME", "SYS_TV_HOME" }, + [2193] = { "FLAG_SYS_TV_WATCH", "SYS_TV_WATCH" }, + [2194] = { false, "FLAG_SYS_TV_START", "SYS_TV_START" }, + [2195] = { "FLAG_SYS_CHANGED_DEWFORD_TREND", "SYS_CHANGED_DEWFORD_TREND" }, + [2196] = { "FLAG_SYS_MIX_RECORD", "SYS_MIX_RECORD" }, + [2197] = { "FLAG_SYS_CLOCK_SET", "SYS_CLOCK_SET" }, + [2198] = { "FLAG_SYS_NATIONAL_DEX", "SYS_NATIONAL_DEX" }, + [2199] = { "FLAG_SYS_CAVE_SHIP", "SYS_CAVE_SHIP" }, + [2200] = { "FLAG_SYS_CAVE_WONDER", "SYS_CAVE_WONDER" }, + [2201] = { "FLAG_SYS_CAVE_BATTLE", "SYS_CAVE_BATTLE" }, + [2202] = { "FLAG_SYS_SHOAL_TIDE", "SYS_SHOAL_TIDE" }, + [2203] = { "FLAG_SYS_RIBBON_GET", "SYS_RIBBON_GET" }, + [2204] = { "FLAG_LANDMARK_FLOWER_SHOP", "LANDMARK_FLOWER_SHOP" }, + [2205] = { "FLAG_LANDMARK_MR_BRINEY_HOUSE", "LANDMARK_MR_BRINEY_HOUSE" }, + [2206] = { "FLAG_LANDMARK_ABANDONED_SHIP", "LANDMARK_ABANDONED_SHIP" }, + [2207] = { "FLAG_LANDMARK_SEASHORE_HOUSE", "LANDMARK_SEASHORE_HOUSE" }, + [2208] = { "FLAG_LANDMARK_NEW_MAUVILLE", "LANDMARK_NEW_MAUVILLE" }, + [2209] = { "FLAG_LANDMARK_OLD_LADY_REST_SHOP", "LANDMARK_OLD_LADY_REST_SHOP" }, + [2210] = { "FLAG_LANDMARK_TRICK_HOUSE", "LANDMARK_TRICK_HOUSE" }, + [2211] = { "FLAG_LANDMARK_WINSTRATE_FAMILY", "LANDMARK_WINSTRATE_FAMILY" }, + [2212] = { "FLAG_LANDMARK_GLASS_WORKSHOP", "LANDMARK_GLASS_WORKSHOP" }, + [2213] = { "FLAG_LANDMARK_LANETTES_HOUSE", "LANDMARK_LANETTES_HOUSE" }, + [2214] = { "FLAG_LANDMARK_POKEMON_DAYCARE", "LANDMARK_POKEMON_DAYCARE" }, + [2215] = { "FLAG_LANDMARK_SEAFLOOR_CAVERN", "LANDMARK_SEAFLOOR_CAVERN" }, + [2216] = { "FLAG_LANDMARK_BATTLE_FRONTIER", "LANDMARK_BATTLE_FRONTIER" }, + [2217] = { "FLAG_LANDMARK_SOUTHERN_ISLAND", "LANDMARK_SOUTHERN_ISLAND" }, + [2218] = { "FLAG_LANDMARK_FIERY_PATH", "LANDMARK_FIERY_PATH" }, + [2219] = { "FLAG_SYS_PC_LANETTE", "SYS_PC_LANETTE" }, + [2220] = { "FLAG_SYS_MYSTERY_EVENT_ENABLE", "SYS_MYSTERY_EVENT_ENABLE" }, + [2221] = { "FLAG_SYS_ENC_UP_ITEM", "SYS_ENC_UP_ITEM" }, + [2222] = { "FLAG_SYS_ENC_DOWN_ITEM", "SYS_ENC_DOWN_ITEM" }, + [2223] = { "FLAG_SYS_BRAILLE_DIG", "SYS_BRAILLE_DIG" }, + [2224] = { "FLAG_SYS_REGIROCK_PUZZLE_COMPLETED", "SYS_REGIROCK_PUZZLE_COMPLETED" }, + [2225] = { "FLAG_SYS_BRAILLE_REGICE_COMPLETED", "SYS_BRAILLE_REGICE_COMPLETED" }, + [2226] = { "FLAG_SYS_REGISTEEL_PUZZLE_COMPLETED", "SYS_REGISTEEL_PUZZLE_COMPLETED" }, + [2227] = { "FLAG_ENABLE_SHIP_SOUTHERN_ISLAND", "ENABLE_SHIP_SOUTHERN_ISLAND" }, + [2228] = { "FLAG_LANDMARK_POKEMON_LEAGUE", "LANDMARK_POKEMON_LEAGUE" }, + [2229] = { "FLAG_LANDMARK_ISLAND_CAVE", "LANDMARK_ISLAND_CAVE" }, + [2230] = { "FLAG_LANDMARK_DESERT_RUINS", "LANDMARK_DESERT_RUINS" }, + [2231] = { "FLAG_LANDMARK_FOSSIL_MANIACS_HOUSE", "LANDMARK_FOSSIL_MANIACS_HOUSE" }, + [2232] = { "FLAG_LANDMARK_SCORCHED_SLAB", "LANDMARK_SCORCHED_SLAB" }, + [2233] = { "FLAG_LANDMARK_ANCIENT_TOMB", "LANDMARK_ANCIENT_TOMB" }, + [2234] = { "FLAG_LANDMARK_TUNNELERS_REST_HOUSE", "LANDMARK_TUNNELERS_REST_HOUSE" }, + [2235] = { "FLAG_LANDMARK_HUNTERS_HOUSE", "LANDMARK_HUNTERS_HOUSE" }, + [2236] = { "FLAG_LANDMARK_SEALED_CHAMBER", "LANDMARK_SEALED_CHAMBER" }, + [2237] = { "FLAG_SYS_TV_LATIAS_LATIOS", "SYS_TV_LATIAS_LATIOS" }, + [2238] = { "FLAG_LANDMARK_SKY_PILLAR", "LANDMARK_SKY_PILLAR" }, + [2239] = { "FLAG_SYS_SHOAL_ITEM", "SYS_SHOAL_ITEM" }, + [2240] = { "FLAG_SYS_B_DASH", "SYS_B_DASH" }, + [2241] = { "FLAG_SYS_CTRL_OBJ_DELETE", "SYS_CTRL_OBJ_DELETE" }, + [2242] = { "FLAG_SYS_RESET_RTC_ENABLE", "SYS_RESET_RTC_ENABLE" }, + [2243] = { "FLAG_LANDMARK_BERRY_MASTERS_HOUSE", "LANDMARK_BERRY_MASTERS_HOUSE" }, + [2244] = { "FLAG_SYS_TOWER_SILVER", "SYS_TOWER_SILVER" }, + [2245] = { "FLAG_SYS_TOWER_GOLD", "SYS_TOWER_GOLD" }, + [2246] = { "FLAG_SYS_DOME_SILVER", "SYS_DOME_SILVER" }, + [2247] = { "FLAG_SYS_DOME_GOLD", "SYS_DOME_GOLD" }, + [2248] = { "FLAG_SYS_PALACE_SILVER", "SYS_PALACE_SILVER" }, + [2249] = { "FLAG_SYS_PALACE_GOLD", "SYS_PALACE_GOLD" }, + [2250] = { "FLAG_SYS_ARENA_SILVER", "SYS_ARENA_SILVER" }, + [2251] = { "FLAG_SYS_ARENA_GOLD", "SYS_ARENA_GOLD" }, + [2252] = { "FLAG_SYS_FACTORY_SILVER", "SYS_FACTORY_SILVER" }, + [2253] = { "FLAG_SYS_FACTORY_GOLD", "SYS_FACTORY_GOLD" }, + [2254] = { "FLAG_SYS_PIKE_SILVER", "SYS_PIKE_SILVER" }, + [2255] = { "FLAG_SYS_PIKE_GOLD", "SYS_PIKE_GOLD" }, + [2256] = { "FLAG_SYS_PYRAMID_SILVER", "SYS_PYRAMID_SILVER" }, + [2257] = { "FLAG_SYS_PYRAMID_GOLD", "SYS_PYRAMID_GOLD" }, + [2258] = { "FLAG_SYS_FRONTIER_PASS", "SYS_FRONTIER_PASS" }, + [2259] = { "FLAG_MAP_SCRIPT_CHECKED_DEOXYS", "MAP_SCRIPT_CHECKED_DEOXYS" }, + [2260] = { "FLAG_DEOXYS_ROCK_COMPLETE", "DEOXYS_ROCK_COMPLETE" }, + [2261] = { "FLAG_ENABLE_SHIP_BIRTH_ISLAND", "ENABLE_SHIP_BIRTH_ISLAND" }, + [2262] = { "FLAG_ENABLE_SHIP_FARAWAY_ISLAND", "ENABLE_SHIP_FARAWAY_ISLAND" }, + [2263] = { "FLAG_SHOWN_BOX_WAS_FULL_MESSAGE", "SHOWN_BOX_WAS_FULL_MESSAGE" }, + [2264] = { "FLAG_ARRIVED_ON_FARAWAY_ISLAND", "ARRIVED_ON_FARAWAY_ISLAND" }, + [2265] = { "FLAG_ARRIVED_AT_MARINE_CAVE_EMERGE_SPOT", "ARRIVED_AT_MARINE_CAVE_EMERGE_SPOT" }, + [2266] = { "FLAG_ARRIVED_AT_TERRA_CAVE_ENTRANCE", "ARRIVED_AT_TERRA_CAVE_ENTRANCE" }, + [2267] = { "FLAG_SYS_MYSTERY_GIFT_ENABLE", "SYS_MYSTERY_GIFT_ENABLE" }, + [2268] = { "FLAG_ENTERED_MIRAGE_TOWER", "ENTERED_MIRAGE_TOWER" }, + [2269] = { "FLAG_LANDMARK_ALTERING_CAVE", "LANDMARK_ALTERING_CAVE" }, + [2270] = { "FLAG_LANDMARK_DESERT_UNDERPASS", "LANDMARK_DESERT_UNDERPASS" }, + [2271] = { "FLAG_LANDMARK_ARTISAN_CAVE", "LANDMARK_ARTISAN_CAVE" }, + [2272] = { "FLAG_ENABLE_SHIP_NAVEL_ROCK", "ENABLE_SHIP_NAVEL_ROCK" }, + [2273] = { "FLAG_ARRIVED_AT_NAVEL_ROCK", "ARRIVED_AT_NAVEL_ROCK" }, + [2274] = { "FLAG_LANDMARK_TRAINER_HILL", "LANDMARK_TRAINER_HILL" }, + [2275] = { "FLAG_UNUSED_0x8E3", "UNUSED_0x8E3" }, + [2276] = { "FLAG_RECEIVED_POKEDEX_FROM_BIRCH", "RECEIVED_POKEDEX_FROM_BIRCH" }, + [2277] = { "FLAG_UNUSED_0x8E5", "UNUSED_0x8E5" }, + [2278] = { "FLAG_UNUSED_0x8E6", "UNUSED_0x8E6" }, + [2279] = { "FLAG_UNUSED_0x8E7", "UNUSED_0x8E7" }, + [2280] = { "FLAG_UNUSED_0x8E8", "UNUSED_0x8E8" }, + [2281] = { "FLAG_UNUSED_0x8E9", "UNUSED_0x8E9" }, + [2282] = { "FLAG_UNUSED_0x8EA", "UNUSED_0x8EA" }, + [2283] = { "FLAG_UNUSED_0x8EB", "UNUSED_0x8EB" }, + [2284] = { "FLAG_UNUSED_0x8EC", "UNUSED_0x8EC" }, + [2285] = { "FLAG_UNUSED_0x8ED", "UNUSED_0x8ED" }, + [2286] = { "FLAG_UNUSED_0x8EE", "UNUSED_0x8EE" }, + [2287] = { "FLAG_UNUSED_0x8EF", "UNUSED_0x8EF" }, + [2288] = { "FLAG_UNUSED_0x8F0", "UNUSED_0x8F0" }, + [2289] = { "FLAG_UNUSED_0x8F1", "UNUSED_0x8F1" }, + [2290] = { "FLAG_UNUSED_0x8F2", "UNUSED_0x8F2" }, + [2291] = { "FLAG_UNUSED_0x8F3", "UNUSED_0x8F3" }, + [2292] = { "FLAG_UNUSED_0x8F4", "UNUSED_0x8F4" }, + [2293] = { "FLAG_UNUSED_0x8F5", "UNUSED_0x8F5" }, + [2294] = { "FLAG_UNUSED_0x8F6", "UNUSED_0x8F6" }, + [2295] = { "FLAG_UNUSED_0x8F7", "UNUSED_0x8F7" }, + [2296] = { "FLAG_UNUSED_0x8F8", "UNUSED_0x8F8" }, + [2297] = { "FLAG_UNUSED_0x8F9", "UNUSED_0x8F9" }, + [2298] = { "FLAG_UNUSED_0x8FA", "UNUSED_0x8FA" }, + [2299] = { "FLAG_UNUSED_0x8FB", "UNUSED_0x8FB" }, + [2300] = { "FLAG_UNUSED_0x8FC", "UNUSED_0x8FC" }, + [2301] = { "FLAG_UNUSED_0x8FD", "UNUSED_0x8FD" }, + [2302] = { "FLAG_UNUSED_0x8FE", "UNUSED_0x8FE" }, + [2303] = { "FLAG_UNUSED_0x8FF", "UNUSED_0x8FF" }, + [2304] = { "FLAG_UNUSED_0x900", "UNUSED_0x900" }, + [2305] = { "FLAG_UNUSED_0x901", "UNUSED_0x901" }, + [2306] = { "FLAG_UNUSED_0x902", "UNUSED_0x902" }, + [2307] = { "FLAG_UNUSED_0x903", "UNUSED_0x903" }, + [2308] = { "FLAG_UNUSED_0x904", "UNUSED_0x904" }, + [2309] = { "FLAG_UNUSED_0x905", "UNUSED_0x905" }, + [2310] = { "FLAG_UNUSED_0x906", "UNUSED_0x906" }, + [2311] = { "FLAG_UNUSED_0x907", "UNUSED_0x907" }, + [2312] = { "FLAG_UNUSED_0x908", "UNUSED_0x908" }, + [2313] = { "FLAG_UNUSED_0x909", "UNUSED_0x909" }, + [2314] = { "FLAG_UNUSED_0x90A", "UNUSED_0x90A" }, + [2315] = { "FLAG_UNUSED_0x90B", "UNUSED_0x90B" }, + [2316] = { "FLAG_UNUSED_0x90C", "UNUSED_0x90C" }, + [2317] = { "FLAG_UNUSED_0x90D", "UNUSED_0x90D" }, + [2318] = { "FLAG_UNUSED_0x90E", "UNUSED_0x90E" }, + [2319] = { "FLAG_UNUSED_0x90F", "UNUSED_0x90F" }, + [2320] = { "FLAG_UNUSED_0x910", "UNUSED_0x910" }, + [2321] = { "FLAG_UNUSED_0x911", "UNUSED_0x911" }, + [2322] = { "FLAG_UNUSED_0x912", "UNUSED_0x912" }, + [2323] = { "FLAG_UNUSED_0x913", "UNUSED_0x913" }, + [2324] = { "FLAG_UNUSED_0x914", "UNUSED_0x914" }, + [2325] = { "FLAG_UNUSED_0x915", "UNUSED_0x915" }, + [2326] = { "FLAG_UNUSED_0x916", "UNUSED_0x916" }, + [2327] = { "FLAG_UNUSED_0x917", "UNUSED_0x917" }, + [2328] = { "FLAG_UNUSED_0x918", "UNUSED_0x918" }, + [2329] = { "FLAG_UNUSED_0x919", "UNUSED_0x919" }, + [2330] = { "FLAG_UNUSED_0x91A", "UNUSED_0x91A" }, + [2331] = { "FLAG_UNUSED_0x91B", "UNUSED_0x91B" }, + [2332] = { "FLAG_UNUSED_0x91C", "UNUSED_0x91C" }, + [2333] = { "FLAG_UNUSED_0x91D", "UNUSED_0x91D" }, + [2334] = { "FLAG_UNUSED_0x91E", "UNUSED_0x91E" }, + [2335] = { "FLAG_UNUSED_0x91F", "UNUSED_0x91F" }, + [2336] = { "FLAG_UNUSED_0x920", "DAILY_FLAGS_START", "UNUSED_0x920" }, + [2337] = { "FLAG_DAILY_CONTEST_LOBBY_RECEIVED_BERRY", "DAILY_CONTEST_LOBBY_RECEIVED_BERRY" }, + [2338] = { "FLAG_DAILY_SECRET_BASE", "DAILY_SECRET_BASE" }, + [2339] = { "FLAG_UNUSED_0x923", "UNUSED_0x923" }, + [2340] = { "FLAG_UNUSED_0x924", "UNUSED_0x924" }, + [2341] = { "FLAG_UNUSED_0x925", "UNUSED_0x925" }, + [2342] = { "FLAG_UNUSED_0x926", "UNUSED_0x926" }, + [2343] = { "FLAG_UNUSED_0x927", "UNUSED_0x927" }, + [2344] = { "FLAG_UNUSED_0x928", "UNUSED_0x928" }, + [2345] = { "FLAG_UNUSED_0x929", "UNUSED_0x929" }, + [2346] = { "FLAG_DAILY_PICKED_LOTO_TICKET", "DAILY_PICKED_LOTO_TICKET" }, + [2347] = { "FLAG_DAILY_ROUTE_114_RECEIVED_BERRY", "DAILY_ROUTE_114_RECEIVED_BERRY" }, + [2348] = { "FLAG_DAILY_ROUTE_111_RECEIVED_BERRY", "DAILY_ROUTE_111_RECEIVED_BERRY" }, + [2349] = { "FLAG_DAILY_BERRY_MASTER_RECEIVED_BERRY", "DAILY_BERRY_MASTER_RECEIVED_BERRY" }, + [2350] = { "FLAG_DAILY_ROUTE_120_RECEIVED_BERRY", "DAILY_ROUTE_120_RECEIVED_BERRY" }, + [2351] = { "FLAG_DAILY_LILYCOVE_RECEIVED_BERRY", "DAILY_LILYCOVE_RECEIVED_BERRY" }, + [2352] = { "FLAG_DAILY_FLOWER_SHOP_RECEIVED_BERRY", "DAILY_FLOWER_SHOP_RECEIVED_BERRY" }, + [2353] = { "FLAG_DAILY_BERRY_MASTERS_WIFE", "DAILY_BERRY_MASTERS_WIFE" }, + [2354] = { "FLAG_DAILY_SOOTOPOLIS_RECEIVED_BERRY", "DAILY_SOOTOPOLIS_RECEIVED_BERRY" }, + [2355] = { "FLAG_UNUSED_0x933", "UNUSED_0x933" }, + [2356] = { "FLAG_DAILY_APPRENTICE_LEAVES", "DAILY_APPRENTICE_LEAVES" }, + [2357] = { "FLAG_UNUSED_0x935", "UNUSED_0x935" }, + [2358] = { "FLAG_UNUSED_0x936", "UNUSED_0x936" }, + [2359] = { "FLAG_UNUSED_0x937", "UNUSED_0x937" }, + [2360] = { "FLAG_UNUSED_0x938", "UNUSED_0x938" }, + [2361] = { "FLAG_UNUSED_0x939", "UNUSED_0x939" }, + [2362] = { "FLAG_UNUSED_0x93A", "UNUSED_0x93A" }, + [2363] = { "FLAG_UNUSED_0x93B", "UNUSED_0x93B" }, + [2364] = { "FLAG_UNUSED_0x93C", "UNUSED_0x93C" }, + [2365] = { "FLAG_UNUSED_0x93D", "UNUSED_0x93D" }, + [2366] = { "FLAG_UNUSED_0x93E", "UNUSED_0x93E" }, + [2367] = { "FLAG_UNUSED_0x93F", "UNUSED_0x93F" }, + [2368] = { "FLAG_UNUSED_0x940", "UNUSED_0x940" }, + [2369] = { "FLAG_UNUSED_0x941", "UNUSED_0x941" }, + [2370] = { "FLAG_UNUSED_0x942", "UNUSED_0x942" }, + [2371] = { "FLAG_UNUSED_0x943", "UNUSED_0x943" }, + [2372] = { "FLAG_UNUSED_0x944", "UNUSED_0x944" }, + [2373] = { "FLAG_UNUSED_0x945", "UNUSED_0x945" }, + [2374] = { "FLAG_UNUSED_0x946", "UNUSED_0x946" }, + [2375] = { "FLAG_UNUSED_0x947", "UNUSED_0x947" }, + [2376] = { "FLAG_UNUSED_0x948", "UNUSED_0x948" }, + [2377] = { "FLAG_UNUSED_0x949", "UNUSED_0x949" }, + [2378] = { "FLAG_UNUSED_0x94A", "UNUSED_0x94A" }, + [2379] = { "FLAG_UNUSED_0x94B", "UNUSED_0x94B" }, + [2380] = { "FLAG_UNUSED_0x94C", "UNUSED_0x94C" }, + [2381] = { "FLAG_UNUSED_0x94D", "UNUSED_0x94D" }, + [2382] = { "FLAG_UNUSED_0x94E", "UNUSED_0x94E" }, + [2383] = { "FLAG_UNUSED_0x94F", "UNUSED_0x94F" }, + [2384] = { "FLAG_UNUSED_0x950", "UNUSED_0x950" }, + [2385] = { "FLAG_UNUSED_0x951", "UNUSED_0x951" }, + [2386] = { "FLAG_UNUSED_0x952", "UNUSED_0x952" }, + [2387] = { "FLAG_UNUSED_0x953", "UNUSED_0x953" }, + [2388] = { "FLAG_UNUSED_0x954", "UNUSED_0x954" }, + [2389] = { "FLAG_UNUSED_0x955", "UNUSED_0x955" }, + [2390] = { "FLAG_UNUSED_0x956", "UNUSED_0x956" }, + [2391] = { "FLAG_UNUSED_0x957", "UNUSED_0x957" }, + [2392] = { "FLAG_UNUSED_0x958", "UNUSED_0x958" }, + [2393] = { "FLAG_UNUSED_0x959", "UNUSED_0x959" }, + [2394] = { "FLAG_UNUSED_0x95A", "UNUSED_0x95A" }, + [2395] = { "FLAG_UNUSED_0x95B", "UNUSED_0x95B" }, + [2396] = { "FLAG_UNUSED_0x95C", "UNUSED_0x95C" }, + [2397] = { "FLAG_UNUSED_0x95D", "UNUSED_0x95D" }, + [2398] = { "FLAG_UNUSED_0x95E", "UNUSED_0x95E" }, + [2399] = { "FLAG_UNUSED_0x95F", "DAILY_FLAGS_END", "UNUSED_0x95F" }, + }, +} diff --git a/src/mods/flag_names/firered.lua b/src/mods/flag_names/firered.lua new file mode 100644 index 000000000..4123b7412 --- /dev/null +++ b/src/mods/flag_names/firered.lua @@ -0,0 +1,1477 @@ +-- Generated flag identifiers only; no engine implementation. +-- Source: bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 +-- Regenerate with tools/flag-catalog-check/generate.py. +return { + limit = 2304, + records = { + [1] = { "FLAG_TEMP_1", "TEMP_1" }, + [2] = { "FLAG_TEMP_2", "TEMP_2" }, + [3] = { "FLAG_TEMP_3", "TEMP_3" }, + [4] = { "FLAG_TEMP_4", "TEMP_4" }, + [5] = { "FLAG_TEMP_5", "TEMP_5" }, + [6] = { "FLAG_TEMP_6", "TEMP_6" }, + [7] = { "FLAG_TEMP_7", "TEMP_7" }, + [8] = { "FLAG_TEMP_8", "NUM_BADGES", "TEMP_8" }, + [9] = { "FLAG_TEMP_9", "TEMP_9" }, + [10] = { "FLAG_TEMP_A", "TEMP_A" }, + [11] = { "FLAG_TEMP_B", "TEMP_B" }, + [12] = { "FLAG_TEMP_C", "TEMP_C" }, + [13] = { "FLAG_TEMP_D", "TEMP_D" }, + [14] = { "FLAG_TEMP_E", "TEMP_E" }, + [15] = { "FLAG_TEMP_F", "TEMP_F" }, + [16] = { "FLAG_TEMP_10", "TEMP_10" }, + [17] = { "FLAG_TEMP_11", "TEMP_11" }, + [18] = { "FLAG_TEMP_12", "TEMP_12" }, + [19] = { "FLAG_TEMP_13", "TEMP_13" }, + [20] = { "FLAG_TEMP_14", "NUM_WONDER_CARD_FLAGS", "TEMP_14" }, + [21] = { "FLAG_TEMP_15", "TEMP_15" }, + [22] = { "FLAG_TEMP_16", "TEMP_16" }, + [23] = { "FLAG_TEMP_17", "TEMP_17" }, + [24] = { "FLAG_TEMP_18", "TEMP_18" }, + [25] = { "FLAG_TEMP_19", "TEMP_19" }, + [26] = { "FLAG_TEMP_1A", "TEMP_1A" }, + [27] = { "FLAG_TEMP_1B", "TEMP_1B" }, + [28] = { "FLAG_TEMP_1C", "TEMP_1C" }, + [29] = { "FLAG_TEMP_1D", "TEMP_1D" }, + [30] = { "FLAG_TEMP_1E", "TEMP_1E" }, + [31] = { "FLAG_TEMP_1F", "TEMP_1F", "TEMP_FLAGS_END" }, + [32] = { "FLAG_0x020", "0x020" }, + [33] = { "FLAG_0x021", "0x021" }, + [34] = { "FLAG_0x022", "0x022" }, + [35] = { "FLAG_0x023", "0x023" }, + [36] = { "FLAG_0x024", "0x024" }, + [37] = { "FLAG_0x025", "0x025" }, + [38] = { "FLAG_0x026", "0x026" }, + [39] = { "FLAG_0x027", "0x027" }, + [40] = { "FLAG_HIDE_BULBASAUR_BALL", "HIDE_BULBASAUR_BALL" }, + [41] = { "FLAG_HIDE_SQUIRTLE_BALL", "HIDE_SQUIRTLE_BALL" }, + [42] = { "FLAG_HIDE_CHARMANDER_BALL", "HIDE_CHARMANDER_BALL" }, + [43] = { "FLAG_HIDE_OAK_IN_HIS_LAB", "HIDE_OAK_IN_HIS_LAB" }, + [44] = { "FLAG_HIDE_OAK_IN_PALLET_TOWN", "HIDE_OAK_IN_PALLET_TOWN" }, + [45] = { "FLAG_HIDE_RIVAL_IN_LAB", "HIDE_RIVAL_IN_LAB" }, + [46] = { "FLAG_HIDE_PEWTER_CITY_GYM_GUIDE", "HIDE_PEWTER_CITY_GYM_GUIDE" }, + [47] = { "FLAG_HIDE_DOME_FOSSIL", "HIDE_DOME_FOSSIL" }, + [48] = { "FLAG_HIDE_HELIX_FOSSIL", "HIDE_HELIX_FOSSIL" }, + [49] = { "FLAG_HIDE_NUGGET_BRIDGE_ROCKET", "HIDE_NUGGET_BRIDGE_ROCKET" }, + [50] = { "FLAG_HIDE_BILL_CLEFAIRY", "HIDE_BILL_CLEFAIRY" }, + [51] = { "FLAG_HIDE_BILL_HUMAN_SEA_COTTAGE", "HIDE_BILL_HUMAN_SEA_COTTAGE" }, + [52] = { "FLAG_HIDE_TOWER_FUJI", "HIDE_TOWER_FUJI" }, + [53] = { "FLAG_HIDE_POKEHOUSE_FUJI", "HIDE_POKEHOUSE_FUJI" }, + [54] = { "FLAG_HIDE_LIFT_KEY", "HIDE_LIFT_KEY" }, + [55] = { "FLAG_HIDE_SILPH_SCOPE", "HIDE_SILPH_SCOPE" }, + [56] = { "FLAG_HIDE_HIDEOUT_GIOVANNI", "HIDE_HIDEOUT_GIOVANNI" }, + [57] = { "FLAG_HIDE_TOWN_MAP", "HIDE_TOWN_MAP" }, + [58] = { "FLAG_HIDE_POKEDEX", "HIDE_POKEDEX" }, + [59] = { "FLAG_HIDE_CERULEAN_ROCKET", "HIDE_CERULEAN_ROCKET" }, + [60] = { "FLAG_HIDE_CERULEAN_RIVAL", "HIDE_CERULEAN_RIVAL" }, + [61] = { "FLAG_HIDE_SS_ANNE_RIVAL", "HIDE_SS_ANNE_RIVAL" }, + [62] = { "FLAG_HIDE_SAFFRON_ROCKETS", "HIDE_SAFFRON_ROCKETS" }, + [63] = { "FLAG_HIDE_SAFFRON_CIVILIANS", "HIDE_SAFFRON_CIVILIANS" }, + [64] = { "FLAG_HIDE_SEAFOAM_1F_BOULDER_1", "HIDE_SEAFOAM_1F_BOULDER_1" }, + [65] = { "FLAG_HIDE_SEAFOAM_1F_BOULDER_2", "HIDE_SEAFOAM_1F_BOULDER_2" }, + [66] = { "FLAG_HIDE_SEAFOAM_B1F_BOULDER_1", "HIDE_SEAFOAM_B1F_BOULDER_1" }, + [67] = { "FLAG_HIDE_SEAFOAM_B1F_BOULDER_2", "HIDE_SEAFOAM_B1F_BOULDER_2" }, + [68] = { "FLAG_HIDE_SEAFOAM_B2F_BOULDER_1", "HIDE_SEAFOAM_B2F_BOULDER_1" }, + [69] = { "FLAG_HIDE_SEAFOAM_B2F_BOULDER_2", "HIDE_SEAFOAM_B2F_BOULDER_2" }, + [70] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_1", "HIDE_SEAFOAM_B3F_BOULDER_1" }, + [71] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_2", "HIDE_SEAFOAM_B3F_BOULDER_2" }, + [72] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_3", "HIDE_SEAFOAM_B3F_BOULDER_3" }, + [73] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_4", "HIDE_SEAFOAM_B3F_BOULDER_4" }, + [74] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_5", "HIDE_SEAFOAM_B3F_BOULDER_5" }, + [75] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_6", "HIDE_SEAFOAM_B3F_BOULDER_6" }, + [76] = { "FLAG_HIDE_SEAFOAM_B4F_BOULDER_1", "HIDE_SEAFOAM_B4F_BOULDER_1" }, + [77] = { "FLAG_HIDE_SEAFOAM_B4F_BOULDER_2", "HIDE_SEAFOAM_B4F_BOULDER_2" }, + [78] = { "FLAG_HIDE_SILPH_RIVAL", "HIDE_SILPH_RIVAL" }, + [79] = { "FLAG_HIDE_ROUTE_22_RIVAL", "HIDE_ROUTE_22_RIVAL" }, + [80] = { "FLAG_HIDE_PEWTER_MUSEUM_GUIDE", "HIDE_PEWTER_MUSEUM_GUIDE" }, + [81] = { "FLAG_HIDE_TOWER_RIVAL", "HIDE_TOWER_RIVAL" }, + [82] = { "FLAG_HIDE_MOLTRES", "HIDE_MOLTRES" }, + [83] = { "FLAG_HIDE_SILPH_ROCKETS", "HIDE_SILPH_ROCKETS" }, + [84] = { "FLAG_HIDE_ROUTE_12_SNORLAX", "HIDE_ROUTE_12_SNORLAX" }, + [85] = { "FLAG_HIDE_VIRIDIAN_GIOVANNI", "HIDE_VIRIDIAN_GIOVANNI" }, + [86] = { "FLAG_HIDE_OLD_AMBER", "HIDE_OLD_AMBER" }, + [87] = { "FLAG_HIDE_EEVEE_BALL", "HIDE_EEVEE_BALL" }, + [88] = { "FLAG_HIDE_VICTORY_ROAD_2F_BOULDER", "HIDE_VICTORY_ROAD_2F_BOULDER" }, + [89] = { "FLAG_HIDE_VICTORY_ROAD_3F_BOULDER", "HIDE_VICTORY_ROAD_3F_BOULDER" }, + [90] = { "FLAG_HIDE_OAK_IN_CHAMP_ROOM", "HIDE_OAK_IN_CHAMP_ROOM" }, + [91] = { "FLAG_HIDE_GAME_CORNER_ROCKET", "HIDE_GAME_CORNER_ROCKET" }, + [92] = { "FLAG_HIDE_CERULEAN_CAVE_GUARD", "HIDE_CERULEAN_CAVE_GUARD" }, + [93] = { "FLAG_HIDE_ZAPDOS", "HIDE_ZAPDOS" }, + [94] = { "FLAG_HIDE_TOWER_ROCKET_1", "HIDE_TOWER_ROCKET_1" }, + [95] = { "FLAG_HIDE_CELADON_ROCKETS", "HIDE_CELADON_ROCKETS" }, + [96] = { "FLAG_HIDE_DOJO_HITMONLEE_BALL", "HIDE_DOJO_HITMONLEE_BALL" }, + [97] = { "FLAG_HIDE_DOJO_HITMONCHAN_BALL", "HIDE_DOJO_HITMONCHAN_BALL" }, + [98] = { "FLAG_HIDE_CINNABAR_BILL", "HIDE_CINNABAR_BILL" }, + [99] = { "FLAG_HIDE_UNION_ROOM_PLAYER_1", "HIDE_UNION_ROOM_PLAYER_1" }, + [100] = { "FLAG_HIDE_UNION_ROOM_PLAYER_2", "HIDE_UNION_ROOM_PLAYER_2" }, + [101] = { "FLAG_HIDE_UNION_ROOM_PLAYER_3", "HIDE_UNION_ROOM_PLAYER_3" }, + [102] = { "FLAG_HIDE_UNION_ROOM_PLAYER_4", "HIDE_UNION_ROOM_PLAYER_4" }, + [103] = { "FLAG_HIDE_UNION_ROOM_PLAYER_5", "HIDE_UNION_ROOM_PLAYER_5" }, + [104] = { "FLAG_HIDE_UNION_ROOM_PLAYER_6", "HIDE_UNION_ROOM_PLAYER_6" }, + [105] = { "FLAG_HIDE_UNION_ROOM_PLAYER_7", "HIDE_UNION_ROOM_PLAYER_7" }, + [106] = { "FLAG_HIDE_UNION_ROOM_PLAYER_8", "HIDE_UNION_ROOM_PLAYER_8" }, + [107] = { "FLAG_HIDE_CINNABAR_SEAGALLOP", "HIDE_CINNABAR_SEAGALLOP" }, + [108] = { "FLAG_HIDE_SAFFRON_FAN_CLUB_BLACK_BELT", "HIDE_SAFFRON_FAN_CLUB_BLACK_BELT" }, + [109] = { "FLAG_HIDE_SAFFRON_FAN_CLUB_ROCKER", "HIDE_SAFFRON_FAN_CLUB_ROCKER" }, + [110] = { "FLAG_HIDE_SAFFRON_FAN_CLUB_WOMAN", "HIDE_SAFFRON_FAN_CLUB_WOMAN" }, + [111] = { "FLAG_HIDE_SAFFRON_FAN_CLUB_BEAUTY", "HIDE_SAFFRON_FAN_CLUB_BEAUTY" }, + [112] = { "FLAG_HIDE_MG_DELIVERYMEN", "HIDE_MG_DELIVERYMEN" }, + [113] = { "FLAG_HIDE_ONE_ISLAND_BILL", "HIDE_ONE_ISLAND_BILL" }, + [114] = { "FLAG_HIDE_ONE_ISLAND_POKECENTER_BILL", "HIDE_ONE_ISLAND_POKECENTER_BILL" }, + [115] = { "FLAG_HIDE_ONE_ISLAND_POKECENTER_CELIO", "HIDE_ONE_ISLAND_POKECENTER_CELIO" }, + [116] = { "FLAG_HIDE_TWO_ISLAND_GAME_CORNER_BIKER", "HIDE_TWO_ISLAND_GAME_CORNER_BIKER" }, + [117] = { "FLAG_HIDE_TWO_ISLAND_GAME_CORNER_LOSTELLE", "HIDE_TWO_ISLAND_GAME_CORNER_LOSTELLE" }, + [118] = { "FLAG_HIDE_LOSTELLE_IN_HER_HOME", "HIDE_LOSTELLE_IN_HER_HOME" }, + [119] = { "FLAG_0x077", "0x077" }, + [120] = { "FLAG_0x078", "0x078" }, + [121] = { "FLAG_HIDE_THREE_ISLAND_BIKERS", "HIDE_THREE_ISLAND_BIKERS" }, + [122] = { "FLAG_HIDE_LOSTELLE_IN_BERRY_FOREST", "HIDE_LOSTELLE_IN_BERRY_FOREST" }, + [123] = { "FLAG_HIDE_TWO_ISLAND_WOMAN", "HIDE_TWO_ISLAND_WOMAN" }, + [124] = { "FLAG_HIDE_TWO_ISLAND_BEAUTY", "HIDE_TWO_ISLAND_BEAUTY" }, + [125] = { "FLAG_HIDE_TWO_ISLAND_POKE_MANIAC", "HIDE_TWO_ISLAND_POKE_MANIAC" }, + [126] = { "FLAG_HIDE_THREE_ISLAND_ANTIBIKERS", "HIDE_THREE_ISLAND_ANTIBIKERS" }, + [127] = { "FLAG_0x07F", "0x07F" }, + [128] = { "FLAG_HIDE_ROUTE_16_SNORLAX", "HIDE_ROUTE_16_SNORLAX" }, + [129] = { "FLAG_HIDE_MEWTWO", "HIDE_MEWTWO" }, + [130] = { "FLAG_HIDE_ARTICUNO", "HIDE_ARTICUNO" }, + [131] = { "FLAG_HIDE_TOWER_ROCKET_2", "HIDE_TOWER_ROCKET_2" }, + [132] = { "FLAG_HIDE_TOWER_ROCKET_3", "HIDE_TOWER_ROCKET_3" }, + [133] = { "FLAG_HIDE_POWER_PLANT_ELECTRODE_1", "HIDE_POWER_PLANT_ELECTRODE_1" }, + [134] = { "FLAG_HIDE_POWER_PLANT_ELECTRODE_2", "HIDE_POWER_PLANT_ELECTRODE_2" }, + [135] = { "FLAG_HIDE_SS_ANNE", "HIDE_SS_ANNE" }, + [136] = { "FLAG_HIDE_FIVE_ISLAND_ROCKETS", "HIDE_FIVE_ISLAND_ROCKETS" }, + [137] = { "FLAG_HIDE_MT_EMBER_EXTERIOR_ROCKETS", "HIDE_MT_EMBER_EXTERIOR_ROCKETS" }, + [138] = { "FLAG_HIDE_RUBY", "HIDE_RUBY" }, + [139] = { "FLAG_HIDE_ICEFALL_CAVE_LORELEI", "HIDE_ICEFALL_CAVE_LORELEI" }, + [140] = { "FLAG_HIDE_LORELEI_IN_HER_HOUSE", "HIDE_LORELEI_IN_HER_HOUSE" }, + [141] = { "FLAG_HIDE_ICEFALL_CAVE_ROCKETS", "HIDE_ICEFALL_CAVE_ROCKETS" }, + [142] = { "FLAG_HIDE_RUIN_VALLEY_SCIENTIST", "HIDE_RUIN_VALLEY_SCIENTIST" }, + [143] = { "FLAG_HIDE_SAPPHIRE", "HIDE_SAPPHIRE" }, + [144] = { "FLAG_HIDE_DOTTED_HOLE_SCIENTIST", "HIDE_DOTTED_HOLE_SCIENTIST" }, + [145] = { "FLAG_HIDE_THREE_ISLAND_LONE_BIKER", "HIDE_THREE_ISLAND_LONE_BIKER" }, + [146] = { "FLAG_HIDE_PEWTER_CITY_RUNNING_SHOES_GUY", "HIDE_PEWTER_CITY_RUNNING_SHOES_GUY" }, + [147] = { "FLAG_HIDE_LOST_CAVE_SELPHY", "HIDE_LOST_CAVE_SELPHY" }, + [148] = { "FLAG_HIDE_RESORT_GORGEOUS_SELPHY", "HIDE_RESORT_GORGEOUS_SELPHY" }, + [149] = { "FLAG_HIDE_RESORT_GORGEOUS_INSIDE_SELPHY", "HIDE_RESORT_GORGEOUS_INSIDE_SELPHY" }, + [150] = { "FLAG_HIDE_SELPHYS_BUTLER", "HIDE_SELPHYS_BUTLER" }, + [151] = { "FLAG_HIDE_FOUR_ISLAND_RIVAL", "HIDE_FOUR_ISLAND_RIVAL" }, + [152] = { "FLAG_HIDE_SIX_ISLAND_POKECENTER_RIVAL", "HIDE_SIX_ISLAND_POKECENTER_RIVAL" }, + [153] = { "FLAG_HIDE_DEOXYS", "HIDE_DEOXYS" }, + [154] = { "FLAG_HIDE_BIRTH_ISLAND_METEORITE", "HIDE_BIRTH_ISLAND_METEORITE" }, + [155] = { "FLAG_HIDE_LUGIA", "HIDE_LUGIA" }, + [156] = { "FLAG_HIDE_HO_OH", "HIDE_HO_OH" }, + [157] = { "FLAG_HIDE_POSTGAME_GOSSIPERS", "HIDE_POSTGAME_GOSSIPERS" }, + [158] = { "FLAG_HIDE_FAME_CHECKER_ERIKA_JOURNALS", "HIDE_FAME_CHECKER_ERIKA_JOURNALS" }, + [159] = { "FLAG_HIDE_FAME_CHECKER_KOGA_JOURNAL", "HIDE_FAME_CHECKER_KOGA_JOURNAL" }, + [160] = { "FLAG_HIDE_FAME_CHECKER_LT_SURGE_JOURNAL", "HIDE_FAME_CHECKER_LT_SURGE_JOURNAL" }, + [161] = { "FLAG_HIDE_VERMILION_CITY_OAKS_AIDE", "HIDE_VERMILION_CITY_OAKS_AIDE" }, + [162] = { "FLAG_HIDE_CINNABAR_POKECENTER_BILL", "HIDE_CINNABAR_POKECENTER_BILL" }, + [163] = { "FLAG_HIDE_CREDITS_RIVAL", "HIDE_CREDITS_RIVAL" }, + [164] = { "FLAG_HIDE_CREDITS_OAK", "HIDE_CREDITS_OAK" }, + [165] = { "FLAG_HIDE_LORELEI_HOUSE_MEOWTH_DOLL", "HIDE_LORELEI_HOUSE_MEOWTH_DOLL" }, + [166] = { "FLAG_HIDE_LORELEI_HOUSE_CHANSEY_DOLL", "HIDE_LORELEI_HOUSE_CHANSEY_DOLL" }, + [167] = { "FLAG_HIDE_LORELEIS_HOUSE_NIDORAN_F_DOLL", "HIDE_LORELEIS_HOUSE_NIDORAN_F_DOLL" }, + [168] = { "FLAG_HIDE_LORELEI_HOUSE_JIGGLYPUFF_DOLL", "HIDE_LORELEI_HOUSE_JIGGLYPUFF_DOLL" }, + [169] = { "FLAG_HIDE_LORELEIS_HOUSE_NIDORAN_M_DOLL", "HIDE_LORELEIS_HOUSE_NIDORAN_M_DOLL" }, + [170] = { "FLAG_HIDE_LORELEIS_HOUSE_FEAROW_DOLL", "HIDE_LORELEIS_HOUSE_FEAROW_DOLL" }, + [171] = { "FLAG_HIDE_LORELEIS_HOUSE_PIDGEOT_DOLL", "HIDE_LORELEIS_HOUSE_PIDGEOT_DOLL" }, + [172] = { "FLAG_HIDE_LORELEIS_HOUSE_LAPRAS_DOLL", "HIDE_LORELEIS_HOUSE_LAPRAS_DOLL" }, + [173] = { "FLAG_HIDE_MISC_KANTO_ROCKETS", "HIDE_MISC_KANTO_ROCKETS" }, + [174] = { "FLAG_HIDE_SAFFRON_CITY_POKECENTER_SABRINA_JOURNALS", "HIDE_SAFFRON_CITY_POKECENTER_SABRINA_JOURNALS" }, + [175] = { "FLAG_0x0AF", "0x0AF" }, + [176] = { "FLAG_0x0B0", "0x0B0" }, + [177] = { "FLAG_0x0B1", "0x0B1" }, + [178] = { "FLAG_0x0B2", "0x0B2" }, + [179] = { "FLAG_0x0B3", "0x0B3" }, + [180] = { "FLAG_0x0B4", "0x0B4" }, + [181] = { "FLAG_0x0B5", "0x0B5" }, + [182] = { "FLAG_0x0B6", "0x0B6" }, + [183] = { "FLAG_0x0B7", "0x0B7" }, + [184] = { "FLAG_0x0B8", "0x0B8" }, + [185] = { "FLAG_0x0B9", "0x0B9" }, + [186] = { "FLAG_0x0BA", "0x0BA" }, + [187] = { "FLAG_0x0BB", "0x0BB" }, + [188] = { "FLAG_0x0BC", "0x0BC" }, + [189] = { "FLAG_0x0BD", "0x0BD" }, + [190] = { "FLAG_0x0BE", "0x0BE" }, + [191] = { "FLAG_0x0BF", "0x0BF" }, + [192] = { "FLAG_0x0C0", "0x0C0" }, + [193] = { "FLAG_0x0C1", "0x0C1" }, + [194] = { "FLAG_0x0C2", "0x0C2" }, + [195] = { "FLAG_0x0C3", "0x0C3" }, + [196] = { "FLAG_0x0C4", "0x0C4" }, + [197] = { "FLAG_0x0C5", "0x0C5" }, + [198] = { "FLAG_0x0C6", "0x0C6" }, + [199] = { "FLAG_0x0C7", "0x0C7" }, + [200] = { "FLAG_0x0C8", "0x0C8" }, + [201] = { "FLAG_0x0C9", "0x0C9" }, + [202] = { "FLAG_0x0CA", "0x0CA" }, + [203] = { "FLAG_0x0CB", "0x0CB" }, + [204] = { "FLAG_0x0CC", "0x0CC" }, + [205] = { "FLAG_0x0CD", "0x0CD" }, + [206] = { "FLAG_0x0CE", "0x0CE" }, + [207] = { "FLAG_0x0CF", "0x0CF" }, + [208] = { "FLAG_0x0D0", "0x0D0" }, + [209] = { "FLAG_0x0D1", "0x0D1" }, + [210] = { "FLAG_0x0D2", "0x0D2" }, + [211] = { "FLAG_0x0D3", "0x0D3" }, + [212] = { "FLAG_0x0D4", "0x0D4" }, + [213] = { "FLAG_0x0D5", "0x0D5" }, + [214] = { "FLAG_0x0D6", "0x0D6" }, + [215] = { "FLAG_0x0D7", "0x0D7" }, + [216] = { "FLAG_0x0D8", "0x0D8" }, + [217] = { "FLAG_0x0D9", "0x0D9" }, + [218] = { "FLAG_0x0DA", "0x0DA" }, + [219] = { "FLAG_0x0DB", "0x0DB" }, + [220] = { "FLAG_0x0DC", "0x0DC" }, + [221] = { "FLAG_0x0DD", "0x0DD" }, + [222] = { "FLAG_0x0DE", "0x0DE" }, + [223] = { "FLAG_0x0DF", "0x0DF" }, + [224] = { "FLAG_0x0E0", "0x0E0" }, + [225] = { "FLAG_0x0E1", "0x0E1" }, + [226] = { "FLAG_0x0E2", "0x0E2" }, + [227] = { "FLAG_0x0E3", "0x0E3" }, + [228] = { "FLAG_0x0E4", "0x0E4" }, + [229] = { "FLAG_0x0E5", "0x0E5" }, + [230] = { "FLAG_0x0E6", "0x0E6" }, + [231] = { "FLAG_0x0E7", "0x0E7" }, + [232] = { "FLAG_0x0E8", "0x0E8" }, + [233] = { "FLAG_0x0E9", "0x0E9" }, + [234] = { "FLAG_0x0EA", "0x0EA" }, + [235] = { "FLAG_0x0EB", "0x0EB" }, + [236] = { "FLAG_0x0EC", "0x0EC" }, + [237] = { "FLAG_0x0ED", "0x0ED" }, + [238] = { "FLAG_0x0EE", "0x0EE" }, + [239] = { "FLAG_0x0EF", "0x0EF" }, + [240] = { "FLAG_0x0F0", "0x0F0" }, + [241] = { "FLAG_0x0F1", "0x0F1" }, + [242] = { "FLAG_0x0F2", "0x0F2" }, + [243] = { "FLAG_0x0F3", "0x0F3" }, + [244] = { "FLAG_0x0F4", "0x0F4" }, + [245] = { "FLAG_0x0F5", "0x0F5" }, + [246] = { "FLAG_0x0F6", "0x0F6" }, + [247] = { "FLAG_0x0F7", "0x0F7" }, + [248] = { "FLAG_0x0F8", "0x0F8" }, + [249] = { "FLAG_0x0F9", "0x0F9" }, + [250] = { "FLAG_0x0FA", "0x0FA" }, + [251] = { "FLAG_0x0FB", "0x0FB" }, + [252] = { "FLAG_0x0FC", "0x0FC" }, + [253] = { "FLAG_0x0FD", "0x0FD" }, + [254] = { "FLAG_0x0FE", "0x0FE" }, + [255] = { "FLAG_0x0FF", "0x0FF" }, + [256] = { "FLAG_0x100", "0x100", "VARS_COUNT" }, + [257] = { "FLAG_0x101", "0x101" }, + [258] = { "FLAG_0x102", "0x102" }, + [259] = { "FLAG_0x103", "0x103" }, + [260] = { "FLAG_0x104", "0x104" }, + [261] = { "FLAG_0x105", "0x105" }, + [262] = { "FLAG_0x106", "0x106" }, + [263] = { "FLAG_0x107", "0x107" }, + [264] = { "FLAG_0x108", "0x108" }, + [265] = { "FLAG_0x109", "0x109" }, + [266] = { "FLAG_0x10A", "0x10A" }, + [267] = { "FLAG_0x10B", "0x10B" }, + [268] = { "FLAG_0x10C", "0x10C" }, + [269] = { "FLAG_0x10D", "0x10D" }, + [270] = { "FLAG_0x10E", "0x10E" }, + [271] = { "FLAG_0x10F", "0x10F" }, + [272] = { "FLAG_0x110", "0x110" }, + [273] = { "FLAG_0x111", "0x111" }, + [274] = { "FLAG_0x112", "0x112" }, + [275] = { "FLAG_0x113", "0x113" }, + [276] = { "FLAG_0x114", "0x114" }, + [277] = { "FLAG_0x115", "0x115" }, + [278] = { "FLAG_0x116", "0x116" }, + [279] = { "FLAG_0x117", "0x117" }, + [280] = { "FLAG_0x118", "0x118" }, + [281] = { "FLAG_0x119", "0x119" }, + [282] = { "FLAG_0x11A", "0x11A" }, + [283] = { "FLAG_0x11B", "0x11B" }, + [284] = { "FLAG_0x11C", "0x11C" }, + [285] = { "FLAG_0x11D", "0x11D" }, + [286] = { "FLAG_0x11E", "0x11E" }, + [287] = { "FLAG_0x11F", "0x11F" }, + [288] = { "FLAG_0x120", "0x120" }, + [289] = { "FLAG_0x121", "0x121" }, + [290] = { "FLAG_0x122", "0x122" }, + [291] = { "FLAG_0x123", "0x123" }, + [292] = { "FLAG_0x124", "0x124" }, + [293] = { "FLAG_0x125", "0x125" }, + [294] = { "FLAG_0x126", "0x126" }, + [295] = { "FLAG_0x127", "0x127" }, + [296] = { "FLAG_0x128", "0x128" }, + [297] = { "FLAG_0x129", "0x129" }, + [298] = { "FLAG_0x12A", "0x12A" }, + [299] = { "FLAG_0x12B", "0x12B" }, + [300] = { "FLAG_0x12C", "0x12C" }, + [301] = { "FLAG_0x12D", "0x12D" }, + [302] = { "FLAG_0x12E", "0x12E" }, + [303] = { "FLAG_0x12F", "0x12F" }, + [304] = { "FLAG_0x130", "0x130" }, + [305] = { "FLAG_0x131", "0x131" }, + [306] = { "FLAG_0x132", "0x132" }, + [307] = { "FLAG_0x133", "0x133" }, + [308] = { "FLAG_0x134", "0x134" }, + [309] = { "FLAG_0x135", "0x135" }, + [310] = { "FLAG_0x136", "0x136" }, + [311] = { "FLAG_0x137", "0x137" }, + [312] = { "FLAG_0x138", "0x138" }, + [313] = { "FLAG_0x139", "0x139" }, + [314] = { "FLAG_0x13A", "0x13A" }, + [315] = { "FLAG_0x13B", "0x13B" }, + [316] = { "FLAG_0x13C", "0x13C" }, + [317] = { "FLAG_0x13D", "0x13D" }, + [318] = { "FLAG_0x13E", "0x13E" }, + [319] = { "FLAG_0x13F", "0x13F" }, + [320] = { "FLAG_0x140", "0x140" }, + [321] = { "FLAG_0x141", "0x141" }, + [322] = { "FLAG_0x142", "0x142" }, + [323] = { "FLAG_0x143", "0x143" }, + [324] = { "FLAG_0x144", "0x144" }, + [325] = { "FLAG_0x145", "0x145" }, + [326] = { "FLAG_0x146", "0x146" }, + [327] = { "FLAG_0x147", "0x147" }, + [328] = { "FLAG_0x148", "0x148" }, + [329] = { "FLAG_0x149", "0x149" }, + [330] = { "FLAG_0x14A", "0x14A" }, + [331] = { "FLAG_0x14B", "0x14B" }, + [332] = { "FLAG_0x14C", "0x14C" }, + [333] = { "FLAG_0x14D", "0x14D" }, + [334] = { "FLAG_0x14E", "0x14E" }, + [335] = { "FLAG_0x14F", "0x14F" }, + [336] = { "FLAG_0x150", "0x150" }, + [337] = { "FLAG_0x151", "0x151" }, + [338] = { "FLAG_0x152", "0x152" }, + [339] = { "FLAG_0x153", "0x153" }, + [340] = { "FLAG_HIDE_ROUTE2_ETHER", "HIDE_ROUTE2_ETHER" }, + [341] = { "FLAG_HIDE_ROUTE2_PARALYZE_HEAL", "HIDE_ROUTE2_PARALYZE_HEAL" }, + [342] = { "FLAG_HIDE_VIRIDIAN_FOREST_POKE_BALL", "HIDE_VIRIDIAN_FOREST_POKE_BALL" }, + [343] = { "FLAG_HIDE_VIRIDIAN_FOREST_ANTIDOTE", "HIDE_VIRIDIAN_FOREST_ANTIDOTE" }, + [344] = { "FLAG_HIDE_VIRIDIAN_FOREST_POTION", "HIDE_VIRIDIAN_FOREST_POTION" }, + [345] = { "FLAG_HIDE_MT_MOON_1F_PARALYZE_HEAL", "HIDE_MT_MOON_1F_PARALYZE_HEAL" }, + [346] = { "FLAG_HIDE_MT_MOON_1F_TM09", "HIDE_MT_MOON_1F_TM09" }, + [347] = { "FLAG_HIDE_MT_MOON_1F_POTION", "HIDE_MT_MOON_1F_POTION" }, + [348] = { "FLAG_HIDE_MT_MOON_1F_RARE_CANDY", "HIDE_MT_MOON_1F_RARE_CANDY" }, + [349] = { "FLAG_HIDE_MT_MOON_1F_ESCAPE_ROPE", "HIDE_MT_MOON_1F_ESCAPE_ROPE" }, + [350] = { "FLAG_HIDE_MT_MOON_1F_MOON_STONE", "HIDE_MT_MOON_1F_MOON_STONE" }, + [351] = { "FLAG_HIDE_MT_MOON_B2F_STAR_PIECE", "HIDE_MT_MOON_B2F_STAR_PIECE" }, + [352] = { "FLAG_HIDE_MT_MOON_B2F_TM46", "HIDE_MT_MOON_B2F_TM46" }, + [353] = { "FLAG_HIDE_ROUTE4_TM05", "HIDE_ROUTE4_TM05" }, + [354] = { "FLAG_HIDE_ROUTE24_TM45", "HIDE_ROUTE24_TM45" }, + [355] = { "FLAG_HIDE_ROUTE25_TM43", "HIDE_ROUTE25_TM43" }, + [356] = { "FLAG_HIDE_SSANNE_1F_ROOM2_TM31", "HIDE_SSANNE_1F_ROOM2_TM31" }, + [357] = { "FLAG_HIDE_SSANNE_2F_ROOM2_STARDUST", "HIDE_SSANNE_2F_ROOM2_STARDUST" }, + [358] = { "FLAG_HIDE_SSANNE_2F_ROOM4_X_ATTACK", "HIDE_SSANNE_2F_ROOM4_X_ATTACK" }, + [359] = { "FLAG_HIDE_SSANNE_B1F_ROOM2_TM44", "HIDE_SSANNE_B1F_ROOM2_TM44" }, + [360] = { "FLAG_HIDE_SSANNE_B1F_ROOM3_ETHER", "HIDE_SSANNE_B1F_ROOM3_ETHER" }, + [361] = { "FLAG_HIDE_SSANNE_B1F_ROOM5_SUPER_POTION", "HIDE_SSANNE_B1F_ROOM5_SUPER_POTION" }, + [362] = { "FLAG_HIDE_SSANNE_KITCHEN_GREAT_BALL", "HIDE_SSANNE_KITCHEN_GREAT_BALL" }, + [363] = { "FLAG_HIDE_ROUTE9_TM40", "HIDE_ROUTE9_TM40" }, + [364] = { "FLAG_HIDE_ROCKET_HIDEOUT_B1F_ESCAPE_ROPE", "HIDE_ROCKET_HIDEOUT_B1F_ESCAPE_ROPE" }, + [365] = { "FLAG_HIDE_ROCKET_HIDEOUT_B1F_HYPER_POTION", "HIDE_ROCKET_HIDEOUT_B1F_HYPER_POTION" }, + [366] = { "FLAG_HIDE_ROCKET_HIDEOUT_B2F_X_SPEED", "HIDE_ROCKET_HIDEOUT_B2F_X_SPEED" }, + [367] = { "FLAG_HIDE_ROCKET_HIDEOUT_B2F_MOON_STONE", "HIDE_ROCKET_HIDEOUT_B2F_MOON_STONE" }, + [368] = { "FLAG_HIDE_ROCKET_HIDEOUT_B2F_TM12", "HIDE_ROCKET_HIDEOUT_B2F_TM12" }, + [369] = { "FLAG_HIDE_ROCKET_HIDEOUT_B2F_SUPER_POTION", "HIDE_ROCKET_HIDEOUT_B2F_SUPER_POTION" }, + [370] = { "FLAG_HIDE_ROCKET_HIDEOUT_B3F_RARE_CANDY", "HIDE_ROCKET_HIDEOUT_B3F_RARE_CANDY" }, + [371] = { "FLAG_HIDE_ROCKET_HIDEOUT_B3F_TM21", "HIDE_ROCKET_HIDEOUT_B3F_TM21" }, + [372] = { "FLAG_HIDE_ROCKET_HIDEOUT_B4F_TM49", "HIDE_ROCKET_HIDEOUT_B4F_TM49" }, + [373] = { "FLAG_HIDE_ROCKET_HIDEOUT_B4F_MAX_ETHER", "HIDE_ROCKET_HIDEOUT_B4F_MAX_ETHER" }, + [374] = { "FLAG_HIDE_ROCKET_HIDEOUT_B4F_CALCIUM", "HIDE_ROCKET_HIDEOUT_B4F_CALCIUM" }, + [375] = { "FLAG_HIDE_POKEMON_TOWER_3F_ESCAPE_ROPE", "HIDE_POKEMON_TOWER_3F_ESCAPE_ROPE" }, + [376] = { "FLAG_HIDE_POKEMON_TOWER_4F_ELIXIR", "HIDE_POKEMON_TOWER_4F_ELIXIR" }, + [377] = { "FLAG_HIDE_POKEMON_TOWER_4F_AWAKENING", "HIDE_POKEMON_TOWER_4F_AWAKENING" }, + [378] = { "FLAG_HIDE_POKEMON_TOWER_4F_GREAT_BALL", "HIDE_POKEMON_TOWER_4F_GREAT_BALL" }, + [379] = { "FLAG_HIDE_POKEMON_TOWER_5F_NUGGET", "HIDE_POKEMON_TOWER_5F_NUGGET" }, + [380] = { "FLAG_HIDE_POKEMON_TOWER_6F_RARE_CANDY", "HIDE_POKEMON_TOWER_6F_RARE_CANDY" }, + [381] = { "FLAG_HIDE_POKEMON_TOWER_6F_X_ACCURACY", "HIDE_POKEMON_TOWER_6F_X_ACCURACY" }, + [382] = { "FLAG_HIDE_ROUTE12_TM48", "HIDE_ROUTE12_TM48" }, + [383] = { "FLAG_HIDE_ROUTE12_IRON", "HIDE_ROUTE12_IRON" }, + [384] = { "FLAG_HIDE_ROUTE15_TM18", "HIDE_ROUTE15_TM18" }, + [385] = { "FLAG_HIDE_SAFARI_ZONE_CENTER_NUGGET", "HIDE_SAFARI_ZONE_CENTER_NUGGET" }, + [386] = { "FLAG_HIDE_SAFARI_ZONE_EAST_MAX_POTION", "HIDE_SAFARI_ZONE_EAST_MAX_POTION" }, + [387] = { "FLAG_HIDE_SAFARI_ZONE_EAST_FULL_RESTORE", "HIDE_SAFARI_ZONE_EAST_FULL_RESTORE" }, + [388] = { "FLAG_HIDE_SAFARI_ZONE_EAST_TM11", "HIDE_SAFARI_ZONE_EAST_TM11" }, + [389] = { "FLAG_HIDE_SAFARI_ZONE_EAST_LEAF_STONE", "HIDE_SAFARI_ZONE_EAST_LEAF_STONE" }, + [390] = { "FLAG_HIDE_SAFARI_ZONE_NORTH_PROTEIN", "HIDE_SAFARI_ZONE_NORTH_PROTEIN" }, + [391] = { "FLAG_HIDE_SAFARI_ZONE_NORTH_TM47", "HIDE_SAFARI_ZONE_NORTH_TM47" }, + [392] = { "FLAG_HIDE_SAFARI_ZONE_WEST_TM32", "HIDE_SAFARI_ZONE_WEST_TM32" }, + [393] = { "FLAG_HIDE_SAFARI_ZONE_WEST_GOLD_TEETH", "HIDE_SAFARI_ZONE_WEST_GOLD_TEETH" }, + [394] = { "FLAG_HIDE_SAFARI_ZONE_WEST_MAX_POTION", "HIDE_SAFARI_ZONE_WEST_MAX_POTION" }, + [395] = { "FLAG_HIDE_SAFARI_ZONE_WEST_MAX_REVIVE", "HIDE_SAFARI_ZONE_WEST_MAX_REVIVE" }, + [396] = { "FLAG_HIDE_SILPH_CO_3F_HYPER_POTION", "HIDE_SILPH_CO_3F_HYPER_POTION" }, + [397] = { "FLAG_HIDE_SILPH_CO_4F_MAX_REVIVE", "HIDE_SILPH_CO_4F_MAX_REVIVE" }, + [398] = { "FLAG_HIDE_SILPH_CO_4F_ESCAPE_ROPE", "HIDE_SILPH_CO_4F_ESCAPE_ROPE" }, + [399] = { "FLAG_HIDE_SILPH_CO_4F_FULL_HEAL", "HIDE_SILPH_CO_4F_FULL_HEAL" }, + [400] = { "FLAG_HIDE_SILPH_CO_5F_PROTEIN", "HIDE_SILPH_CO_5F_PROTEIN" }, + [401] = { "FLAG_HIDE_SILPH_CO_5F_TM01", "HIDE_SILPH_CO_5F_TM01" }, + [402] = { "FLAG_HIDE_SILPH_CO_5F_CARD_KEY", "HIDE_SILPH_CO_5F_CARD_KEY" }, + [403] = { "FLAG_HIDE_SILPH_CO_6F_HP_UP", "HIDE_SILPH_CO_6F_HP_UP" }, + [404] = { "FLAG_HIDE_SILPH_CO_6F_X_SPECIAL", "HIDE_SILPH_CO_6F_X_SPECIAL" }, + [405] = { "FLAG_HIDE_SILPH_CO_7F_CALCIUM", "HIDE_SILPH_CO_7F_CALCIUM" }, + [406] = { "FLAG_HIDE_SILPH_CO_7F_TM08", "HIDE_SILPH_CO_7F_TM08" }, + [407] = { "FLAG_HIDE_SILPH_CO_10F_CARBOS", "HIDE_SILPH_CO_10F_CARBOS" }, + [408] = { "FLAG_HIDE_SILPH_CO_10F_ULTRA_BALL", "HIDE_SILPH_CO_10F_ULTRA_BALL" }, + [409] = { "FLAG_HIDE_SILPH_CO_10F_RARE_CANDY", "HIDE_SILPH_CO_10F_RARE_CANDY" }, + [410] = { "FLAG_HIDE_POWER_PLANT_MAX_POTION", "HIDE_POWER_PLANT_MAX_POTION" }, + [411] = { "FLAG_HIDE_POWER_PLANT_TM17", "HIDE_POWER_PLANT_TM17" }, + [412] = { "FLAG_HIDE_POWER_PLANT_TM25", "HIDE_POWER_PLANT_TM25" }, + [413] = { "FLAG_HIDE_POWER_PLANT_THUNDER_STONE", "HIDE_POWER_PLANT_THUNDER_STONE" }, + [414] = { "FLAG_HIDE_POWER_PLANT_ELIXIR", "HIDE_POWER_PLANT_ELIXIR" }, + [415] = { "FLAG_HIDE_POKEMON_MANSION_1F_CARBOS", "HIDE_POKEMON_MANSION_1F_CARBOS" }, + [416] = { "FLAG_HIDE_POKEMON_MANSION_1F_ESCAPE_ROPE", "HIDE_POKEMON_MANSION_1F_ESCAPE_ROPE" }, + [417] = { "FLAG_HIDE_POKEMON_MANSION_2F_CALCIUM", "HIDE_POKEMON_MANSION_2F_CALCIUM" }, + [418] = { "FLAG_HIDE_POKEMON_MANSION_3F_MAX_POTION", "HIDE_POKEMON_MANSION_3F_MAX_POTION" }, + [419] = { "FLAG_HIDE_POKEMON_MANSION_3F_IRON", "HIDE_POKEMON_MANSION_3F_IRON" }, + [420] = { "FLAG_HIDE_POKEMON_MANSION_B1F_TM14", "HIDE_POKEMON_MANSION_B1F_TM14" }, + [421] = { "FLAG_HIDE_POKEMON_MANSION_B1F_FULL_RESTORE", "HIDE_POKEMON_MANSION_B1F_FULL_RESTORE" }, + [422] = { "FLAG_0x1A6", "0x1A6" }, + [423] = { "FLAG_HIDE_POKEMON_MANSION_B1F_TM22", "HIDE_POKEMON_MANSION_B1F_TM22" }, + [424] = { "FLAG_HIDE_POKEMON_MANSION_B1F_SECRET_KEY", "HIDE_POKEMON_MANSION_B1F_SECRET_KEY" }, + [425] = { "FLAG_HIDE_VICTORY_ROAD_1F_RARE_CANDY", "HIDE_VICTORY_ROAD_1F_RARE_CANDY" }, + [426] = { "FLAG_HIDE_VICTORY_ROAD_1F_TM02", "HIDE_VICTORY_ROAD_1F_TM02" }, + [427] = { "FLAG_HIDE_VICTORY_ROAD_2F_GUARD_SPEC", "HIDE_VICTORY_ROAD_2F_GUARD_SPEC" }, + [428] = { "FLAG_HIDE_VICTORY_ROAD_2F_TM07", "HIDE_VICTORY_ROAD_2F_TM07" }, + [429] = { "FLAG_HIDE_VICTORY_ROAD_2F_FULL_HEAL", "HIDE_VICTORY_ROAD_2F_FULL_HEAL" }, + [430] = { "FLAG_HIDE_VICTORY_ROAD_2F_TM37", "HIDE_VICTORY_ROAD_2F_TM37" }, + [431] = { "FLAG_HIDE_VICTORY_ROAD_3F_MAX_REVIVE", "HIDE_VICTORY_ROAD_3F_MAX_REVIVE" }, + [432] = { "FLAG_HIDE_VICTORY_ROAD_3F_TM50", "HIDE_VICTORY_ROAD_3F_TM50" }, + [433] = { "FLAG_HIDE_CERULEAN_CAVE_1F_MAX_ELIXIR", "HIDE_CERULEAN_CAVE_1F_MAX_ELIXIR" }, + [434] = { "FLAG_HIDE_CERULEAN_CAVE_1F_NUGGET", "HIDE_CERULEAN_CAVE_1F_NUGGET" }, + [435] = { "FLAG_HIDE_CERULEAN_CAVE_1F_FULL_RESTORE", "HIDE_CERULEAN_CAVE_1F_FULL_RESTORE" }, + [436] = { "FLAG_HIDE_CERULEAN_CAVE_2F_FULL_RESTORE", "HIDE_CERULEAN_CAVE_2F_FULL_RESTORE" }, + [437] = { "FLAG_HIDE_CERULEAN_CAVE_2F_PP_UP", "HIDE_CERULEAN_CAVE_2F_PP_UP" }, + [438] = { "FLAG_HIDE_CERULEAN_CAVE_2F_ULTRA_BALL", "HIDE_CERULEAN_CAVE_2F_ULTRA_BALL" }, + [439] = { "FLAG_HIDE_CERULEAN_CAVE_B1F_MAX_REVIVE", "HIDE_CERULEAN_CAVE_B1F_MAX_REVIVE" }, + [440] = { "FLAG_HIDE_CERULEAN_CAVE_B1F_ULTRA_BALL", "HIDE_CERULEAN_CAVE_B1F_ULTRA_BALL" }, + [441] = { "FLAG_HIDE_FUCHSIA_CITY_WARDENS_HOUSE_RARE_CANDY", "HIDE_FUCHSIA_CITY_WARDENS_HOUSE_RARE_CANDY" }, + [442] = { "FLAG_HIDE_TWO_ISLAND_REVIVE", "HIDE_TWO_ISLAND_REVIVE" }, + [443] = { "FLAG_HIDE_THREE_ISLAND_ZINC", "HIDE_THREE_ISLAND_ZINC" }, + [444] = { "FLAG_0x1BC", "0x1BC" }, + [445] = { "FLAG_0x1BD", "0x1BD" }, + [446] = { "FLAG_HIDE_VIRIDIAN_FOREST_POTION_2", "HIDE_VIRIDIAN_FOREST_POTION_2" }, + [447] = { "FLAG_HIDE_MT_MOON_B2F_REVIVE", "HIDE_MT_MOON_B2F_REVIVE" }, + [448] = { "FLAG_HIDE_MT_MOON_B2F_ANTIDOTE", "HIDE_MT_MOON_B2F_ANTIDOTE" }, + [449] = { "FLAG_HIDE_ROUTE11_X_DEFEND", "HIDE_ROUTE11_X_DEFEND" }, + [450] = { "FLAG_HIDE_ROUTE9_BURN_HEAL", "HIDE_ROUTE9_BURN_HEAL" }, + [451] = { "FLAG_HIDE_ROCK_TUNNEL_1F_REPEL", "HIDE_ROCK_TUNNEL_1F_REPEL" }, + [452] = { "FLAG_HIDE_ROCK_TUNNEL_1F_PEARL", "HIDE_ROCK_TUNNEL_1F_PEARL" }, + [453] = { "FLAG_HIDE_ROCK_TUNNEL_1F_ESCAPE_ROPE", "HIDE_ROCK_TUNNEL_1F_ESCAPE_ROPE" }, + [454] = { "FLAG_HIDE_ROCK_TUNNEL_B1F_REVIVE", "HIDE_ROCK_TUNNEL_B1F_REVIVE" }, + [455] = { "FLAG_HIDE_ROCK_TUNNEL_B1F_MAX_ETHER", "HIDE_ROCK_TUNNEL_B1F_MAX_ETHER" }, + [456] = { "FLAG_HIDE_SILPH_CO_8F_IRON", "HIDE_SILPH_CO_8F_IRON" }, + [457] = { "FLAG_HIDE_SILPH_CO_11F_ZINC", "HIDE_SILPH_CO_11F_ZINC" }, + [458] = { "FLAG_HIDE_POKEMON_MANSION_1F_PROTEIN", "HIDE_POKEMON_MANSION_1F_PROTEIN" }, + [459] = { "FLAG_HIDE_POKEMON_MANSION_2F_ZINC", "HIDE_POKEMON_MANSION_2F_ZINC" }, + [460] = { "FLAG_HIDE_POKEMON_MANSION_2F_HP_UP", "HIDE_POKEMON_MANSION_2F_HP_UP" }, + [461] = { "FLAG_HIDE_VIRIDIAN_CITY_POTION", "HIDE_VIRIDIAN_CITY_POTION" }, + [462] = { "FLAG_HIDE_ROUTE11_GREAT_BALL", "HIDE_ROUTE11_GREAT_BALL" }, + [463] = { "FLAG_HIDE_ROUTE11_AWAKENING", "HIDE_ROUTE11_AWAKENING" }, + [464] = { "FLAG_HIDE_POKEMON_TOWER_5F_CLEANSE_TAG", "HIDE_POKEMON_TOWER_5F_CLEANSE_TAG" }, + [465] = { "FLAG_HIDE_CELADON_CITY_ETHER", "HIDE_CELADON_CITY_ETHER" }, + [466] = { "FLAG_HIDE_ROCKET_HIDEOUT_B3F_BLACK_GLASSES", "HIDE_ROCKET_HIDEOUT_B3F_BLACK_GLASSES" }, + [467] = { "FLAG_HIDE_SAFARI_ZONE_NORTH_QUICK_CLAW", "HIDE_SAFARI_ZONE_NORTH_QUICK_CLAW" }, + [468] = { "FLAG_HIDE_SEAFOAM_ISLANDS_1F_ICE_HEAL", "HIDE_SEAFOAM_ISLANDS_1F_ICE_HEAL" }, + [469] = { "FLAG_HIDE_SEAFOAM_ISLANDS_B1F_WATER_STONE", "HIDE_SEAFOAM_ISLANDS_B1F_WATER_STONE" }, + [470] = { "FLAG_HIDE_SEAFOAM_ISLANDS_B1F_REVIVE", "HIDE_SEAFOAM_ISLANDS_B1F_REVIVE" }, + [471] = { "FLAG_HIDE_SEAFOAM_ISLANDS_B2F_BIG_PEARL", "HIDE_SEAFOAM_ISLANDS_B2F_BIG_PEARL" }, + [472] = { "FLAG_HIDE_SEAFOAM_ISLANDS_B4F_ULTRA_BALL", "HIDE_SEAFOAM_ISLANDS_B4F_ULTRA_BALL" }, + [473] = { "FLAG_HIDE_FOUR_ISLAND_STAR_PIECE", "HIDE_FOUR_ISLAND_STAR_PIECE" }, + [474] = { "FLAG_HIDE_FOUR_ISLAND_STARDUST", "HIDE_FOUR_ISLAND_STARDUST" }, + [475] = { "FLAG_HIDE_ONE_ISLAND_KINDLE_ROAD_ETHER", "HIDE_ONE_ISLAND_KINDLE_ROAD_ETHER" }, + [476] = { "FLAG_HIDE_ONE_ISLAND_KINDLE_ROAD_MAX_REPEL", "HIDE_ONE_ISLAND_KINDLE_ROAD_MAX_REPEL" }, + [477] = { "FLAG_HIDE_ONE_ISLAND_KINDLE_ROAD_CARBOS", "HIDE_ONE_ISLAND_KINDLE_ROAD_CARBOS" }, + [478] = { "FLAG_HIDE_FIVE_ISLAND_MEADOW_MAX_POTION", "HIDE_FIVE_ISLAND_MEADOW_MAX_POTION" }, + [479] = { "FLAG_HIDE_FIVE_ISLAND_MEADOW_PP_UP", "HIDE_FIVE_ISLAND_MEADOW_PP_UP" }, + [480] = { "FLAG_HIDE_FIVE_ISLAND_MEMORIAL_PILLAR_METAL_COAT", "HIDE_FIVE_ISLAND_MEMORIAL_PILLAR_METAL_COAT" }, + [481] = { "FLAG_HIDE_SIX_ISLAND_OUTCAST_ISLAND_PP_UP", "HIDE_SIX_ISLAND_OUTCAST_ISLAND_PP_UP" }, + [482] = { "FLAG_HIDE_SIX_ISLAND_WATER_PATH_ELIXIR", "HIDE_SIX_ISLAND_WATER_PATH_ELIXIR" }, + [483] = { "FLAG_HIDE_SIX_ISLAND_WATER_PATH_DRAGON_SCALE", "HIDE_SIX_ISLAND_WATER_PATH_DRAGON_SCALE" }, + [484] = { "FLAG_HIDE_SIX_ISLAND_RUIN_VALLEY_FULL_RESTORE", "HIDE_SIX_ISLAND_RUIN_VALLEY_FULL_RESTORE" }, + [485] = { "FLAG_HIDE_SIX_ISLAND_RUIN_VALLEY_HP_UP", "HIDE_SIX_ISLAND_RUIN_VALLEY_HP_UP" }, + [486] = { "FLAG_HIDE_SIX_ISLAND_RUIN_VALLEY_SUN_STONE", "HIDE_SIX_ISLAND_RUIN_VALLEY_SUN_STONE" }, + [487] = { "FLAG_HIDE_SEVEN_ISLAND_SEVAULT_CANYON_KINGS_ROCK", "HIDE_SEVEN_ISLAND_SEVAULT_CANYON_KINGS_ROCK" }, + [488] = { "FLAG_HIDE_SEVEN_ISLAND_SEVAULT_CANYON_MAX_ELIXIR", "HIDE_SEVEN_ISLAND_SEVAULT_CANYON_MAX_ELIXIR" }, + [489] = { "FLAG_HIDE_SEVEN_ISLAND_SEVAULT_CANYON_NUGGET", "HIDE_SEVEN_ISLAND_SEVAULT_CANYON_NUGGET" }, + [490] = { "FLAG_HIDE_THREE_ISLAND_BERRY_FOREST_MAX_ETHER", "HIDE_THREE_ISLAND_BERRY_FOREST_MAX_ETHER" }, + [491] = { "FLAG_HIDE_THREE_ISLAND_BERRY_FOREST_FULL_HEAL", "HIDE_THREE_ISLAND_BERRY_FOREST_FULL_HEAL" }, + [492] = { "FLAG_HIDE_THREE_ISLAND_BERRY_FOREST_MAX_ELIXIR", "HIDE_THREE_ISLAND_BERRY_FOREST_MAX_ELIXIR" }, + [493] = { "FLAG_HIDE_MT_EMBER_EXTERIOR_ULTRA_BALL", "HIDE_MT_EMBER_EXTERIOR_ULTRA_BALL" }, + [494] = { "FLAG_HIDE_MT_EMBER_EXTERIOR_FIRE_STONE", "HIDE_MT_EMBER_EXTERIOR_FIRE_STONE" }, + [495] = { "FLAG_HIDE_MT_EMBER_EXTERIOR_DIRE_HIT", "HIDE_MT_EMBER_EXTERIOR_DIRE_HIT" }, + [496] = { "FLAG_HIDE_FOUR_ISLAND_ICEFALL_CAVE_1F_ULTRA_BALL", "HIDE_FOUR_ISLAND_ICEFALL_CAVE_1F_ULTRA_BALL" }, + [497] = { "FLAG_HIDE_FOUR_ISLAND_ICEFALL_CAVE_1F_HM07", "HIDE_FOUR_ISLAND_ICEFALL_CAVE_1F_HM07" }, + [498] = { "FLAG_HIDE_FOUR_ISLAND_ICEFALL_CAVE_B1F_FULL_RESTORE", "HIDE_FOUR_ISLAND_ICEFALL_CAVE_B1F_FULL_RESTORE" }, + [499] = { "FLAG_HIDE_FOUR_ISLAND_ICEFALL_CAVE_B1F_NEVER_MELT_ICE", "HIDE_FOUR_ISLAND_ICEFALL_CAVE_B1F_NEVER_MELT_ICE" }, + [500] = { "FLAG_HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_BIG_PEARL", "HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_BIG_PEARL" }, + [501] = { "FLAG_HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_TM36", "HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_TM36" }, + [502] = { "FLAG_HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_PEARL", "HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_PEARL" }, + [503] = { "FLAG_HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_UP_GRADE", "HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_UP_GRADE" }, + [504] = { "FLAG_HIDE_FIVE_ISLAND_LOST_CAVE_ROOM10_SILK_SCARF", "HIDE_FIVE_ISLAND_LOST_CAVE_ROOM10_SILK_SCARF" }, + [505] = { "FLAG_HIDE_FIVE_ISLAND_LOST_CAVE_ROOM11_LAX_INCENSE", "HIDE_FIVE_ISLAND_LOST_CAVE_ROOM11_LAX_INCENSE" }, + [506] = { "FLAG_HIDE_FIVE_ISLAND_LOST_CAVE_ROOM12_SEA_INCENSE", "HIDE_FIVE_ISLAND_LOST_CAVE_ROOM12_SEA_INCENSE" }, + [507] = { "FLAG_HIDE_FIVE_ISLAND_LOST_CAVE_ROOM13_MAX_REVIVE", "HIDE_FIVE_ISLAND_LOST_CAVE_ROOM13_MAX_REVIVE" }, + [508] = { "FLAG_HIDE_FIVE_ISLAND_LOST_CAVE_ROOM14_RARE_CANDY", "HIDE_FIVE_ISLAND_LOST_CAVE_ROOM14_RARE_CANDY" }, + [509] = { "FLAG_HIDE_SEVEN_ISLAND_SEVAULT_CANYON_HOUSE_LUCKY_PUNCH", "HIDE_SEVEN_ISLAND_SEVAULT_CANYON_HOUSE_LUCKY_PUNCH" }, + [510] = { "FLAG_HIDE_SILPH_CO_4F_TM41", "HIDE_SILPH_CO_4F_TM41" }, + [511] = { "FLAG_0x1FF", "0x1FF" }, + [512] = { "FLAG_0x200", "0x200" }, + [513] = { "FLAG_0x201", "0x201" }, + [514] = { "FLAG_0x202", "0x202" }, + [515] = { "FLAG_0x203", "0x203" }, + [516] = { "FLAG_0x204", "0x204" }, + [517] = { "FLAG_0x205", "0x205" }, + [518] = { "FLAG_0x206", "0x206" }, + [519] = { "FLAG_0x207", "0x207" }, + [520] = { "FLAG_0x208", "0x208" }, + [521] = { "FLAG_0x209", "0x209" }, + [522] = { "FLAG_0x20A", "0x20A" }, + [523] = { "FLAG_0x20B", "0x20B" }, + [524] = { "FLAG_0x20C", "0x20C" }, + [525] = { "FLAG_0x20D", "0x20D" }, + [526] = { "FLAG_0x20E", "0x20E" }, + [527] = { "FLAG_0x20F", "0x20F" }, + [528] = { "FLAG_0x210", "0x210" }, + [529] = { "FLAG_0x211", "0x211" }, + [530] = { "FLAG_0x212", "0x212" }, + [531] = { "FLAG_0x213", "0x213" }, + [532] = { "FLAG_0x214", "0x214" }, + [533] = { "FLAG_0x215", "0x215" }, + [534] = { "FLAG_0x216", "0x216" }, + [535] = { "FLAG_0x217", "0x217" }, + [536] = { "FLAG_0x218", "0x218" }, + [537] = { "FLAG_0x219", "0x219" }, + [538] = { "FLAG_0x21A", "0x21A" }, + [539] = { "FLAG_0x21B", "0x21B" }, + [540] = { "FLAG_0x21C", "0x21C" }, + [541] = { "FLAG_0x21D", "0x21D" }, + [542] = { "FLAG_0x21E", "0x21E" }, + [543] = { "FLAG_0x21F", "0x21F" }, + [544] = { "FLAG_0x220", "0x220" }, + [545] = { "FLAG_0x221", "0x221" }, + [546] = { "FLAG_0x222", "0x222" }, + [547] = { "FLAG_0x223", "0x223" }, + [548] = { "FLAG_0x224", "0x224" }, + [549] = { "FLAG_0x225", "0x225" }, + [550] = { "FLAG_0x226", "0x226" }, + [551] = { "FLAG_0x227", "0x227" }, + [552] = { "FLAG_0x228", "0x228" }, + [553] = { "FLAG_0x229", "0x229" }, + [554] = { "FLAG_0x22A", "0x22A" }, + [555] = { "FLAG_0x22B", "0x22B" }, + [556] = { "FLAG_0x22C", "0x22C" }, + [557] = { "FLAG_0x22D", "0x22D" }, + [558] = { "FLAG_0x22E", "0x22E" }, + [559] = { "FLAG_0x22F", "0x22F" }, + [560] = { "FLAG_GOT_POTION_ON_ROUTE_1", "GOT_POTION_ON_ROUTE_1", "STORY_FLAGS_START" }, + [561] = { "FLAG_GOT_TM34_FROM_SURGE", "GOT_TM34_FROM_SURGE" }, + [562] = { "FLAG_GOT_FOSSIL_FROM_MT_MOON", "GOT_FOSSIL_FROM_MT_MOON" }, + [563] = { "FLAG_HELPED_BILL_IN_SEA_COTTAGE", "HELPED_BILL_IN_SEA_COTTAGE" }, + [564] = { "FLAG_GOT_SS_TICKET", "GOT_SS_TICKET" }, + [565] = { "FLAG_GOT_SS_TICKET_DUP", "GOT_SS_TICKET_DUP" }, + [566] = { "FLAG_GOT_TM42_AT_MEMORIAL_PILLAR", "GOT_TM42_AT_MEMORIAL_PILLAR" }, + [567] = { "FLAG_GOT_HM01", "GOT_HM01" }, + [568] = { "FLAG_GOT_HM02", "GOT_HM02" }, + [569] = { "FLAG_GOT_HM03", "GOT_HM03" }, + [570] = { "FLAG_GOT_HM04", "GOT_HM04" }, + [571] = { "FLAG_GOT_HM05", "GOT_HM05" }, + [572] = { "FLAG_RESCUED_MR_FUJI", "RESCUED_MR_FUJI" }, + [573] = { "FLAG_GOT_POKE_FLUTE", "GOT_POKE_FLUTE" }, + [574] = { "FLAG_0x23E", "0x23E" }, + [575] = { "FLAG_GOT_TM28_FROM_ROCKET", "GOT_TM28_FROM_ROCKET" }, + [576] = { "FLAG_GOT_OLD_ROD", "GOT_OLD_ROD" }, + [577] = { "FLAG_GOT_BIKE_VOUCHER", "GOT_BIKE_VOUCHER" }, + [578] = { "FLAG_0x242", "0x242" }, + [579] = { "FLAG_GOT_COIN_CASE", "GOT_COIN_CASE" }, + [580] = { "FLAG_GOT_GOOD_ROD", "GOT_GOOD_ROD" }, + [581] = { "FLAG_GOT_TM29_FROM_MR_PSYCHIC", "GOT_TM29_FROM_MR_PSYCHIC" }, + [582] = { "FLAG_GOT_LAPRAS_FROM_SILPH", "GOT_LAPRAS_FROM_SILPH" }, + [583] = { "FLAG_GOT_POKEBALLS_FROM_OAK_AFTER_22_RIVAL", "GOT_POKEBALLS_FROM_OAK_AFTER_22_RIVAL" }, + [584] = { "FLAG_DID_MIMIEN_TRADE", "DID_MIMIEN_TRADE" }, + [585] = { "FLAG_BOUGHT_MAGIKARP", "BOUGHT_MAGIKARP" }, + [586] = { "FLAG_DID_ZYNX_TRADE", "DID_ZYNX_TRADE" }, + [587] = { "FLAG_DID_MS_NIDO_TRADE", "DID_MS_NIDO_TRADE" }, + [588] = { "FLAG_0x24C", "0x24C" }, + [589] = { "FLAG_DID_CH_DING_TRADE", "DID_CH_DING_TRADE" }, + [590] = { "FLAG_GOT_TM38_FROM_BLAINE", "GOT_TM38_FROM_BLAINE" }, + [591] = { "FLAG_OAK_SKIP_22_RIVAL_CHECK", "OAK_SKIP_22_RIVAL_CHECK" }, + [592] = { "FLAG_GOT_MASTER_BALL_FROM_SILPH", "GOT_MASTER_BALL_FROM_SILPH" }, + [593] = { "FLAG_DID_NINA_TRADE", "DID_NINA_TRADE" }, + [594] = { "FLAG_GOT_ITEMFINDER", "GOT_ITEMFINDER" }, + [595] = { "FLAG_WOKE_UP_ROUTE_12_SNORLAX", "WOKE_UP_ROUTE_12_SNORLAX" }, + [596] = { "FLAG_GOT_TM39_FROM_BROCK", "GOT_TM39_FROM_BROCK" }, + [597] = { "FLAG_GOT_SUPER_ROD", "GOT_SUPER_ROD" }, + [598] = { "FLAG_GOT_EXP_SHARE_FROM_OAKS_AIDE", "GOT_EXP_SHARE_FROM_OAKS_AIDE" }, + [599] = { "FLAG_DID_MARC_TRADE", "DID_MARC_TRADE" }, + [600] = { "FLAG_BEAT_RIVAL_IN_OAKS_LAB", "BEAT_RIVAL_IN_OAKS_LAB" }, + [601] = { "FLAG_GOT_TM06_FROM_KOGA", "GOT_TM06_FROM_KOGA" }, + [602] = { "FLAG_0x25A", "0x25A" }, + [603] = { "FLAG_GOT_TM27", "GOT_TM27" }, + [604] = { "FLAG_0x25C", "0x25C" }, + [605] = { "FLAG_0x25D", "0x25D" }, + [606] = { "FLAG_GOT_OLD_AMBER", "GOT_OLD_AMBER" }, + [607] = { "FLAG_0x25F", "0x25F" }, + [608] = { "FLAG_0x260", "0x260" }, + [609] = { "FLAG_0x261", "0x261" }, + [610] = { "FLAG_0x262", "0x262" }, + [611] = { "FLAG_GOT_EEVEE", "GOT_EEVEE" }, + [612] = { "FLAG_FOUND_BOTH_VERMILION_GYM_SWITCHES", "FOUND_BOTH_VERMILION_GYM_SWITCHES" }, + [613] = { "FLAG_CINNABAR_GYM_QUIZ_1", "CINNABAR_GYM_QUIZ_1" }, + [614] = { "FLAG_PENDING_DAYCARE_EGG", "PENDING_DAYCARE_EGG" }, + [615] = { "FLAG_CINNABAR_GYM_QUIZ_2", "CINNABAR_GYM_QUIZ_2" }, + [616] = { "FLAG_CINNABAR_GYM_QUIZ_3", "CINNABAR_GYM_QUIZ_3" }, + [617] = { "FLAG_CINNABAR_GYM_QUIZ_4", "CINNABAR_GYM_QUIZ_4" }, + [618] = { "FLAG_CINNABAR_GYM_QUIZ_5", "CINNABAR_GYM_QUIZ_5" }, + [619] = { "FLAG_CINNABAR_GYM_QUIZ_6", "CINNABAR_GYM_QUIZ_6" }, + [620] = { "FLAG_POKEMON_MANSION_SWITCH_STATE", "POKEMON_MANSION_SWITCH_STATE" }, + [621] = { "FLAG_OPENED_ROCKET_HIDEOUT", "OPENED_ROCKET_HIDEOUT" }, + [622] = { "FLAG_GOT_10_COINS_FROM_GAMBLER", "GOT_10_COINS_FROM_GAMBLER" }, + [623] = { "FLAG_GOT_20_COINS_FROM_GAMBLER", "GOT_20_COINS_FROM_GAMBLER" }, + [624] = { "FLAG_GOT_20_COINS_FROM_GAMBLER_2", "GOT_20_COINS_FROM_GAMBLER_2" }, + [625] = { "FLAG_GOT_BICYCLE", "GOT_BICYCLE" }, + [626] = { "FLAG_GOT_DOME_FOSSIL", "GOT_DOME_FOSSIL" }, + [627] = { "FLAG_GOT_HELIX_FOSSIL", "GOT_HELIX_FOSSIL" }, + [628] = { "FLAG_DID_ESPHERE_TRADE", "DID_ESPHERE_TRADE" }, + [629] = { "FLAG_DID_TANGENY_TRADE", "DID_TANGENY_TRADE" }, + [630] = { "FLAG_DID_SEELOR_TRADE", "DID_SEELOR_TRADE" }, + [631] = { "FLAG_0x277", "0x277" }, + [632] = { "FLAG_GOT_HITMON_FROM_DOJO", "GOT_HITMON_FROM_DOJO" }, + [633] = { "FLAG_0x279", "0x279" }, + [634] = { "FLAG_SILPH_2F_DOOR_1", "SILPH_2F_DOOR_1" }, + [635] = { "FLAG_SILPH_2F_DOOR_2", "SILPH_2F_DOOR_2" }, + [636] = { "FLAG_SILPH_3F_DOOR_1", "SILPH_3F_DOOR_1" }, + [637] = { "FLAG_SILPH_3F_DOOR_2", "SILPH_3F_DOOR_2" }, + [638] = { "FLAG_SILPH_4F_DOOR_1", "SILPH_4F_DOOR_1" }, + [639] = { "FLAG_SILPH_4F_DOOR_2", "SILPH_4F_DOOR_2" }, + [640] = { "FLAG_SILPH_5F_DOOR_1", "SILPH_5F_DOOR_1" }, + [641] = { "FLAG_SILPH_5F_DOOR_2", "SILPH_5F_DOOR_2" }, + [642] = { "FLAG_SILPH_5F_DOOR_3", "SILPH_5F_DOOR_3" }, + [643] = { "FLAG_SILPH_6F_DOOR", "SILPH_6F_DOOR" }, + [644] = { "FLAG_SILPH_7F_DOOR_1", "SILPH_7F_DOOR_1" }, + [645] = { "FLAG_SILPH_7F_DOOR_2", "SILPH_7F_DOOR_2" }, + [646] = { "FLAG_SILPH_7F_DOOR_3", "SILPH_7F_DOOR_3" }, + [647] = { "FLAG_SILPH_8F_DOOR", "SILPH_8F_DOOR" }, + [648] = { "FLAG_SILPH_9F_DOOR_1", "SILPH_9F_DOOR_1" }, + [649] = { "FLAG_SILPH_9F_DOOR_2", "SILPH_9F_DOOR_2" }, + [650] = { "FLAG_SILPH_9F_DOOR_3", "SILPH_9F_DOOR_3" }, + [651] = { "FLAG_SILPH_9F_DOOR_4", "SILPH_9F_DOOR_4" }, + [652] = { "FLAG_SILPH_10F_DOOR", "SILPH_10F_DOOR" }, + [653] = { "FLAG_SILPH_11F_DOOR", "SILPH_11F_DOOR" }, + [654] = { "FLAG_0x28E", "0x28E" }, + [655] = { "FLAG_0x28F", "0x28F" }, + [656] = { "FLAG_MET_STICKER_MAN", "MET_STICKER_MAN" }, + [657] = { "FLAG_PALLET_LADY_NOT_BLOCKING_SIGN", "PALLET_LADY_NOT_BLOCKING_SIGN" }, + [658] = { "FLAG_GOT_VS_SEEKER", "GOT_VS_SEEKER" }, + [659] = { "FLAG_GOT_TM19_FROM_ERIKA", "GOT_TM19_FROM_ERIKA" }, + [660] = { "FLAG_GOT_TM33_FROM_THIRSTY_GIRL", "GOT_TM33_FROM_THIRSTY_GIRL" }, + [661] = { "FLAG_GOT_TM20_FROM_THIRSTY_GIRL", "GOT_TM20_FROM_THIRSTY_GIRL" }, + [662] = { "FLAG_GOT_TM16_FROM_THIRSTY_GIRL", "GOT_TM16_FROM_THIRSTY_GIRL" }, + [663] = { "FLAG_GOT_TM03_FROM_MISTY", "GOT_TM03_FROM_MISTY" }, + [664] = { "FLAG_GOT_TM26_FROM_GIOVANNI", "GOT_TM26_FROM_GIOVANNI" }, + [665] = { "FLAG_0x299", "0x299" }, + [666] = { "FLAG_GOT_TM04_FROM_SABRINA", "GOT_TM04_FROM_SABRINA" }, + [667] = { "FLAG_GOT_FAME_CHECKER", "GOT_FAME_CHECKER" }, + [668] = { "FLAG_GOT_RECORD_SETTING_MAGIKARP", "GOT_RECORD_SETTING_MAGIKARP" }, + [669] = { "FLAG_TWO_ISLAND_SHOP_INTRODUCED", "TWO_ISLAND_SHOP_INTRODUCED" }, + [670] = { "FLAG_TWO_ISLAND_SHOP_EXPANDED_1", "TWO_ISLAND_SHOP_EXPANDED_1" }, + [671] = { "FLAG_TWO_ISLAND_SHOP_EXPANDED_2", "TWO_ISLAND_SHOP_EXPANDED_2" }, + [672] = { "FLAG_TWO_ISLAND_SHOP_EXPANDED_3", "TWO_ISLAND_SHOP_EXPANDED_3" }, + [673] = { "FLAG_SEVII_DETOUR_FINISHED", "SEVII_DETOUR_FINISHED" }, + [674] = { "FLAG_VISITED_TWO_ISLAND", "VISITED_TWO_ISLAND" }, + [675] = { "FLAG_RESCUED_LOSTELLE", "RESCUED_LOSTELLE" }, + [676] = { "FLAG_0x2A4", "0x2A4" }, + [677] = { "FLAG_CAN_USE_ROCKET_HIDEOUT_LIFT", "CAN_USE_ROCKET_HIDEOUT_LIFT" }, + [678] = { "FLAG_GOT_TEA", "GOT_TEA" }, + [679] = { "FLAG_RECEIVED_AURORA_TICKET", "RECEIVED_AURORA_TICKET" }, + [680] = { "FLAG_RECEIVED_MYSTIC_TICKET", "RECEIVED_MYSTIC_TICKET" }, + [681] = { "FLAG_RECEIVED_OLD_SEA_MAP", "RECEIVED_OLD_SEA_MAP" }, + [682] = { "FLAG_WONDER_CARD_UNUSED_1", "WONDER_CARD_UNUSED_1" }, + [683] = { "FLAG_WONDER_CARD_UNUSED_2", "WONDER_CARD_UNUSED_2" }, + [684] = { "FLAG_WONDER_CARD_UNUSED_3", "WONDER_CARD_UNUSED_3" }, + [685] = { "FLAG_WONDER_CARD_UNUSED_4", "WONDER_CARD_UNUSED_4" }, + [686] = { "FLAG_WONDER_CARD_UNUSED_5", "WONDER_CARD_UNUSED_5" }, + [687] = { "FLAG_WONDER_CARD_UNUSED_6", "WONDER_CARD_UNUSED_6" }, + [688] = { "FLAG_WONDER_CARD_UNUSED_7", "WONDER_CARD_UNUSED_7" }, + [689] = { "FLAG_WONDER_CARD_UNUSED_8", "WONDER_CARD_UNUSED_8" }, + [690] = { "FLAG_WONDER_CARD_UNUSED_9", "WONDER_CARD_UNUSED_9" }, + [691] = { "FLAG_WONDER_CARD_UNUSED_10", "WONDER_CARD_UNUSED_10" }, + [692] = { "FLAG_WONDER_CARD_UNUSED_11", "WONDER_CARD_UNUSED_11" }, + [693] = { "FLAG_WONDER_CARD_UNUSED_12", "WONDER_CARD_UNUSED_12" }, + [694] = { "FLAG_WONDER_CARD_UNUSED_13", "WONDER_CARD_UNUSED_13" }, + [695] = { "FLAG_WONDER_CARD_UNUSED_14", "WONDER_CARD_UNUSED_14" }, + [696] = { "FLAG_WONDER_CARD_UNUSED_15", "WONDER_CARD_UNUSED_15" }, + [697] = { "FLAG_WONDER_CARD_UNUSED_16", "WONDER_CARD_UNUSED_16" }, + [698] = { "FLAG_WONDER_CARD_UNUSED_17", "WONDER_CARD_UNUSED_17" }, + [699] = { "FLAG_GOT_POWDER_JAR", "GOT_POWDER_JAR" }, + [700] = { "FLAG_FOUGHT_MEWTWO", "FOUGHT_MEWTWO" }, + [701] = { "FLAG_FOUGHT_MOLTRES", "FOUGHT_MOLTRES" }, + [702] = { "FLAG_FOUGHT_ARTICUNO", "FOUGHT_ARTICUNO" }, + [703] = { "FLAG_FOUGHT_ZAPDOS", "FOUGHT_ZAPDOS" }, + [704] = { "FLAG_TUTOR_DOUBLE_EDGE", "TUTOR_DOUBLE_EDGE" }, + [705] = { "FLAG_TUTOR_THUNDER_WAVE", "TUTOR_THUNDER_WAVE" }, + [706] = { "FLAG_TUTOR_ROCK_SLIDE", "TUTOR_ROCK_SLIDE" }, + [707] = { "FLAG_TUTOR_EXPLOSION", "TUTOR_EXPLOSION" }, + [708] = { "FLAG_TUTOR_MEGA_PUNCH", "TUTOR_MEGA_PUNCH" }, + [709] = { "FLAG_TUTOR_MEGA_KICK", "TUTOR_MEGA_KICK" }, + [710] = { "FLAG_TUTOR_DREAM_EATER", "TUTOR_DREAM_EATER" }, + [711] = { "FLAG_TUTOR_SOFT_BOILED", "TUTOR_SOFT_BOILED" }, + [712] = { "FLAG_TUTOR_SUBSTITUTE", "TUTOR_SUBSTITUTE" }, + [713] = { "FLAG_TUTOR_SWORDS_DANCE", "TUTOR_SWORDS_DANCE" }, + [714] = { "FLAG_TUTOR_SEISMIC_TOSS", "TUTOR_SEISMIC_TOSS" }, + [715] = { "FLAG_TUTOR_COUNTER", "TUTOR_COUNTER" }, + [716] = { "FLAG_TUTOR_METRONOME", "TUTOR_METRONOME" }, + [717] = { "FLAG_TUTOR_MIMIC", "TUTOR_MIMIC" }, + [718] = { "FLAG_TUTOR_BODY_SLAM", "TUTOR_BODY_SLAM" }, + [719] = { "FLAG_VISITED_OAKS_LAB", "VISITED_OAKS_LAB" }, + [720] = { "FLAG_FOUGHT_POWER_PLANT_ELECTRODE_1", "FOUGHT_POWER_PLANT_ELECTRODE_1" }, + [721] = { "FLAG_FOUGHT_POWER_PLANT_ELECTRODE_2", "FOUGHT_POWER_PLANT_ELECTRODE_2" }, + [722] = { "FLAG_STOPPED_SEAFOAM_B3F_CURRENT", "STOPPED_SEAFOAM_B3F_CURRENT" }, + [723] = { "FLAG_STOPPED_SEAFOAM_B4F_CURRENT", "STOPPED_SEAFOAM_B4F_CURRENT" }, + [724] = { "FLAG_TALKED_TO_LORELEI_AFTER_WAREHOUSE", "TALKED_TO_LORELEI_AFTER_WAREHOUSE" }, + [725] = { "FLAG_DEFEATED_ROCKETS_IN_WAREHOUSE", "DEFEATED_ROCKETS_IN_WAREHOUSE" }, + [726] = { "FLAG_UNLOCKED_ROCKET_WAREHOUSE", "UNLOCKED_ROCKET_WAREHOUSE" }, + [727] = { "FLAG_NO_ROOM_FOR_TM42_AT_MEMORIAL_PILLAR", "NO_ROOM_FOR_TM42_AT_MEMORIAL_PILLAR" }, + [728] = { "FLAG_LEARNED_YES_NAH_CHANSEY", "LEARNED_YES_NAH_CHANSEY" }, + [729] = { "FLAG_GOT_NEST_BALL_FROM_WATER_PATH_HOUSE_1", "GOT_NEST_BALL_FROM_WATER_PATH_HOUSE_1" }, + [730] = { "FLAG_GOT_TOGEPI_EGG", "GOT_TOGEPI_EGG" }, + [731] = { "FLAG_NO_ROOM_FOR_TOGEPI_EGG", "NO_ROOM_FOR_TOGEPI_EGG" }, + [732] = { "FLAG_RECOVERED_SAPPHIRE", "RECOVERED_SAPPHIRE" }, + [733] = { "FLAG_GOT_RUBY", "GOT_RUBY" }, + [734] = { "FLAG_TUTOR_FRENZY_PLANT", "TUTOR_FRENZY_PLANT" }, + [735] = { "FLAG_TUTOR_BLAST_BURN", "TUTOR_BLAST_BURN" }, + [736] = { "FLAG_TUTOR_HYDRO_CANNON", "TUTOR_HYDRO_CANNON" }, + [737] = { "FLAG_LEARNED_ALL_MOVES_AT_CAPE_BRINK", "LEARNED_ALL_MOVES_AT_CAPE_BRINK" }, + [738] = { "FLAG_GOT_NUGGET_FROM_DUNSPARCE_TUNNEL", "GOT_NUGGET_FROM_DUNSPARCE_TUNNEL" }, + [739] = { "FLAG_USED_CUT_ON_RUIN_VALLEY_BRAILLE", "USED_CUT_ON_RUIN_VALLEY_BRAILLE" }, + [740] = { "FLAG_FOUGHT_DEOXYS", "FOUGHT_DEOXYS" }, + [741] = { "FLAG_0x2E5", "0x2E5" }, + [742] = { "FLAG_0x2E6", "0x2E6" }, + [743] = { "FLAG_0x2E7", "0x2E7" }, + [744] = { "FLAG_0x2E8", "0x2E8" }, + [745] = { "FLAG_0x2E9", "0x2E9" }, + [746] = { "FLAG_0x2EA", "0x2EA" }, + [747] = { "FLAG_0x2EB", "0x2EB" }, + [748] = { "FLAG_REVIVED_DOME", "REVIVED_DOME" }, + [749] = { "FLAG_REVIVED_HELIX", "REVIVED_HELIX" }, + [750] = { "FLAG_REVIVED_AMBER", "REVIVED_AMBER" }, + [751] = { "FLAG_GOT_HM06", "GOT_HM06" }, + [752] = { "FLAG_SHOWN_MYSTIC_TICKET", "SHOWN_MYSTIC_TICKET" }, + [753] = { "FLAG_SHOWN_AURORA_TICKET", "SHOWN_AURORA_TICKET" }, + [754] = { "FLAG_FOUGHT_LUGIA", "FOUGHT_LUGIA" }, + [755] = { "FLAG_FOUGHT_HO_OH", "FOUGHT_HO_OH" }, + [756] = { "FLAG_OAK_SAW_DEX_COMPLETION", "OAK_SAW_DEX_COMPLETION" }, + [757] = { "FLAG_LUGIA_FLEW_AWAY", "LUGIA_FLEW_AWAY" }, + [758] = { "FLAG_HO_OH_FLEW_AWAY", "HO_OH_FLEW_AWAY" }, + [759] = { "FLAG_DEOXYS_FLEW_AWAY", "DEOXYS_FLEW_AWAY" }, + [760] = { "FLAG_TALKED_TO_TEA_LADY_AFTER_HOF", "TALKED_TO_TEA_LADY_AFTER_HOF" }, + [761] = { "FLAG_TALKED_TO_OAKS_AIDE_IN_VERMILION", "TALKED_TO_OAKS_AIDE_IN_VERMILION" }, + [762] = { "FLAG_GOT_EVERSTONE_FROM_OAKS_AIDE", "GOT_EVERSTONE_FROM_OAKS_AIDE" }, + [763] = { "FLAG_GOT_MOON_STONE_FROM_JOYFUL_GAME_CORNER", "GOT_MOON_STONE_FROM_JOYFUL_GAME_CORNER" }, + [764] = { "FLAG_GOT_FULL_RESTORE_FROM_THREE_ISLAND_DEFENDER", "GOT_FULL_RESTORE_FROM_THREE_ISLAND_DEFENDER" }, + [765] = { "FLAG_GOT_AMULET_COIN_FROM_OAKS_AIDE", "GOT_AMULET_COIN_FROM_OAKS_AIDE" }, + [766] = { "FLAG_NO_ROOM_FOR_JOYFUL_GAME_CORNER_MOON_STONE", "NO_ROOM_FOR_JOYFUL_GAME_CORNER_MOON_STONE" }, + [767] = { "FLAG_OAKS_RATING_IS_VIA_PC", "OAKS_RATING_IS_VIA_PC" }, + [768] = { "FLAG_0x300", "0x300" }, + [769] = { "FLAG_0x301", "0x301" }, + [770] = { "FLAG_0x302", "0x302" }, + [771] = { "FLAG_0x303", "0x303" }, + [772] = { "FLAG_0x304", "0x304" }, + [773] = { "FLAG_0x305", "0x305" }, + [774] = { "FLAG_0x306", "0x306" }, + [775] = { "FLAG_0x307", "0x307" }, + [776] = { "FLAG_0x308", "0x308" }, + [777] = { "FLAG_0x309", "0x309" }, + [778] = { "FLAG_0x30A", "0x30A" }, + [779] = { "FLAG_0x30B", "0x30B" }, + [780] = { "FLAG_0x30C", "0x30C" }, + [781] = { "FLAG_0x30D", "0x30D" }, + [782] = { "FLAG_0x30E", "0x30E" }, + [783] = { "FLAG_0x30F", "0x30F" }, + [784] = { "FLAG_0x310", "0x310" }, + [785] = { "FLAG_0x311", "0x311" }, + [786] = { "FLAG_0x312", "0x312" }, + [787] = { "FLAG_0x313", "0x313" }, + [788] = { "FLAG_0x314", "0x314" }, + [789] = { "FLAG_0x315", "0x315" }, + [790] = { "FLAG_0x316", "0x316" }, + [791] = { "FLAG_0x317", "0x317" }, + [792] = { "FLAG_0x318", "0x318" }, + [793] = { "FLAG_0x319", "0x319" }, + [794] = { "FLAG_0x31A", "0x31A" }, + [795] = { "FLAG_0x31B", "0x31B" }, + [796] = { "FLAG_0x31C", "0x31C" }, + [797] = { "FLAG_0x31D", "0x31D" }, + [798] = { "FLAG_0x31E", "0x31E" }, + [799] = { "FLAG_0x31F", "0x31F" }, + [800] = { "FLAG_0x320", "0x320" }, + [801] = { "FLAG_0x321", "0x321" }, + [802] = { "FLAG_0x322", "0x322" }, + [803] = { "FLAG_0x323", "0x323" }, + [804] = { "FLAG_0x324", "0x324" }, + [805] = { "FLAG_0x325", "0x325" }, + [806] = { "FLAG_0x326", "0x326" }, + [807] = { "FLAG_0x327", "0x327" }, + [808] = { "FLAG_0x328", "0x328" }, + [809] = { "FLAG_0x329", "0x329" }, + [810] = { "FLAG_0x32A", "0x32A" }, + [811] = { "FLAG_0x32B", "0x32B" }, + [812] = { "FLAG_0x32C", "0x32C" }, + [813] = { "FLAG_0x32D", "0x32D" }, + [814] = { "FLAG_0x32E", "0x32E" }, + [815] = { "FLAG_0x32F", "0x32F" }, + [816] = { "FLAG_0x330", "0x330" }, + [817] = { "FLAG_0x331", "0x331" }, + [818] = { "FLAG_0x332", "0x332" }, + [819] = { "FLAG_0x333", "0x333" }, + [820] = { "FLAG_0x334", "0x334" }, + [821] = { "FLAG_0x335", "0x335" }, + [822] = { "FLAG_0x336", "0x336" }, + [823] = { "FLAG_0x337", "0x337" }, + [824] = { "FLAG_0x338", "0x338" }, + [825] = { "FLAG_0x339", "0x339" }, + [826] = { "FLAG_0x33A", "0x33A" }, + [827] = { "FLAG_0x33B", "0x33B" }, + [828] = { "FLAG_0x33C", "0x33C" }, + [829] = { "FLAG_0x33D", "0x33D" }, + [830] = { "FLAG_0x33E", "0x33E" }, + [831] = { "FLAG_0x33F", "0x33F" }, + [832] = { "FLAG_0x340", "0x340" }, + [833] = { "FLAG_0x341", "0x341" }, + [834] = { "FLAG_0x342", "0x342" }, + [835] = { "FLAG_0x343", "0x343" }, + [836] = { "FLAG_0x344", "0x344" }, + [837] = { "FLAG_0x345", "0x345" }, + [838] = { "FLAG_0x346", "0x346" }, + [839] = { "FLAG_0x347", "0x347" }, + [840] = { "FLAG_0x348", "0x348" }, + [841] = { "FLAG_0x349", "0x349" }, + [842] = { "FLAG_0x34A", "0x34A" }, + [843] = { "FLAG_0x34B", "0x34B" }, + [844] = { "FLAG_0x34C", "0x34C" }, + [845] = { "FLAG_0x34D", "0x34D" }, + [846] = { "FLAG_0x34E", "0x34E" }, + [847] = { "FLAG_0x34F", "0x34F" }, + [848] = { "FLAG_0x350", "0x350" }, + [849] = { "FLAG_0x351", "0x351" }, + [850] = { "FLAG_0x352", "0x352" }, + [851] = { "FLAG_0x353", "0x353" }, + [852] = { "FLAG_0x354", "0x354" }, + [853] = { "FLAG_0x355", "0x355" }, + [854] = { "FLAG_0x356", "0x356" }, + [855] = { "FLAG_0x357", "0x357" }, + [856] = { "FLAG_0x358", "0x358" }, + [857] = { "FLAG_0x359", "0x359" }, + [858] = { "FLAG_0x35A", "0x35A" }, + [859] = { "FLAG_0x35B", "0x35B" }, + [860] = { "FLAG_0x35C", "0x35C" }, + [861] = { "FLAG_0x35D", "0x35D" }, + [862] = { "FLAG_0x35E", "0x35E" }, + [863] = { "FLAG_0x35F", "0x35F" }, + [864] = { "FLAG_0x360", "0x360" }, + [865] = { "FLAG_0x361", "0x361" }, + [866] = { "FLAG_0x362", "0x362" }, + [867] = { "FLAG_0x363", "0x363" }, + [868] = { "FLAG_0x364", "0x364" }, + [869] = { "FLAG_0x365", "0x365" }, + [870] = { "FLAG_0x366", "0x366" }, + [871] = { "FLAG_0x367", "0x367" }, + [872] = { "FLAG_0x368", "0x368" }, + [873] = { "FLAG_0x369", "0x369" }, + [874] = { "FLAG_0x36A", "0x36A" }, + [875] = { "FLAG_0x36B", "0x36B" }, + [876] = { "FLAG_0x36C", "0x36C" }, + [877] = { "FLAG_0x36D", "0x36D" }, + [878] = { "FLAG_0x36E", "0x36E" }, + [879] = { "FLAG_0x36F", "0x36F" }, + [880] = { "FLAG_0x370", "0x370" }, + [881] = { "FLAG_0x371", "0x371" }, + [882] = { "FLAG_0x372", "0x372" }, + [883] = { "FLAG_0x373", "0x373" }, + [884] = { "FLAG_0x374", "0x374" }, + [885] = { "FLAG_0x375", "0x375" }, + [886] = { "FLAG_0x376", "0x376" }, + [887] = { "FLAG_0x377", "0x377" }, + [888] = { "FLAG_0x378", "0x378" }, + [889] = { "FLAG_0x379", "0x379" }, + [890] = { "FLAG_0x37A", "0x37A" }, + [891] = { "FLAG_0x37B", "0x37B" }, + [892] = { "FLAG_0x37C", "0x37C" }, + [893] = { "FLAG_0x37D", "0x37D" }, + [894] = { "FLAG_0x37E", "0x37E" }, + [895] = { "FLAG_0x37F", "0x37F" }, + [896] = { "FLAG_0x380", "0x380" }, + [897] = { "FLAG_0x381", "0x381" }, + [898] = { "FLAG_0x382", "0x382" }, + [899] = { "FLAG_0x383", "0x383" }, + [900] = { "FLAG_0x384", "0x384" }, + [901] = { "FLAG_0x385", "0x385" }, + [902] = { "FLAG_0x386", "0x386" }, + [903] = { "FLAG_0x387", "0x387" }, + [904] = { "FLAG_0x388", "0x388" }, + [905] = { "FLAG_0x389", "0x389" }, + [906] = { "FLAG_0x38A", "0x38A" }, + [907] = { "FLAG_0x38B", "0x38B" }, + [908] = { "FLAG_0x38C", "0x38C" }, + [909] = { "FLAG_0x38D", "0x38D" }, + [910] = { "FLAG_0x38E", "0x38E" }, + [911] = { "FLAG_0x38F", "0x38F" }, + [912] = { "FLAG_0x390", "0x390" }, + [913] = { "FLAG_0x391", "0x391" }, + [914] = { "FLAG_0x392", "0x392" }, + [915] = { "FLAG_0x393", "0x393" }, + [916] = { "FLAG_0x394", "0x394" }, + [917] = { "FLAG_0x395", "0x395" }, + [918] = { "FLAG_0x396", "0x396" }, + [919] = { "FLAG_0x397", "0x397" }, + [920] = { "FLAG_0x398", "0x398" }, + [921] = { "FLAG_0x399", "0x399" }, + [922] = { "FLAG_0x39A", "0x39A" }, + [923] = { "FLAG_0x39B", "0x39B" }, + [924] = { "FLAG_0x39C", "0x39C" }, + [925] = { "FLAG_0x39D", "0x39D" }, + [926] = { "FLAG_0x39E", "0x39E" }, + [927] = { "FLAG_0x39F", "0x39F" }, + [928] = { "FLAG_0x3A0", "0x3A0" }, + [929] = { "FLAG_0x3A1", "0x3A1" }, + [930] = { "FLAG_0x3A2", "0x3A2" }, + [931] = { "FLAG_0x3A3", "0x3A3" }, + [932] = { "FLAG_0x3A4", "0x3A4" }, + [933] = { "FLAG_0x3A5", "0x3A5" }, + [934] = { "FLAG_0x3A6", "0x3A6" }, + [935] = { "FLAG_0x3A7", "0x3A7" }, + [936] = { "FLAG_0x3A8", "0x3A8" }, + [937] = { "FLAG_0x3A9", "0x3A9" }, + [938] = { "FLAG_0x3AA", "0x3AA" }, + [939] = { "FLAG_0x3AB", "0x3AB" }, + [940] = { "FLAG_0x3AC", "0x3AC" }, + [941] = { "FLAG_0x3AD", "0x3AD" }, + [942] = { "FLAG_0x3AE", "0x3AE" }, + [943] = { "FLAG_0x3AF", "0x3AF" }, + [944] = { "FLAG_0x3B0", "0x3B0" }, + [945] = { "FLAG_0x3B1", "0x3B1" }, + [946] = { "FLAG_0x3B2", "0x3B2" }, + [947] = { "FLAG_0x3B3", "0x3B3" }, + [948] = { "FLAG_0x3B4", "0x3B4" }, + [949] = { "FLAG_0x3B5", "0x3B5" }, + [950] = { "FLAG_0x3B6", "0x3B6" }, + [951] = { "FLAG_0x3B7", "0x3B7" }, + [952] = { "FLAG_0x3B8", "0x3B8" }, + [953] = { "FLAG_0x3B9", "0x3B9" }, + [954] = { "FLAG_0x3BA", "0x3BA" }, + [955] = { "FLAG_0x3BB", "0x3BB" }, + [956] = { "FLAG_0x3BC", "0x3BC" }, + [957] = { "FLAG_0x3BD", "0x3BD" }, + [958] = { "FLAG_0x3BE", "0x3BE" }, + [959] = { "FLAG_0x3BF", "0x3BF" }, + [960] = { "FLAG_0x3C0", "0x3C0" }, + [961] = { "FLAG_0x3C1", "0x3C1" }, + [962] = { "FLAG_0x3C2", "0x3C2" }, + [963] = { "FLAG_0x3C3", "0x3C3" }, + [964] = { "FLAG_0x3C4", "0x3C4" }, + [965] = { "FLAG_0x3C5", "0x3C5" }, + [966] = { "FLAG_0x3C6", "0x3C6" }, + [967] = { "FLAG_0x3C7", "0x3C7" }, + [968] = { "FLAG_0x3C8", "0x3C8" }, + [969] = { "FLAG_0x3C9", "0x3C9" }, + [970] = { "FLAG_0x3CA", "0x3CA" }, + [971] = { "FLAG_0x3CB", "0x3CB" }, + [972] = { "FLAG_0x3CC", "0x3CC" }, + [973] = { "FLAG_0x3CD", "0x3CD" }, + [974] = { "FLAG_0x3CE", "0x3CE" }, + [975] = { "FLAG_0x3CF", "0x3CF" }, + [976] = { "FLAG_0x3D0", "0x3D0" }, + [977] = { "FLAG_0x3D1", "0x3D1" }, + [978] = { "FLAG_0x3D2", "0x3D2" }, + [979] = { "FLAG_0x3D3", "0x3D3" }, + [980] = { "FLAG_0x3D4", "0x3D4" }, + [981] = { "FLAG_0x3D5", "0x3D5" }, + [982] = { "FLAG_0x3D6", "0x3D6" }, + [983] = { "FLAG_0x3D7", "0x3D7" }, + [984] = { "FLAG_MYSTERY_GIFT_DONE", "MYSTERY_GIFT_DONE" }, + [985] = { "FLAG_MYSTERY_GIFT_1", "MYSTERY_GIFT_1" }, + [986] = { "FLAG_MYSTERY_GIFT_2", "MYSTERY_GIFT_2" }, + [987] = { "FLAG_MYSTERY_GIFT_3", "MYSTERY_GIFT_3" }, + [988] = { "FLAG_MYSTERY_GIFT_4", "MYSTERY_GIFT_4" }, + [989] = { "FLAG_MYSTERY_GIFT_5", "MYSTERY_GIFT_5" }, + [990] = { "FLAG_MYSTERY_GIFT_6", "MYSTERY_GIFT_6" }, + [991] = { "FLAG_MYSTERY_GIFT_7", "MYSTERY_GIFT_7" }, + [992] = { "FLAG_MYSTERY_GIFT_8", "MYSTERY_GIFT_8" }, + [993] = { "FLAG_MYSTERY_GIFT_9", "MYSTERY_GIFT_9" }, + [994] = { "FLAG_MYSTERY_GIFT_10", "MYSTERY_GIFT_10" }, + [995] = { "FLAG_MYSTERY_GIFT_11", "MYSTERY_GIFT_11" }, + [996] = { "FLAG_MYSTERY_GIFT_12", "MYSTERY_GIFT_12" }, + [997] = { "FLAG_MYSTERY_GIFT_13", "MYSTERY_GIFT_13" }, + [998] = { "FLAG_MYSTERY_GIFT_14", "MYSTERY_GIFT_14" }, + [999] = { "FLAG_MYSTERY_GIFT_15", "MYSTERY_GIFT_15" }, + [1000] = { "FLAG_HIDDEN_ITEMS_START", "FLAG_HIDDEN_ITEM_VIRIDIAN_FOREST_POTION", "HIDDEN_ITEMS_START", "HIDDEN_ITEM_VIRIDIAN_FOREST_POTION" }, + [1001] = { "FLAG_HIDDEN_ITEM_VIRIDIAN_FOREST_ANTIDOTE", "HIDDEN_ITEM_VIRIDIAN_FOREST_ANTIDOTE" }, + [1002] = { "FLAG_HIDDEN_ITEM_MT_MOON_B2F_MOON_STONE", "HIDDEN_ITEM_MT_MOON_B2F_MOON_STONE" }, + [1003] = { "FLAG_HIDDEN_ITEM_MT_MOON_B2F_ETHER", "HIDDEN_ITEM_MT_MOON_B2F_ETHER" }, + [1004] = { "FLAG_HIDDEN_ITEM_ROUTE25_ELIXIR", "HIDDEN_ITEM_ROUTE25_ELIXIR" }, + [1005] = { "FLAG_HIDDEN_ITEM_ROUTE25_ETHER", "HIDDEN_ITEM_ROUTE25_ETHER" }, + [1006] = { "FLAG_HIDDEN_ITEM_ROUTE9_ETHER", "HIDDEN_ITEM_ROUTE9_ETHER" }, + [1007] = { "FLAG_HIDDEN_ITEM_UNUSED_0x07", "HIDDEN_ITEM_UNUSED_0x07" }, + [1008] = { "FLAG_HIDDEN_ITEM_SSANNE_B1F_CORRIDOR_HYPER_POTION", "HIDDEN_ITEM_SSANNE_B1F_CORRIDOR_HYPER_POTION" }, + [1009] = { "FLAG_HIDDEN_ITEM_ROUTE10_SUPER_POTION", "HIDDEN_ITEM_ROUTE10_SUPER_POTION" }, + [1010] = { "FLAG_HIDDEN_ITEM_ROUTE10_MAX_ETHER", "HIDDEN_ITEM_ROUTE10_MAX_ETHER" }, + [1011] = { "FLAG_HIDDEN_ITEM_ROCKET_HIDEOUT_B1F_PP_UP", "HIDDEN_ITEM_ROCKET_HIDEOUT_B1F_PP_UP" }, + [1012] = { "FLAG_HIDDEN_ITEM_ROCKET_HIDEOUT_B3F_NUGGET", "HIDDEN_ITEM_ROCKET_HIDEOUT_B3F_NUGGET" }, + [1013] = { "FLAG_HIDDEN_ITEM_ROCKET_HIDEOUT_B4F_NEST_BALL", "HIDDEN_ITEM_ROCKET_HIDEOUT_B4F_NEST_BALL" }, + [1014] = { "FLAG_HIDDEN_ITEM_POKEMON_TOWER_5F_BIG_MUSHROOM", "HIDDEN_ITEM_POKEMON_TOWER_5F_BIG_MUSHROOM" }, + [1015] = { "FLAG_HIDDEN_ITEM_ROUTE13_PP_UP", "HIDDEN_ITEM_ROUTE13_PP_UP" }, + [1016] = { "FLAG_HIDDEN_ITEM_UNUSED_0x10", "HIDDEN_ITEM_UNUSED_0x10" }, + [1017] = { "FLAG_HIDDEN_ITEM_ROUTE17_RARE_CANDY", "HIDDEN_ITEM_ROUTE17_RARE_CANDY" }, + [1018] = { "FLAG_HIDDEN_ITEM_ROUTE17_FULL_RESTORE", "HIDDEN_ITEM_ROUTE17_FULL_RESTORE" }, + [1019] = { "FLAG_HIDDEN_ITEM_ROUTE17_PP_UP", "HIDDEN_ITEM_ROUTE17_PP_UP" }, + [1020] = { "FLAG_HIDDEN_ITEM_ROUTE17_MAX_REVIVE", "HIDDEN_ITEM_ROUTE17_MAX_REVIVE" }, + [1021] = { "FLAG_HIDDEN_ITEM_ROUTE17_MAX_ELIXIR", "HIDDEN_ITEM_ROUTE17_MAX_ELIXIR" }, + [1022] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_CENTER_LEAF_STONE", "HIDDEN_ITEM_SAFARI_ZONE_CENTER_LEAF_STONE" }, + [1023] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_WEST_REVIVE", "HIDDEN_ITEM_SAFARI_ZONE_WEST_REVIVE" }, + [1024] = { "FLAG_HIDDEN_ITEM_SILPH_CO_5F_ELIXIR", "HIDDEN_ITEM_SILPH_CO_5F_ELIXIR" }, + [1025] = { "FLAG_HIDDEN_ITEM_SILPH_CO_9F_MAX_POTION", "HIDDEN_ITEM_SILPH_CO_9F_MAX_POTION" }, + [1026] = { "FLAG_HIDDEN_ITEM_SAFFRON_CITY_COPYCATS_HOUSE_2F_NUGGET", "HIDDEN_ITEM_SAFFRON_CITY_COPYCATS_HOUSE_2F_NUGGET" }, + [1027] = { "FLAG_HIDDEN_ITEM_POWER_PLANT_MAX_ELIXIR", "HIDDEN_ITEM_POWER_PLANT_MAX_ELIXIR" }, + [1028] = { "FLAG_HIDDEN_ITEM_POWER_PLANT_THUNDER_STONE", "HIDDEN_ITEM_POWER_PLANT_THUNDER_STONE" }, + [1029] = { "FLAG_HIDDEN_ITEM_SEAFOAM_ISLANDS_B3F_NUGGET", "HIDDEN_ITEM_SEAFOAM_ISLANDS_B3F_NUGGET" }, + [1030] = { "FLAG_HIDDEN_ITEM_SEAFOAM_ISLANDS_B4F_WATER_STONE", "HIDDEN_ITEM_SEAFOAM_ISLANDS_B4F_WATER_STONE" }, + [1031] = { "FLAG_HIDDEN_ITEM_POKEMON_MANSION_1F_MOON_STONE", "HIDDEN_ITEM_POKEMON_MANSION_1F_MOON_STONE" }, + [1032] = { "FLAG_HIDDEN_ITEM_POKEMON_MANSION_3F_RARE_CANDY", "HIDDEN_ITEM_POKEMON_MANSION_3F_RARE_CANDY" }, + [1033] = { "FLAG_HIDDEN_ITEM_POKEMON_MANSION_B1F_ELIXIR", "HIDDEN_ITEM_POKEMON_MANSION_B1F_ELIXIR" }, + [1034] = { "FLAG_HIDDEN_ITEM_ROUTE23_FULL_RESTORE", "HIDDEN_ITEM_ROUTE23_FULL_RESTORE" }, + [1035] = { "FLAG_HIDDEN_ITEM_ROUTE23_ULTRA_BALL", "HIDDEN_ITEM_ROUTE23_ULTRA_BALL" }, + [1036] = { "FLAG_HIDDEN_ITEM_ROUTE23_MAX_ETHER", "HIDDEN_ITEM_ROUTE23_MAX_ETHER" }, + [1037] = { "FLAG_HIDDEN_ITEM_VICTORY_ROAD_1F_ULTRA_BALL", "HIDDEN_ITEM_VICTORY_ROAD_1F_ULTRA_BALL" }, + [1038] = { "FLAG_HIDDEN_ITEM_VICTORY_ROAD_1F_FULL_RESTORE", "HIDDEN_ITEM_VICTORY_ROAD_1F_FULL_RESTORE" }, + [1039] = { "FLAG_HIDDEN_ITEM_CERULEAN_CAVE_1F_ULTRA_BALL", "HIDDEN_ITEM_CERULEAN_CAVE_1F_ULTRA_BALL" }, + [1040] = { "FLAG_HIDDEN_ITEM_UNUSED_0x28", "HIDDEN_ITEM_UNUSED_0x28" }, + [1041] = { "FLAG_HIDDEN_ITEM_ROUTE11_ESCAPE_ROPE", "HIDDEN_ITEM_ROUTE11_ESCAPE_ROPE" }, + [1042] = { "FLAG_HIDDEN_ITEM_ROUTE12_HYPER_POTION", "HIDDEN_ITEM_ROUTE12_HYPER_POTION" }, + [1043] = { "FLAG_HIDDEN_ITEM_UNUSED_0x2B", "HIDDEN_ITEM_UNUSED_0x2B" }, + [1044] = { "FLAG_HIDDEN_ITEM_UNUSED_0x2C", "HIDDEN_ITEM_UNUSED_0x2C" }, + [1045] = { "FLAG_HIDDEN_ITEM_UNUSED_0x2D", "HIDDEN_ITEM_UNUSED_0x2D" }, + [1046] = { "FLAG_HIDDEN_ITEM_UNUSED_0x2E", "HIDDEN_ITEM_UNUSED_0x2E" }, + [1047] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_PP_UP", "HIDDEN_ITEM_CELADON_CITY_PP_UP" }, + [1048] = { "FLAG_HIDDEN_ITEM_VERMILION_CITY_MAX_ETHER", "HIDDEN_ITEM_VERMILION_CITY_MAX_ETHER" }, + [1049] = { "FLAG_HIDDEN_ITEM_CERULEAN_CITY_RARE_CANDY", "HIDDEN_ITEM_CERULEAN_CITY_RARE_CANDY" }, + [1050] = { "FLAG_HIDDEN_ITEM_ROUTE4_GREAT_BALL", "HIDDEN_ITEM_ROUTE4_GREAT_BALL" }, + [1051] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS" }, + [1052] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_2", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_2" }, + [1053] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_3", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_3" }, + [1054] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_4", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_4" }, + [1055] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_5", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_5" }, + [1056] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_6", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_6" }, + [1057] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_7", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_7" }, + [1058] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_8", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_8" }, + [1059] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_9", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_9" }, + [1060] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_10", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_10" }, + [1061] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_11", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_11" }, + [1062] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_12", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_12" }, + [1063] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_SEVAULT_CANYON_CHERI_BERRY", "HIDDEN_ITEM_SEVEN_ISLAND_SEVAULT_CANYON_CHERI_BERRY" }, + [1064] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_4", "HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_4" }, + [1065] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE", "HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE" }, + [1066] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_2", "HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_2" }, + [1067] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_3", "HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_3" }, + [1068] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_ROCKET_WAREHOUSE_NEST_BALL", "HIDDEN_ITEM_FIVE_ISLAND_ROCKET_WAREHOUSE_NEST_BALL" }, + [1069] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_ROCKET_WAREHOUSE_NET_BALL", "HIDDEN_ITEM_FIVE_ISLAND_ROCKET_WAREHOUSE_NET_BALL" }, + [1070] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_POTION", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_POTION" }, + [1071] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ANTIDOTE", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ANTIDOTE" }, + [1072] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_PARALYZE_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_PARALYZE_HEAL" }, + [1073] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_AWAKENING", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_AWAKENING" }, + [1074] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_BURN_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_BURN_HEAL" }, + [1075] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ICE_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ICE_HEAL" }, + [1076] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ETHER", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ETHER" }, + [1077] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_POTION", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_POTION" }, + [1078] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ANTIDOTE", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ANTIDOTE" }, + [1079] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_PARALYZE_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_PARALYZE_HEAL" }, + [1080] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_AWAKENING", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_AWAKENING" }, + [1081] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_BURN_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_BURN_HEAL" }, + [1082] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ICE_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ICE_HEAL" }, + [1083] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ETHER", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ETHER" }, + [1084] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM", "HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM" }, + [1085] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_2", "HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_2" }, + [1086] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_3", "HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_3" }, + [1087] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM", "HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM" }, + [1088] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM_2", "HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM_2" }, + [1089] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM_3", "HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM_3" }, + [1090] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_RAZZ_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_RAZZ_BERRY" }, + [1091] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_BLUK_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_BLUK_BERRY" }, + [1092] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_NANAB_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_NANAB_BERRY" }, + [1093] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_WEPEAR_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_WEPEAR_BERRY" }, + [1094] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ORAN_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ORAN_BERRY" }, + [1095] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHERI_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHERI_BERRY" }, + [1096] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHESTO_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHESTO_BERRY" }, + [1097] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PECHA_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PECHA_BERRY" }, + [1098] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_RAWST_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_RAWST_BERRY" }, + [1099] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ASPEAR_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ASPEAR_BERRY" }, + [1100] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PERSIM_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PERSIM_BERRY" }, + [1101] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PINAP_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PINAP_BERRY" }, + [1102] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_LUM_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_LUM_BERRY" }, + [1103] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STARDUST", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STARDUST" }, + [1104] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STARDUST_2", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STARDUST_2" }, + [1105] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_PEARL", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_PEARL" }, + [1106] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_PEARL_2", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_PEARL_2" }, + [1107] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL" }, + [1108] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL_2", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL_2" }, + [1109] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STAR_PIECE", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STAR_PIECE" }, + [1110] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_BIG_PEARL", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_BIG_PEARL" }, + [1111] = { "FLAG_HIDDEN_ITEM_TWO_ISLAND_CAPE_BRINK_RARE_CANDY", "HIDDEN_ITEM_TWO_ISLAND_CAPE_BRINK_RARE_CANDY" }, + [1112] = { "FLAG_HIDDEN_ITEM_PEWTER_CITY_POKE_BALL", "HIDDEN_ITEM_PEWTER_CITY_POKE_BALL" }, + [1113] = { "FLAG_HIDDEN_ITEM_ROUTE3_ORAN_BERRY", "HIDDEN_ITEM_ROUTE3_ORAN_BERRY" }, + [1114] = { "FLAG_HIDDEN_ITEM_ROUTE4_PERSIM_BERRY", "HIDDEN_ITEM_ROUTE4_PERSIM_BERRY" }, + [1115] = { "FLAG_HIDDEN_ITEM_ROUTE24_PECHA_BERRY", "HIDDEN_ITEM_ROUTE24_PECHA_BERRY" }, + [1116] = { "FLAG_HIDDEN_ITEM_ROUTE25_ORAN_BERRY", "HIDDEN_ITEM_ROUTE25_ORAN_BERRY" }, + [1117] = { "FLAG_HIDDEN_ITEM_ROUTE25_BLUK_BERRY", "HIDDEN_ITEM_ROUTE25_BLUK_BERRY" }, + [1118] = { "FLAG_HIDDEN_ITEM_ROUTE6_SITRUS_BERRY", "HIDDEN_ITEM_ROUTE6_SITRUS_BERRY" }, + [1119] = { "FLAG_HIDDEN_ITEM_ROUTE6_RARE_CANDY", "HIDDEN_ITEM_ROUTE6_RARE_CANDY" }, + [1120] = { "FLAG_HIDDEN_ITEM_SSANNE_KITCHEN_PECHA_BERRY", "HIDDEN_ITEM_SSANNE_KITCHEN_PECHA_BERRY" }, + [1121] = { "FLAG_HIDDEN_ITEM_SSANNE_KITCHEN_CHERI_BERRY", "HIDDEN_ITEM_SSANNE_KITCHEN_CHERI_BERRY" }, + [1122] = { "FLAG_HIDDEN_ITEM_SSANNE_KITCHEN_CHESTO_BERRY", "HIDDEN_ITEM_SSANNE_KITCHEN_CHESTO_BERRY" }, + [1123] = { "FLAG_HIDDEN_ITEM_ROUTE9_RARE_CANDY", "HIDDEN_ITEM_ROUTE9_RARE_CANDY" }, + [1124] = { "FLAG_HIDDEN_ITEM_UNUSED_0x7C", "HIDDEN_ITEM_UNUSED_0x7C" }, + [1125] = { "FLAG_HIDDEN_ITEM_ROUTE10_PERSIM_BERRY", "HIDDEN_ITEM_ROUTE10_PERSIM_BERRY" }, + [1126] = { "FLAG_HIDDEN_ITEM_ROUTE10_CHERI_BERRY", "HIDDEN_ITEM_ROUTE10_CHERI_BERRY" }, + [1127] = { "FLAG_HIDDEN_ITEM_ROUTE8_RAWST_BERRY", "HIDDEN_ITEM_ROUTE8_RAWST_BERRY" }, + [1128] = { "FLAG_HIDDEN_ITEM_ROUTE8_LUM_BERRY", "HIDDEN_ITEM_ROUTE8_LUM_BERRY" }, + [1129] = { "FLAG_HIDDEN_ITEM_ROUTE8_LEPPA_BERRY", "HIDDEN_ITEM_ROUTE8_LEPPA_BERRY" }, + [1130] = { "FLAG_HIDDEN_ITEM_ROUTE12_RARE_CANDY", "HIDDEN_ITEM_ROUTE12_RARE_CANDY" }, + [1131] = { "FLAG_HIDDEN_ITEM_ROUTE12_LEFTOVERS", "HIDDEN_ITEM_ROUTE12_LEFTOVERS" }, + [1132] = { "FLAG_HIDDEN_ITEM_ROUTE16_LEFTOVERS", "HIDDEN_ITEM_ROUTE16_LEFTOVERS" }, + [1133] = { "FLAG_HIDDEN_ITEM_FUCHSIA_CITY_MAX_REVIVE", "HIDDEN_ITEM_FUCHSIA_CITY_MAX_REVIVE" }, + [1134] = { "FLAG_HIDDEN_ITEM_ROCKET_HIDEOUT_B4F_NET_BALL", "HIDDEN_ITEM_ROCKET_HIDEOUT_B4F_NET_BALL" }, + [1135] = { "FLAG_HIDDEN_ITEM_SILPH_CO_2F_ULTRA_BALL", "HIDDEN_ITEM_SILPH_CO_2F_ULTRA_BALL" }, + [1136] = { "FLAG_HIDDEN_ITEM_SILPH_CO_3F_PROTEIN", "HIDDEN_ITEM_SILPH_CO_3F_PROTEIN" }, + [1137] = { "FLAG_HIDDEN_ITEM_SILPH_CO_4F_IRON", "HIDDEN_ITEM_SILPH_CO_4F_IRON" }, + [1138] = { "FLAG_HIDDEN_ITEM_SILPH_CO_5F_PP_UP", "HIDDEN_ITEM_SILPH_CO_5F_PP_UP" }, + [1139] = { "FLAG_HIDDEN_ITEM_SILPH_CO_6F_CARBOS", "HIDDEN_ITEM_SILPH_CO_6F_CARBOS" }, + [1140] = { "FLAG_HIDDEN_ITEM_SILPH_CO_7F_ZINC", "HIDDEN_ITEM_SILPH_CO_7F_ZINC" }, + [1141] = { "FLAG_HIDDEN_ITEM_SILPH_CO_8F_NUGGET", "HIDDEN_ITEM_SILPH_CO_8F_NUGGET" }, + [1142] = { "FLAG_HIDDEN_ITEM_SILPH_CO_9F_CALCIUM", "HIDDEN_ITEM_SILPH_CO_9F_CALCIUM" }, + [1143] = { "FLAG_HIDDEN_ITEM_SILPH_CO_10F_HP_UP", "HIDDEN_ITEM_SILPH_CO_10F_HP_UP" }, + [1144] = { "FLAG_HIDDEN_ITEM_SILPH_CO_11F_REVIVE", "HIDDEN_ITEM_SILPH_CO_11F_REVIVE" }, + [1145] = { "FLAG_HIDDEN_ITEM_ROUTE23_LUM_BERRY", "HIDDEN_ITEM_ROUTE23_LUM_BERRY" }, + [1146] = { "FLAG_HIDDEN_ITEM_ROUTE23_SITRUS_BERRY", "HIDDEN_ITEM_ROUTE23_SITRUS_BERRY" }, + [1147] = { "FLAG_HIDDEN_ITEM_ROUTE23_ASPEAR_BERRY", "HIDDEN_ITEM_ROUTE23_ASPEAR_BERRY" }, + [1148] = { "FLAG_HIDDEN_ITEM_ROUTE23_LEPPA_BERRY", "HIDDEN_ITEM_ROUTE23_LEPPA_BERRY" }, + [1149] = { "FLAG_HIDDEN_ITEM_ROUTE14_ZINC", "HIDDEN_ITEM_ROUTE14_ZINC" }, + [1150] = { "FLAG_HIDDEN_ITEM_ROUTE9_CHESTO_BERRY", "HIDDEN_ITEM_ROUTE9_CHESTO_BERRY" }, + [1151] = { "FLAG_HIDDEN_ITEM_ROUTE10_NANAB_BERRY", "HIDDEN_ITEM_ROUTE10_NANAB_BERRY" }, + [1152] = { "FLAG_HIDDEN_ITEM_ROUTE7_WEPEAR_BERRY", "HIDDEN_ITEM_ROUTE7_WEPEAR_BERRY" }, + [1153] = { "FLAG_HIDDEN_ITEM_ROUTE20_STARDUST", "HIDDEN_ITEM_ROUTE20_STARDUST" }, + [1154] = { "FLAG_HIDDEN_ITEM_ROUTE21_NORTH_PEARL", "HIDDEN_ITEM_ROUTE21_NORTH_PEARL" }, + [1155] = { "FLAG_HIDDEN_ITEM_ROUTE23_MAX_ELIXIR", "HIDDEN_ITEM_ROUTE23_MAX_ELIXIR" }, + [1156] = { "FLAG_HIDDEN_ITEM_ROUTE4_RAZZ_BERRY", "HIDDEN_ITEM_ROUTE4_RAZZ_BERRY" }, + [1157] = { "FLAG_HIDDEN_ITEM_ROUTE14_PINAP_BERRY", "HIDDEN_ITEM_ROUTE14_PINAP_BERRY" }, + [1158] = { "FLAG_HIDDEN_ITEM_MT_EMBER_EXTERIOR_FIRE_STONE", "HIDDEN_ITEM_MT_EMBER_EXTERIOR_FIRE_STONE" }, + [1159] = { "FLAG_HIDDEN_ITEM_POKEMON_TOWER_7F_SOOTHE_BELL", "HIDDEN_ITEM_POKEMON_TOWER_7F_SOOTHE_BELL" }, + [1160] = { "FLAG_HIDDEN_ITEM_NAVEL_ROCK_SUMMIT_SACRED_ASH", "HIDDEN_ITEM_NAVEL_ROCK_SUMMIT_SACRED_ASH" }, + [1161] = { "FLAG_HIDDEN_ITEM_TWO_ISLAND_CAPE_BRINK_PP_MAX", "HIDDEN_ITEM_TWO_ISLAND_CAPE_BRINK_PP_MAX" }, + [1162] = { "FLAG_HIDDEN_ITEM_MT_EMBER_EXTERIOR_ULTRA_BALL", "HIDDEN_ITEM_MT_EMBER_EXTERIOR_ULTRA_BALL" }, + [1163] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_DUNSPARCE_TUNNEL_NUGGET", "HIDDEN_ITEM_THREE_ISLAND_DUNSPARCE_TUNNEL_NUGGET" }, + [1164] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_PP_UP", "HIDDEN_ITEM_THREE_ISLAND_PP_UP" }, + [1165] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_MAX_REPEL", "HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_MAX_REPEL" }, + [1166] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_PEARL", "HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_PEARL" }, + [1167] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_STARDUST", "HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_STARDUST" }, + [1168] = { "FLAG_HIDDEN_ITEM_FOUR_ISLAND_PEARL", "HIDDEN_ITEM_FOUR_ISLAND_PEARL" }, + [1169] = { "FLAG_HIDDEN_ITEM_FOUR_ISLAND_ULTRA_BALL", "HIDDEN_ITEM_FOUR_ISLAND_ULTRA_BALL" }, + [1170] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_BIG_PEARL", "HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_BIG_PEARL" }, + [1171] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_RAZZ_BERRY", "HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_RAZZ_BERRY" }, + [1172] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_SITRUS_BERRY", "HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_SITRUS_BERRY" }, + [1173] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_BLUK_BERRY", "HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_BLUK_BERRY" }, + [1174] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_NEST_BALL", "HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_NEST_BALL" }, + [1175] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STARDUST", "HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STARDUST" }, + [1176] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STAR_PIECE", "HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STAR_PIECE" }, + [1177] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STARDUST_2", "HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STARDUST_2" }, + [1178] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_OUTCAST_ISLAND_STAR_PIECE", "HIDDEN_ITEM_SIX_ISLAND_OUTCAST_ISLAND_STAR_PIECE" }, + [1179] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_OUTCAST_ISLAND_NET_BALL", "HIDDEN_ITEM_SIX_ISLAND_OUTCAST_ISLAND_NET_BALL" }, + [1180] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_GREEN_PATH_ULTRA_BALL", "HIDDEN_ITEM_SIX_ISLAND_GREEN_PATH_ULTRA_BALL" }, + [1181] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_ASPEAR_BERRY", "HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_ASPEAR_BERRY" }, + [1182] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_ORAN_BERRY", "HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_ORAN_BERRY" }, + [1183] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_PINAP_BERRY", "HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_PINAP_BERRY" }, + [1184] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_LEPPA_BERRY", "HIDDEN_ITEM_SIX_ISLAND_LEPPA_BERRY" }, + [1185] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_BIG_PEARL", "HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_BIG_PEARL" }, + [1186] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_PEARL", "HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_PEARL" }, + [1187] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_NANAB_BERRY", "HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_NANAB_BERRY" }, + [1188] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_SEVAULT_CANYON_ENTRANCE_RAWST_BERRY", "HIDDEN_ITEM_SEVEN_ISLAND_SEVAULT_CANYON_ENTRANCE_RAWST_BERRY" }, + [1189] = { "FLAG_HIDDEN_ITEM_VIRIDIAN_CITY_GYM_MACHO_BRACE", "HIDDEN_ITEM_VIRIDIAN_CITY_GYM_MACHO_BRACE" }, + [1190] = { "FLAG_HIDDEN_ITEM_SSANNE_EXTERIOR_LAVA_COOKIE", "HIDDEN_ITEM_SSANNE_EXTERIOR_LAVA_COOKIE" }, + [1191] = { "FLAG_UNUSED_0x4A7", "UNUSED_0x4A7" }, + [1192] = { "FLAG_UNUSED_0x4A8", "UNUSED_0x4A8" }, + [1193] = { "FLAG_UNUSED_0x4A9", "UNUSED_0x4A9" }, + [1194] = { "FLAG_UNUSED_0x4AA", "UNUSED_0x4AA" }, + [1195] = { "FLAG_UNUSED_0x4AB", "UNUSED_0x4AB" }, + [1196] = { "FLAG_UNUSED_0x4AC", "UNUSED_0x4AC" }, + [1197] = { "FLAG_UNUSED_0x4AD", "UNUSED_0x4AD" }, + [1198] = { "FLAG_UNUSED_0x4AE", "UNUSED_0x4AE" }, + [1199] = { "FLAG_UNUSED_0x4AF", "UNUSED_0x4AF" }, + [1200] = { "FLAG_DEFEATED_BROCK", "DEFEATED_BROCK" }, + [1201] = { "FLAG_DEFEATED_MISTY", "DEFEATED_MISTY" }, + [1202] = { "FLAG_DEFEATED_LT_SURGE", "DEFEATED_LT_SURGE" }, + [1203] = { "FLAG_DEFEATED_ERIKA", "DEFEATED_ERIKA" }, + [1204] = { "FLAG_DEFEATED_KOGA", "DEFEATED_KOGA" }, + [1205] = { "FLAG_DEFEATED_SABRINA", "DEFEATED_SABRINA" }, + [1206] = { "FLAG_DEFEATED_BLAINE", "DEFEATED_BLAINE" }, + [1207] = { "FLAG_DEFEATED_LEADER_GIOVANNI", "DEFEATED_LEADER_GIOVANNI" }, + [1208] = { "FLAG_DEFEATED_LORELEI", "DEFEATED_LORELEI" }, + [1209] = { "FLAG_DEFEATED_BRUNO", "DEFEATED_BRUNO" }, + [1210] = { "FLAG_DEFEATED_AGATHA", "DEFEATED_AGATHA" }, + [1211] = { "FLAG_DEFEATED_LANCE", "DEFEATED_LANCE" }, + [1212] = { "FLAG_DEFEATED_CHAMP", "DEFEATED_CHAMP" }, + [1213] = { "FLAG_0x4BD", "0x4BD" }, + [1214] = { "FLAG_0x4BE", "0x4BE" }, + [1215] = { "FLAG_0x4BF", "0x4BF" }, + [1216] = { "FLAG_0x4C0", "0x4C0" }, + [1217] = { "FLAG_0x4C1", "0x4C1" }, + [1218] = { "FLAG_0x4C2", "0x4C2" }, + [1219] = { "FLAG_0x4C3", "0x4C3" }, + [1220] = { "FLAG_0x4C4", "0x4C4" }, + [1221] = { "FLAG_0x4C5", "0x4C5" }, + [1222] = { "FLAG_0x4C6", "0x4C6" }, + [1223] = { "FLAG_0x4C7", "0x4C7" }, + [1224] = { "FLAG_0x4C8", "0x4C8" }, + [1225] = { "FLAG_0x4C9", "0x4C9" }, + [1226] = { "FLAG_0x4CA", "0x4CA" }, + [1227] = { "FLAG_0x4CB", "0x4CB" }, + [1228] = { "FLAG_0x4CC", "0x4CC" }, + [1229] = { "FLAG_0x4CD", "0x4CD" }, + [1230] = { "FLAG_0x4CE", "0x4CE" }, + [1231] = { "FLAG_0x4CF", "0x4CF" }, + [1232] = { "FLAG_0x4D0", "0x4D0" }, + [1233] = { "FLAG_0x4D1", "0x4D1" }, + [1234] = { "FLAG_0x4D2", "0x4D2" }, + [1235] = { "FLAG_0x4D3", "0x4D3" }, + [1236] = { "FLAG_0x4D4", "0x4D4" }, + [1237] = { "FLAG_0x4D5", "0x4D5" }, + [1238] = { "FLAG_0x4D6", "0x4D6" }, + [1239] = { "FLAG_0x4D7", "0x4D7" }, + [1240] = { "FLAG_0x4D8", "0x4D8" }, + [1241] = { "FLAG_0x4D9", "0x4D9" }, + [1242] = { "FLAG_0x4DA", "0x4DA" }, + [1243] = { "FLAG_0x4DB", "0x4DB" }, + [1244] = { "FLAG_0x4DC", "0x4DC" }, + [1245] = { "FLAG_0x4DD", "0x4DD" }, + [1246] = { "FLAG_0x4DE", "0x4DE" }, + [1247] = { "FLAG_0x4DF", "0x4DF" }, + [1248] = { "FLAG_0x4E0", "0x4E0" }, + [1249] = { "FLAG_0x4E1", "0x4E1" }, + [1250] = { "FLAG_0x4E2", "0x4E2" }, + [1251] = { "FLAG_0x4E3", "0x4E3" }, + [1252] = { "FLAG_0x4E4", "0x4E4" }, + [1253] = { "FLAG_0x4E5", "0x4E5" }, + [1254] = { "FLAG_0x4E6", "0x4E6" }, + [1255] = { "FLAG_0x4E7", "0x4E7" }, + [1256] = { "FLAG_0x4E8", "0x4E8" }, + [1257] = { "FLAG_0x4E9", "0x4E9" }, + [1258] = { "FLAG_0x4EA", "0x4EA" }, + [1259] = { "FLAG_0x4EB", "0x4EB" }, + [1260] = { "FLAG_0x4EC", "0x4EC" }, + [1261] = { "FLAG_0x4ED", "0x4ED" }, + [1262] = { "FLAG_0x4EE", "0x4EE" }, + [1263] = { "FLAG_0x4EF", "0x4EF" }, + [1264] = { "FLAG_0x4F0", "0x4F0" }, + [1265] = { "FLAG_0x4F1", "0x4F1" }, + [1266] = { "FLAG_0x4F2", "0x4F2" }, + [1267] = { "FLAG_0x4F3", "0x4F3" }, + [1268] = { "FLAG_0x4F4", "0x4F4" }, + [1269] = { "FLAG_0x4F5", "0x4F5" }, + [1270] = { "FLAG_0x4F6", "0x4F6" }, + [1271] = { "FLAG_0x4F7", "0x4F7" }, + [1272] = { "FLAG_0x4F8", "0x4F8" }, + [1273] = { "FLAG_0x4F9", "0x4F9" }, + [1274] = { "FLAG_0x4FA", "0x4FA" }, + [1275] = { "FLAG_0x4FB", "0x4FB" }, + [1276] = { "FLAG_0x4FC", "0x4FC" }, + [1277] = { "FLAG_0x4FD", "0x4FD" }, + [1278] = { "FLAG_0x4FE", "0x4FE" }, + [1279] = { "FLAG_0x4FF", "0x4FF" }, + [1280] = { false, "TRAINER_FLAGS_START" }, + [2047] = { false, "TRAINER_FLAGS_END" }, + [2048] = { "FLAG_SYS_SAFARI_MODE", "SYS_FLAGS", "SYS_SAFARI_MODE" }, + [2049] = { "FLAG_SYS_VS_SEEKER_CHARGING", "SYS_VS_SEEKER_CHARGING" }, + [2050] = { "FLAG_SYS_CRUISE_MODE", "SYS_CRUISE_MODE" }, + [2051] = { "FLAG_SYS_WHITE_FLUTE_ACTIVE", "SYS_WHITE_FLUTE_ACTIVE" }, + [2052] = { "FLAG_SYS_BLACK_FLUTE_ACTIVE", "SYS_BLACK_FLUTE_ACTIVE" }, + [2053] = { "FLAG_SYS_USE_STRENGTH", "SYS_USE_STRENGTH" }, + [2054] = { "FLAG_SYS_FLASH_ACTIVE", "SYS_FLASH_ACTIVE" }, + [2055] = { "FLAG_SYS_SPECIAL_WILD_BATTLE", "SYS_SPECIAL_WILD_BATTLE" }, + [2056] = { "FLAG_SYS_QL_DEPARTED", "SYS_QL_DEPARTED" }, + [2057] = { "FLAG_0x809", "0x809" }, + [2058] = { "FLAG_0x80A", "0x80A" }, + [2059] = { "FLAG_0x80B", "0x80B" }, + [2060] = { "FLAG_0x80C", "0x80C" }, + [2061] = { "FLAG_0x80D", "0x80D" }, + [2062] = { "FLAG_0x80E", "0x80E" }, + [2063] = { "FLAG_0x80F", "0x80F" }, + [2064] = { "FLAG_0x810", "0x810" }, + [2065] = { "FLAG_0x811", "0x811" }, + [2066] = { "FLAG_0x812", "0x812" }, + [2067] = { "FLAG_0x813", "0x813" }, + [2068] = { "FLAG_0x814", "0x814" }, + [2069] = { "FLAG_0x815", "0x815" }, + [2070] = { "FLAG_0x816", "0x816" }, + [2071] = { "FLAG_0x817", "0x817" }, + [2072] = { "FLAG_0x818", "0x818" }, + [2073] = { "FLAG_0x819", "0x819" }, + [2074] = { "FLAG_0x81A", "0x81A" }, + [2075] = { "FLAG_0x81B", "0x81B" }, + [2076] = { "FLAG_0x81C", "0x81C" }, + [2077] = { "FLAG_0x81D", "0x81D" }, + [2078] = { "FLAG_0x81E", "0x81E" }, + [2079] = { "FLAG_0x81F", "0x81F" }, + [2080] = { "FLAG_BADGE01_GET", "BADGE01_GET", "PERMA_SYS_FLAGS_START" }, + [2081] = { "FLAG_BADGE02_GET", "BADGE02_GET" }, + [2082] = { "FLAG_BADGE03_GET", "BADGE03_GET" }, + [2083] = { "FLAG_BADGE04_GET", "BADGE04_GET" }, + [2084] = { "FLAG_BADGE05_GET", "BADGE05_GET" }, + [2085] = { "FLAG_BADGE06_GET", "BADGE06_GET" }, + [2086] = { "FLAG_BADGE07_GET", "BADGE07_GET" }, + [2087] = { "FLAG_BADGE08_GET", "BADGE08_GET" }, + [2088] = { "FLAG_SYS_POKEMON_GET", "SYS_POKEMON_GET" }, + [2089] = { "FLAG_SYS_POKEDEX_GET", "SYS_POKEDEX_GET" }, + [2090] = { "FLAG_0x82A", "0x82A" }, + [2091] = { "FLAG_0x82B", "0x82B" }, + [2092] = { "FLAG_SYS_GAME_CLEAR", "SYS_GAME_CLEAR" }, + [2093] = { "FLAG_SYS_SET_TRAINER_CARD_PROFILE", "SYS_SET_TRAINER_CARD_PROFILE" }, + [2094] = { "FLAG_0x82E", "0x82E" }, + [2095] = { "FLAG_SYS_B_DASH", "SYS_B_DASH" }, + [2096] = { "FLAG_SYS_ON_CYCLING_ROAD", "SYS_ON_CYCLING_ROAD" }, + [2097] = { "FLAG_0x831", "0x831" }, + [2098] = { "FLAG_0x832", "0x832" }, + [2099] = { "FLAG_0x833", "0x833" }, + [2100] = { "FLAG_SYS_NOT_SOMEONES_PC", "SYS_NOT_SOMEONES_PC" }, + [2101] = { "FLAG_0x835", "0x835" }, + [2102] = { "FLAG_0x836", "0x836" }, + [2103] = { "FLAG_SYS_RESET_RTC_ENABLE", "SYS_RESET_RTC_ENABLE" }, + [2104] = { "FLAG_0x838", "0x838" }, + [2105] = { "FLAG_SYS_MYSTERY_GIFT_ENABLED", "SYS_MYSTERY_GIFT_ENABLED" }, + [2106] = { "FLAG_0x83A", "0x83A" }, + [2107] = { "FLAG_SYS_RIBBON_GET", "SYS_RIBBON_GET" }, + [2108] = { "FLAG_SYS_SAW_HELP_SYSTEM_INTRO", "SYS_SAW_HELP_SYSTEM_INTRO" }, + [2109] = { "FLAG_0x83D", "0x83D" }, + [2110] = { "FLAG_OPENED_START_MENU", "OPENED_START_MENU" }, + [2111] = { "FLAG_0x83F", "0x83F" }, + [2112] = { "FLAG_SYS_NATIONAL_DEX", "SYS_NATIONAL_DEX" }, + [2113] = { "FLAG_SYS_PC_STORAGE_DISABLED", "SYS_PC_STORAGE_DISABLED" }, + [2114] = { "FLAG_SYS_INFORMED_OF_LOCAL_WIRELESS_PLAYER", "SYS_INFORMED_OF_LOCAL_WIRELESS_PLAYER" }, + [2115] = { "FLAG_SHOWN_BOX_WAS_FULL_MESSAGE", "SHOWN_BOX_WAS_FULL_MESSAGE" }, + [2116] = { "FLAG_SYS_CAN_LINK_WITH_RS", "SYS_CAN_LINK_WITH_RS" }, + [2117] = { "FLAG_SYS_SEVII_MAP_123", "SYS_SEVII_MAP_123" }, + [2118] = { "FLAG_SYS_SEVII_MAP_4567", "SYS_SEVII_MAP_4567" }, + [2119] = { "FLAG_SYS_GOT_BERRY_POUCH", "SYS_GOT_BERRY_POUCH" }, + [2120] = { "FLAG_SYS_DEOXYS_AWAKENED", "SYS_DEOXYS_AWAKENED" }, + [2121] = { "FLAG_SYS_UNLOCKED_TANOBY_RUINS", "SYS_UNLOCKED_TANOBY_RUINS" }, + [2122] = { "FLAG_ENABLE_SHIP_NAVEL_ROCK", "ENABLE_SHIP_NAVEL_ROCK" }, + [2123] = { "FLAG_ENABLE_SHIP_BIRTH_ISLAND", "ENABLE_SHIP_BIRTH_ISLAND" }, + [2192] = { "FLAG_WORLD_MAP_PALLET_TOWN", "WORLD_MAP_PALLET_TOWN" }, + [2193] = { "FLAG_WORLD_MAP_VIRIDIAN_CITY", "WORLD_MAP_VIRIDIAN_CITY" }, + [2194] = { "FLAG_WORLD_MAP_PEWTER_CITY", "WORLD_MAP_PEWTER_CITY" }, + [2195] = { "FLAG_WORLD_MAP_CERULEAN_CITY", "WORLD_MAP_CERULEAN_CITY" }, + [2196] = { "FLAG_WORLD_MAP_LAVENDER_TOWN", "WORLD_MAP_LAVENDER_TOWN" }, + [2197] = { "FLAG_WORLD_MAP_VERMILION_CITY", "WORLD_MAP_VERMILION_CITY" }, + [2198] = { "FLAG_WORLD_MAP_CELADON_CITY", "WORLD_MAP_CELADON_CITY" }, + [2199] = { "FLAG_WORLD_MAP_FUCHSIA_CITY", "WORLD_MAP_FUCHSIA_CITY" }, + [2200] = { "FLAG_WORLD_MAP_CINNABAR_ISLAND", "WORLD_MAP_CINNABAR_ISLAND" }, + [2201] = { "FLAG_WORLD_MAP_INDIGO_PLATEAU_EXTERIOR", "WORLD_MAP_INDIGO_PLATEAU_EXTERIOR" }, + [2202] = { "FLAG_WORLD_MAP_SAFFRON_CITY", "WORLD_MAP_SAFFRON_CITY" }, + [2203] = { "FLAG_WORLD_MAP_ONE_ISLAND", "WORLD_MAP_ONE_ISLAND" }, + [2204] = { "FLAG_WORLD_MAP_TWO_ISLAND", "WORLD_MAP_TWO_ISLAND" }, + [2205] = { "FLAG_WORLD_MAP_THREE_ISLAND", "WORLD_MAP_THREE_ISLAND" }, + [2206] = { "FLAG_WORLD_MAP_FOUR_ISLAND", "WORLD_MAP_FOUR_ISLAND" }, + [2207] = { "FLAG_WORLD_MAP_FIVE_ISLAND", "WORLD_MAP_FIVE_ISLAND" }, + [2208] = { "FLAG_WORLD_MAP_SEVEN_ISLAND", "WORLD_MAP_SEVEN_ISLAND" }, + [2209] = { "FLAG_WORLD_MAP_SIX_ISLAND", "WORLD_MAP_SIX_ISLAND" }, + [2210] = { "FLAG_WORLD_MAP_ROUTE4_POKEMON_CENTER_1F", "WORLD_MAP_ROUTE4_POKEMON_CENTER_1F" }, + [2211] = { "FLAG_WORLD_MAP_ROUTE10_POKEMON_CENTER_1F", "WORLD_MAP_ROUTE10_POKEMON_CENTER_1F" }, + [2212] = { "FLAG_WORLD_MAP_VIRIDIAN_FOREST", "WORLD_MAP_VIRIDIAN_FOREST" }, + [2213] = { "FLAG_WORLD_MAP_MT_MOON_1F", "WORLD_MAP_MT_MOON_1F" }, + [2214] = { "FLAG_WORLD_MAP_SSANNE_EXTERIOR", "WORLD_MAP_SSANNE_EXTERIOR" }, + [2215] = { "FLAG_WORLD_MAP_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL", "WORLD_MAP_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL" }, + [2216] = { "FLAG_WORLD_MAP_UNDERGROUND_PATH_EAST_WEST_TUNNEL", "WORLD_MAP_UNDERGROUND_PATH_EAST_WEST_TUNNEL" }, + [2217] = { "FLAG_WORLD_MAP_DIGLETTS_CAVE_B1F", "WORLD_MAP_DIGLETTS_CAVE_B1F" }, + [2218] = { "FLAG_WORLD_MAP_VICTORY_ROAD_1F", "WORLD_MAP_VICTORY_ROAD_1F" }, + [2219] = { "FLAG_WORLD_MAP_ROCKET_HIDEOUT_B1F", "WORLD_MAP_ROCKET_HIDEOUT_B1F" }, + [2220] = { "FLAG_WORLD_MAP_SILPH_CO_1F", "WORLD_MAP_SILPH_CO_1F" }, + [2221] = { "FLAG_WORLD_MAP_POKEMON_MANSION_1F", "WORLD_MAP_POKEMON_MANSION_1F" }, + [2222] = { "FLAG_WORLD_MAP_SAFARI_ZONE_CENTER", "WORLD_MAP_SAFARI_ZONE_CENTER" }, + [2223] = { "FLAG_WORLD_MAP_POKEMON_LEAGUE_LORELEIS_ROOM", "WORLD_MAP_POKEMON_LEAGUE_LORELEIS_ROOM" }, + [2224] = { "FLAG_WORLD_MAP_ROCK_TUNNEL_1F", "WORLD_MAP_ROCK_TUNNEL_1F" }, + [2225] = { "FLAG_WORLD_MAP_SEAFOAM_ISLANDS_1F", "WORLD_MAP_SEAFOAM_ISLANDS_1F" }, + [2226] = { "FLAG_WORLD_MAP_POKEMON_TOWER_1F", "WORLD_MAP_POKEMON_TOWER_1F" }, + [2227] = { "FLAG_WORLD_MAP_CERULEAN_CAVE_1F", "WORLD_MAP_CERULEAN_CAVE_1F" }, + [2228] = { "FLAG_WORLD_MAP_POWER_PLANT", "WORLD_MAP_POWER_PLANT" }, + [2229] = { "FLAG_WORLD_MAP_NAVEL_ROCK_EXTERIOR", "WORLD_MAP_NAVEL_ROCK_EXTERIOR" }, + [2230] = { "FLAG_WORLD_MAP_MT_EMBER_EXTERIOR", "WORLD_MAP_MT_EMBER_EXTERIOR" }, + [2231] = { "FLAG_WORLD_MAP_THREE_ISLAND_BERRY_FOREST", "WORLD_MAP_THREE_ISLAND_BERRY_FOREST" }, + [2232] = { "FLAG_WORLD_MAP_FOUR_ISLAND_ICEFALL_CAVE_ENTRANCE", "WORLD_MAP_FOUR_ISLAND_ICEFALL_CAVE_ENTRANCE" }, + [2233] = { "FLAG_WORLD_MAP_FIVE_ISLAND_ROCKET_WAREHOUSE", "WORLD_MAP_FIVE_ISLAND_ROCKET_WAREHOUSE" }, + [2234] = { "FLAG_WORLD_MAP_TRAINER_TOWER_LOBBY", "WORLD_MAP_TRAINER_TOWER_LOBBY" }, + [2235] = { "FLAG_WORLD_MAP_SIX_ISLAND_DOTTED_HOLE_1F", "WORLD_MAP_SIX_ISLAND_DOTTED_HOLE_1F" }, + [2236] = { "FLAG_WORLD_MAP_FIVE_ISLAND_LOST_CAVE_ENTRANCE", "WORLD_MAP_FIVE_ISLAND_LOST_CAVE_ENTRANCE" }, + [2237] = { "FLAG_WORLD_MAP_SIX_ISLAND_PATTERN_BUSH", "WORLD_MAP_SIX_ISLAND_PATTERN_BUSH" }, + [2238] = { "FLAG_WORLD_MAP_SIX_ISLAND_ALTERING_CAVE", "WORLD_MAP_SIX_ISLAND_ALTERING_CAVE" }, + [2239] = { "FLAG_WORLD_MAP_SEVEN_ISLAND_TANOBY_RUINS_MONEAN_CHAMBER", "WORLD_MAP_SEVEN_ISLAND_TANOBY_RUINS_MONEAN_CHAMBER" }, + [2240] = { "FLAG_WORLD_MAP_THREE_ISLAND_DUNSPARCE_TUNNEL", "WORLD_MAP_THREE_ISLAND_DUNSPARCE_TUNNEL" }, + [2241] = { "FLAG_WORLD_MAP_SEVEN_ISLAND_SEVAULT_CANYON_TANOBY_KEY", "WORLD_MAP_SEVEN_ISLAND_SEVAULT_CANYON_TANOBY_KEY" }, + [2242] = { "FLAG_WORLD_MAP_BIRTH_ISLAND_EXTERIOR", "WORLD_MAP_BIRTH_ISLAND_EXTERIOR" }, + [2243] = { "FLAG_0x8C3", "0x8C3" }, + [2244] = { "FLAG_0x8C4", "0x8C4" }, + [2245] = { "FLAG_0x8C5", "0x8C5" }, + [2246] = { "FLAG_0x8C6", "0x8C6" }, + [2247] = { "FLAG_0x8C7", "0x8C7" }, + [2248] = { "FLAG_0x8C8", "0x8C8" }, + [2249] = { "FLAG_0x8C9", "0x8C9" }, + [2250] = { "FLAG_0x8CA", "0x8CA" }, + [2251] = { "FLAG_0x8CB", "0x8CB" }, + [2252] = { "FLAG_0x8CC", "0x8CC" }, + [2253] = { "FLAG_0x8CD", "0x8CD" }, + [2254] = { "FLAG_0x8CE", "0x8CE" }, + [2255] = { "FLAG_0x8CF", "0x8CF" }, + [2256] = { "FLAG_0x8D0", "0x8D0" }, + [2257] = { "FLAG_0x8D1", "0x8D1" }, + [2258] = { "FLAG_0x8D2", "0x8D2" }, + [2259] = { "FLAG_0x8D3", "0x8D3" }, + [2260] = { "FLAG_0x8D4", "0x8D4" }, + [2261] = { "FLAG_0x8D5", "0x8D5" }, + [2262] = { "FLAG_0x8D6", "0x8D6" }, + [2263] = { "FLAG_0x8D7", "0x8D7" }, + [2264] = { "FLAG_0x8D8", "0x8D8" }, + [2265] = { "FLAG_0x8D9", "0x8D9" }, + [2266] = { "FLAG_0x8DA", "0x8DA" }, + [2267] = { "FLAG_0x8DB", "0x8DB" }, + [2268] = { "FLAG_0x8DC", "0x8DC" }, + [2269] = { "FLAG_0x8DD", "0x8DD" }, + [2270] = { "FLAG_0x8DE", "0x8DE" }, + [2271] = { "FLAG_0x8DF", "0x8DF" }, + [2272] = { "FLAG_0x8E0", "0x8E0" }, + [2273] = { "FLAG_0x8E1", "0x8E1" }, + [2274] = { "FLAG_0x8E2", "0x8E2" }, + [2275] = { "FLAG_0x8E3", "0x8E3" }, + [2276] = { "FLAG_0x8E4", "0x8E4" }, + [2277] = { "FLAG_0x8E5", "0x8E5" }, + [2278] = { "FLAG_0x8E6", "0x8E6" }, + [2279] = { "FLAG_0x8E7", "0x8E7" }, + [2280] = { "FLAG_0x8E8", "0x8E8" }, + [2281] = { "FLAG_0x8E9", "0x8E9" }, + [2282] = { "FLAG_0x8EA", "0x8EA" }, + [2283] = { "FLAG_0x8EB", "0x8EB" }, + [2284] = { "FLAG_0x8EC", "0x8EC" }, + [2285] = { "FLAG_0x8ED", "0x8ED" }, + [2286] = { "FLAG_0x8EE", "0x8EE" }, + [2287] = { "FLAG_0x8EF", "0x8EF" }, + [2288] = { "FLAG_0x8F0", "0x8F0" }, + [2289] = { "FLAG_0x8F1", "0x8F1" }, + [2290] = { "FLAG_0x8F2", "0x8F2" }, + [2291] = { "FLAG_0x8F3", "0x8F3" }, + [2292] = { "FLAG_0x8F4", "0x8F4" }, + [2293] = { "FLAG_0x8F5", "0x8F5" }, + [2294] = { "FLAG_0x8F6", "0x8F6" }, + [2295] = { "FLAG_0x8F7", "0x8F7" }, + [2296] = { "FLAG_0x8F8", "0x8F8" }, + [2297] = { "FLAG_0x8F9", "0x8F9" }, + [2298] = { "FLAG_0x8FA", "0x8FA" }, + [2299] = { "FLAG_0x8FB", "0x8FB" }, + [2300] = { "FLAG_0x8FC", "0x8FC" }, + [2301] = { "FLAG_0x8FD", "0x8FD" }, + [2302] = { "FLAG_0x8FE", "0x8FE" }, + [2303] = { "FLAG_0x8FF", "0x8FF" }, + }, +} diff --git a/src/mods/flag_names/red.lua b/src/mods/flag_names/red.lua new file mode 100644 index 000000000..14899c835 --- /dev/null +++ b/src/mods/flag_names/red.lua @@ -0,0 +1,514 @@ +-- Generated flag identifiers only; no engine implementation. +-- Source: bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 +-- Regenerate with tools/flag-catalog-check/generate.py. +return { + records = { + [0] = { "EVENT_FOLLOWED_OAK_INTO_LAB" }, + [3] = { "EVENT_HALL_OF_FAME_DEX_RATING" }, + [6] = { "EVENT_PALLET_AFTER_GETTING_POKEBALLS" }, + [24] = { "EVENT_GOT_TOWN_MAP" }, + [25] = { "EVENT_ENTERED_BLUES_HOUSE" }, + [26] = { "EVENT_DAISY_WALKING" }, + [32] = { "EVENT_FOLLOWED_OAK_INTO_LAB_2" }, + [33] = { "EVENT_OAK_ASKED_TO_CHOOSE_MON" }, + [34] = { "EVENT_GOT_STARTER" }, + [35] = { "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, + [36] = { "EVENT_GOT_POKEBALLS_FROM_OAK" }, + [37] = { "EVENT_GOT_POKEDEX" }, + [38] = { "EVENT_PALLET_AFTER_GETTING_POKEBALLS_2" }, + [39] = { "EVENT_OAK_APPEARED_IN_PALLET" }, + [40] = { "EVENT_VIRIDIAN_GYM_OPEN" }, + [41] = { "EVENT_GOT_TM42" }, + [56] = { "EVENT_OAK_GOT_PARCEL" }, + [57] = { "EVENT_GOT_OAKS_PARCEL" }, + [80] = { "EVENT_GOT_TM27" }, + [81] = { "EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI" }, + [82] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_0" }, + [83] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_1" }, + [84] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_2" }, + [85] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_3" }, + [86] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_4" }, + [87] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_5" }, + [88] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_6" }, + [89] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_7" }, + [104] = { "EVENT_BOUGHT_MUSEUM_TICKET" }, + [105] = { "EVENT_GOT_OLD_AMBER" }, + [114] = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" }, + [118] = { "EVENT_GOT_TM34" }, + [119] = { "EVENT_BEAT_BROCK" }, + [152] = { "EVENT_BEAT_CERULEAN_RIVAL" }, + [167] = { "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, + [186] = { "EVENT_BEAT_CERULEAN_GYM_TRAINER_0" }, + [187] = { "EVENT_BEAT_CERULEAN_GYM_TRAINER_1" }, + [190] = { "EVENT_GOT_TM11" }, + [191] = { "EVENT_BEAT_MISTY" }, + [192] = { "EVENT_GOT_BICYCLE" }, + [238] = { "EVENT_POKEMON_TOWER_RIVAL_ON_LEFT" }, + [239] = { "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, + [241] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_0" }, + [242] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_1" }, + [243] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_2" }, + [249] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_0" }, + [250] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_1" }, + [251] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_2" }, + [258] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_0" }, + [259] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_1" }, + [260] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_2" }, + [261] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_3" }, + [263] = { "EVENT_IN_PURIFIED_ZONE" }, + [265] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_0" }, + [266] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_1" }, + [267] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_2" }, + [271] = { "EVENT_BEAT_GHOST_MAROWAK" }, + [273] = { "EVENT_BEAT_POKEMONTOWER_7_TRAINER_0" }, + [274] = { "EVENT_BEAT_POKEMONTOWER_7_TRAINER_1" }, + [275] = { "EVENT_BEAT_POKEMONTOWER_7_TRAINER_2" }, + [279] = { "EVENT_RESCUED_MR_FUJI_2" }, + [296] = { "EVENT_GOT_POKE_FLUTE" }, + [337] = { "EVENT_GOT_BIKE_VOUCHER" }, + [342] = { "EVENT_SEEL_FAN_BOAST" }, + [343] = { "EVENT_PIKACHU_FAN_BOAST" }, + [352] = { "EVENT_2ND_LOCK_OPENED" }, + [353] = { "EVENT_1ST_LOCK_OPENED" }, + [354] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_0" }, + [355] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_1" }, + [356] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_2" }, + [358] = { "EVENT_GOT_TM24" }, + [359] = { "EVENT_BEAT_LT_SURGE" }, + [384] = { "EVENT_GOT_TM41" }, + [396] = { "EVENT_GOT_TM13" }, + [397] = { "EVENT_GOT_TM48" }, + [398] = { "EVENT_GOT_TM49" }, + [399] = { "EVENT_GOT_TM18" }, + [424] = { "EVENT_GOT_TM21" }, + [425] = { "EVENT_BEAT_ERIKA" }, + [426] = { "EVENT_BEAT_CELADON_GYM_TRAINER_0" }, + [427] = { "EVENT_BEAT_CELADON_GYM_TRAINER_1" }, + [428] = { "EVENT_BEAT_CELADON_GYM_TRAINER_2" }, + [429] = { "EVENT_BEAT_CELADON_GYM_TRAINER_3" }, + [430] = { "EVENT_BEAT_CELADON_GYM_TRAINER_4" }, + [431] = { "EVENT_BEAT_CELADON_GYM_TRAINER_5" }, + [432] = { "EVENT_BEAT_CELADON_GYM_TRAINER_6" }, + [440] = { "EVENT_1B8" }, + [441] = { "EVENT_FOUND_ROCKET_HIDEOUT" }, + [442] = { "EVENT_GOT_10_COINS" }, + [443] = { "EVENT_GOT_20_COINS" }, + [444] = { "EVENT_GOT_20_COINS_2" }, + [447] = { "EVENT_1BF" }, + [480] = { "EVENT_GOT_COIN_CASE" }, + [568] = { "EVENT_GOT_HM04" }, + [569] = { "EVENT_GAVE_GOLD_TEETH" }, + [590] = { "EVENT_SAFARI_GAME_OVER" }, + [591] = { "EVENT_IN_SAFARI_ZONE" }, + [600] = { "EVENT_GOT_TM06" }, + [601] = { "EVENT_BEAT_KOGA" }, + [602] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_0" }, + [603] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_1" }, + [604] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_2" }, + [605] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_3" }, + [606] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_4" }, + [607] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_5" }, + [632] = { "EVENT_MANSION_SWITCH_ON" }, + [649] = { "EVENT_BEAT_MANSION_1_TRAINER_0" }, + [664] = { "EVENT_GOT_TM38" }, + [665] = { "EVENT_BEAT_BLAINE" }, + [666] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_0" }, + [667] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_1" }, + [668] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_2" }, + [669] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_3" }, + [670] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_4" }, + [671] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_5" }, + [672] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_6" }, + [679] = { "EVENT_2A7" }, + [680] = { "EVENT_CINNABAR_GYM_GATE0_UNLOCKED" }, + [681] = { "EVENT_CINNABAR_GYM_GATE1_UNLOCKED" }, + [682] = { "EVENT_CINNABAR_GYM_GATE2_UNLOCKED" }, + [683] = { "EVENT_CINNABAR_GYM_GATE3_UNLOCKED" }, + [684] = { "EVENT_CINNABAR_GYM_GATE4_UNLOCKED" }, + [685] = { "EVENT_CINNABAR_GYM_GATE5_UNLOCKED" }, + [686] = { "EVENT_CINNABAR_GYM_GATE6_UNLOCKED" }, + [727] = { "EVENT_GOT_TM35" }, + [736] = { "EVENT_GAVE_FOSSIL_TO_LAB" }, + [737] = { "EVENT_LAB_STILL_REVIVING_FOSSIL" }, + [738] = { "EVENT_LAB_HANDING_OVER_FOSSIL_MON" }, + [832] = { "EVENT_GOT_TM31" }, + [848] = { "EVENT_DEFEATED_FIGHTING_DOJO" }, + [849] = { "EVENT_BEAT_KARATE_MASTER" }, + [850] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_0" }, + [851] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_1" }, + [852] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_2" }, + [853] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_3" }, + [854] = { "EVENT_GOT_HITMONLEE" }, + [855] = { "EVENT_GOT_HITMONCHAN" }, + [864] = { "EVENT_GOT_TM46" }, + [865] = { "EVENT_BEAT_SABRINA" }, + [866] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_0" }, + [867] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_1" }, + [868] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_2" }, + [869] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_3" }, + [870] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_4" }, + [871] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_5" }, + [872] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_6" }, + [919] = { "EVENT_SILPH_CO_RECEPTIONIST_AT_DESK" }, + [944] = { "EVENT_GOT_TM29" }, + [960] = { "EVENT_GOT_POTION_SAMPLE" }, + [984] = { "EVENT_GOT_HM05" }, + [994] = { "EVENT_BEAT_ROUTE_3_TRAINER_0" }, + [995] = { "EVENT_BEAT_ROUTE_3_TRAINER_1" }, + [996] = { "EVENT_BEAT_ROUTE_3_TRAINER_2" }, + [997] = { "EVENT_BEAT_ROUTE_3_TRAINER_3" }, + [998] = { "EVENT_BEAT_ROUTE_3_TRAINER_4" }, + [999] = { "EVENT_BEAT_ROUTE_3_TRAINER_5" }, + [1000] = { "EVENT_BEAT_ROUTE_3_TRAINER_6" }, + [1001] = { "EVENT_BEAT_ROUTE_3_TRAINER_7" }, + [1010] = { "EVENT_BEAT_ROUTE_4_TRAINER_0" }, + [1023] = { "EVENT_BOUGHT_MAGIKARP" }, + [1041] = { "EVENT_BEAT_ROUTE_6_TRAINER_0" }, + [1042] = { "EVENT_BEAT_ROUTE_6_TRAINER_1" }, + [1043] = { "EVENT_BEAT_ROUTE_6_TRAINER_2" }, + [1044] = { "EVENT_BEAT_ROUTE_6_TRAINER_3" }, + [1045] = { "EVENT_BEAT_ROUTE_6_TRAINER_4" }, + [1046] = { "EVENT_BEAT_ROUTE_6_TRAINER_5" }, + [1073] = { "EVENT_BEAT_ROUTE_8_TRAINER_0" }, + [1074] = { "EVENT_BEAT_ROUTE_8_TRAINER_1" }, + [1075] = { "EVENT_BEAT_ROUTE_8_TRAINER_2" }, + [1076] = { "EVENT_BEAT_ROUTE_8_TRAINER_3" }, + [1077] = { "EVENT_BEAT_ROUTE_8_TRAINER_4" }, + [1078] = { "EVENT_BEAT_ROUTE_8_TRAINER_5" }, + [1079] = { "EVENT_BEAT_ROUTE_8_TRAINER_6" }, + [1080] = { "EVENT_BEAT_ROUTE_8_TRAINER_7" }, + [1081] = { "EVENT_BEAT_ROUTE_8_TRAINER_8" }, + [1089] = { "EVENT_BEAT_ROUTE_9_TRAINER_0" }, + [1090] = { "EVENT_BEAT_ROUTE_9_TRAINER_1" }, + [1091] = { "EVENT_BEAT_ROUTE_9_TRAINER_2" }, + [1092] = { "EVENT_BEAT_ROUTE_9_TRAINER_3" }, + [1093] = { "EVENT_BEAT_ROUTE_9_TRAINER_4" }, + [1094] = { "EVENT_BEAT_ROUTE_9_TRAINER_5" }, + [1095] = { "EVENT_BEAT_ROUTE_9_TRAINER_6" }, + [1096] = { "EVENT_BEAT_ROUTE_9_TRAINER_7" }, + [1097] = { "EVENT_BEAT_ROUTE_9_TRAINER_8" }, + [1105] = { "EVENT_BEAT_ROUTE_10_TRAINER_0" }, + [1106] = { "EVENT_BEAT_ROUTE_10_TRAINER_1" }, + [1107] = { "EVENT_BEAT_ROUTE_10_TRAINER_2" }, + [1108] = { "EVENT_BEAT_ROUTE_10_TRAINER_3" }, + [1109] = { "EVENT_BEAT_ROUTE_10_TRAINER_4" }, + [1110] = { "EVENT_BEAT_ROUTE_10_TRAINER_5" }, + [1113] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_0" }, + [1114] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_1" }, + [1115] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_2" }, + [1116] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_3" }, + [1117] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_4" }, + [1118] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_5" }, + [1119] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_6" }, + [1121] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_0" }, + [1122] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_1" }, + [1123] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_2" }, + [1124] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_3" }, + [1125] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_4" }, + [1126] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_5" }, + [1127] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_6" }, + [1128] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_7" }, + [1129] = { "EVENT_BEAT_ZAPDOS" }, + [1137] = { "EVENT_BEAT_ROUTE_11_TRAINER_0" }, + [1138] = { "EVENT_BEAT_ROUTE_11_TRAINER_1" }, + [1139] = { "EVENT_BEAT_ROUTE_11_TRAINER_2" }, + [1140] = { "EVENT_BEAT_ROUTE_11_TRAINER_3" }, + [1141] = { "EVENT_BEAT_ROUTE_11_TRAINER_4" }, + [1142] = { "EVENT_BEAT_ROUTE_11_TRAINER_5" }, + [1143] = { "EVENT_BEAT_ROUTE_11_TRAINER_6" }, + [1144] = { "EVENT_BEAT_ROUTE_11_TRAINER_7" }, + [1145] = { "EVENT_BEAT_ROUTE_11_TRAINER_8" }, + [1146] = { "EVENT_BEAT_ROUTE_11_TRAINER_9" }, + [1151] = { "EVENT_GOT_ITEMFINDER" }, + [1152] = { "EVENT_GOT_TM39" }, + [1154] = { "EVENT_BEAT_ROUTE_12_TRAINER_0" }, + [1155] = { "EVENT_BEAT_ROUTE_12_TRAINER_1" }, + [1156] = { "EVENT_BEAT_ROUTE_12_TRAINER_2" }, + [1157] = { "EVENT_BEAT_ROUTE_12_TRAINER_3" }, + [1158] = { "EVENT_BEAT_ROUTE_12_TRAINER_4" }, + [1159] = { "EVENT_BEAT_ROUTE_12_TRAINER_5" }, + [1160] = { "EVENT_BEAT_ROUTE_12_TRAINER_6" }, + [1166] = { "EVENT_FIGHT_ROUTE12_SNORLAX" }, + [1167] = { "EVENT_BEAT_ROUTE12_SNORLAX" }, + [1169] = { "EVENT_BEAT_ROUTE_13_TRAINER_0" }, + [1170] = { "EVENT_BEAT_ROUTE_13_TRAINER_1" }, + [1171] = { "EVENT_BEAT_ROUTE_13_TRAINER_2" }, + [1172] = { "EVENT_BEAT_ROUTE_13_TRAINER_3" }, + [1173] = { "EVENT_BEAT_ROUTE_13_TRAINER_4" }, + [1174] = { "EVENT_BEAT_ROUTE_13_TRAINER_5" }, + [1175] = { "EVENT_BEAT_ROUTE_13_TRAINER_6" }, + [1176] = { "EVENT_BEAT_ROUTE_13_TRAINER_7" }, + [1177] = { "EVENT_BEAT_ROUTE_13_TRAINER_8" }, + [1178] = { "EVENT_BEAT_ROUTE_13_TRAINER_9" }, + [1185] = { "EVENT_BEAT_ROUTE_14_TRAINER_0" }, + [1186] = { "EVENT_BEAT_ROUTE_14_TRAINER_1" }, + [1187] = { "EVENT_BEAT_ROUTE_14_TRAINER_2" }, + [1188] = { "EVENT_BEAT_ROUTE_14_TRAINER_3" }, + [1189] = { "EVENT_BEAT_ROUTE_14_TRAINER_4" }, + [1190] = { "EVENT_BEAT_ROUTE_14_TRAINER_5" }, + [1191] = { "EVENT_BEAT_ROUTE_14_TRAINER_6" }, + [1192] = { "EVENT_BEAT_ROUTE_14_TRAINER_7" }, + [1193] = { "EVENT_BEAT_ROUTE_14_TRAINER_8" }, + [1194] = { "EVENT_BEAT_ROUTE_14_TRAINER_9" }, + [1200] = { "EVENT_GOT_EXP_ALL" }, + [1201] = { "EVENT_BEAT_ROUTE_15_TRAINER_0" }, + [1202] = { "EVENT_BEAT_ROUTE_15_TRAINER_1" }, + [1203] = { "EVENT_BEAT_ROUTE_15_TRAINER_2" }, + [1204] = { "EVENT_BEAT_ROUTE_15_TRAINER_3" }, + [1205] = { "EVENT_BEAT_ROUTE_15_TRAINER_4" }, + [1206] = { "EVENT_BEAT_ROUTE_15_TRAINER_5" }, + [1207] = { "EVENT_BEAT_ROUTE_15_TRAINER_6" }, + [1208] = { "EVENT_BEAT_ROUTE_15_TRAINER_7" }, + [1209] = { "EVENT_BEAT_ROUTE_15_TRAINER_8" }, + [1210] = { "EVENT_BEAT_ROUTE_15_TRAINER_9" }, + [1217] = { "EVENT_BEAT_ROUTE_16_TRAINER_0" }, + [1218] = { "EVENT_BEAT_ROUTE_16_TRAINER_1" }, + [1219] = { "EVENT_BEAT_ROUTE_16_TRAINER_2" }, + [1220] = { "EVENT_BEAT_ROUTE_16_TRAINER_3" }, + [1221] = { "EVENT_BEAT_ROUTE_16_TRAINER_4" }, + [1222] = { "EVENT_BEAT_ROUTE_16_TRAINER_5" }, + [1224] = { "EVENT_FIGHT_ROUTE16_SNORLAX" }, + [1225] = { "EVENT_BEAT_ROUTE16_SNORLAX" }, + [1230] = { "EVENT_GOT_HM02" }, + [1231] = { "EVENT_RESCUED_MR_FUJI" }, + [1233] = { "EVENT_BEAT_ROUTE_17_TRAINER_0" }, + [1234] = { "EVENT_BEAT_ROUTE_17_TRAINER_1" }, + [1235] = { "EVENT_BEAT_ROUTE_17_TRAINER_2" }, + [1236] = { "EVENT_BEAT_ROUTE_17_TRAINER_3" }, + [1237] = { "EVENT_BEAT_ROUTE_17_TRAINER_4" }, + [1238] = { "EVENT_BEAT_ROUTE_17_TRAINER_5" }, + [1239] = { "EVENT_BEAT_ROUTE_17_TRAINER_6" }, + [1240] = { "EVENT_BEAT_ROUTE_17_TRAINER_7" }, + [1241] = { "EVENT_BEAT_ROUTE_17_TRAINER_8" }, + [1242] = { "EVENT_BEAT_ROUTE_17_TRAINER_9" }, + [1249] = { "EVENT_BEAT_ROUTE_18_TRAINER_0" }, + [1250] = { "EVENT_BEAT_ROUTE_18_TRAINER_1" }, + [1251] = { "EVENT_BEAT_ROUTE_18_TRAINER_2" }, + [1265] = { "EVENT_BEAT_ROUTE_19_TRAINER_0" }, + [1266] = { "EVENT_BEAT_ROUTE_19_TRAINER_1" }, + [1267] = { "EVENT_BEAT_ROUTE_19_TRAINER_2" }, + [1268] = { "EVENT_BEAT_ROUTE_19_TRAINER_3" }, + [1269] = { "EVENT_BEAT_ROUTE_19_TRAINER_4" }, + [1270] = { "EVENT_BEAT_ROUTE_19_TRAINER_5" }, + [1271] = { "EVENT_BEAT_ROUTE_19_TRAINER_6" }, + [1272] = { "EVENT_BEAT_ROUTE_19_TRAINER_7" }, + [1273] = { "EVENT_BEAT_ROUTE_19_TRAINER_8" }, + [1274] = { "EVENT_BEAT_ROUTE_19_TRAINER_9" }, + [1280] = { "EVENT_IN_SEAFOAM_ISLANDS" }, + [1281] = { "EVENT_BEAT_ROUTE_20_TRAINER_0" }, + [1282] = { "EVENT_BEAT_ROUTE_20_TRAINER_1" }, + [1283] = { "EVENT_BEAT_ROUTE_20_TRAINER_2" }, + [1284] = { "EVENT_BEAT_ROUTE_20_TRAINER_3" }, + [1285] = { "EVENT_BEAT_ROUTE_20_TRAINER_4" }, + [1286] = { "EVENT_BEAT_ROUTE_20_TRAINER_5" }, + [1287] = { "EVENT_BEAT_ROUTE_20_TRAINER_6" }, + [1288] = { "EVENT_BEAT_ROUTE_20_TRAINER_7" }, + [1289] = { "EVENT_BEAT_ROUTE_20_TRAINER_8" }, + [1290] = { "EVENT_BEAT_ROUTE_20_TRAINER_9" }, + [1294] = { "EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE" }, + [1295] = { "EVENT_SEAFOAM1_BOULDER2_DOWN_HOLE" }, + [1297] = { "EVENT_BEAT_ROUTE_21_TRAINER_0" }, + [1298] = { "EVENT_BEAT_ROUTE_21_TRAINER_1" }, + [1299] = { "EVENT_BEAT_ROUTE_21_TRAINER_2" }, + [1300] = { "EVENT_BEAT_ROUTE_21_TRAINER_3" }, + [1301] = { "EVENT_BEAT_ROUTE_21_TRAINER_4" }, + [1302] = { "EVENT_BEAT_ROUTE_21_TRAINER_5" }, + [1303] = { "EVENT_BEAT_ROUTE_21_TRAINER_6" }, + [1304] = { "EVENT_BEAT_ROUTE_21_TRAINER_7" }, + [1305] = { "EVENT_BEAT_ROUTE_21_TRAINER_8" }, + [1312] = { "EVENT_1ST_ROUTE22_RIVAL_BATTLE" }, + [1313] = { "EVENT_2ND_ROUTE22_RIVAL_BATTLE" }, + [1317] = { "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, + [1318] = { "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE" }, + [1319] = { "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" }, + [1328] = { "EVENT_PASSED_CASCADEBADGE_CHECK" }, + [1329] = { "EVENT_PASSED_THUNDERBADGE_CHECK" }, + [1330] = { "EVENT_PASSED_RAINBOWBADGE_CHECK" }, + [1331] = { "EVENT_PASSED_SOULBADGE_CHECK" }, + [1332] = { "EVENT_PASSED_MARSHBADGE_CHECK" }, + [1333] = { "EVENT_PASSED_VOLCANOBADGE_CHECK" }, + [1334] = { "EVENT_PASSED_EARTHBADGE_CHECK" }, + [1336] = { "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1" }, + [1337] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_0" }, + [1338] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_1" }, + [1339] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_2" }, + [1340] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_3" }, + [1341] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_4" }, + [1342] = { "EVENT_BEAT_MOLTRES" }, + [1343] = { "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2" }, + [1344] = { "EVENT_GOT_NUGGET" }, + [1345] = { "EVENT_BEAT_ROUTE24_ROCKET" }, + [1346] = { "EVENT_BEAT_ROUTE_24_TRAINER_0" }, + [1347] = { "EVENT_BEAT_ROUTE_24_TRAINER_1" }, + [1348] = { "EVENT_BEAT_ROUTE_24_TRAINER_2" }, + [1349] = { "EVENT_BEAT_ROUTE_24_TRAINER_3" }, + [1350] = { "EVENT_BEAT_ROUTE_24_TRAINER_4" }, + [1351] = { "EVENT_BEAT_ROUTE_24_TRAINER_5" }, + [1353] = { "EVENT_NUGGET_REWARD_AVAILABLE" }, + [1360] = { "EVENT_MET_BILL" }, + [1361] = { "EVENT_BEAT_ROUTE_25_TRAINER_0" }, + [1362] = { "EVENT_BEAT_ROUTE_25_TRAINER_1" }, + [1363] = { "EVENT_BEAT_ROUTE_25_TRAINER_2" }, + [1364] = { "EVENT_BEAT_ROUTE_25_TRAINER_3" }, + [1365] = { "EVENT_BEAT_ROUTE_25_TRAINER_4" }, + [1366] = { "EVENT_BEAT_ROUTE_25_TRAINER_5" }, + [1367] = { "EVENT_BEAT_ROUTE_25_TRAINER_6" }, + [1368] = { "EVENT_BEAT_ROUTE_25_TRAINER_7" }, + [1369] = { "EVENT_BEAT_ROUTE_25_TRAINER_8" }, + [1371] = { "EVENT_USED_CELL_SEPARATOR_ON_BILL" }, + [1372] = { "EVENT_GOT_SS_TICKET" }, + [1373] = { "EVENT_MET_BILL_2" }, + [1374] = { "EVENT_BILL_SAID_USE_CELL_SEPARATOR" }, + [1375] = { "EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING" }, + [1378] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_0" }, + [1379] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_1" }, + [1380] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_2" }, + [1393] = { "EVENT_BEAT_MT_MOON_1_TRAINER_0" }, + [1394] = { "EVENT_BEAT_MT_MOON_1_TRAINER_1" }, + [1395] = { "EVENT_BEAT_MT_MOON_1_TRAINER_2" }, + [1396] = { "EVENT_BEAT_MT_MOON_1_TRAINER_3" }, + [1397] = { "EVENT_BEAT_MT_MOON_1_TRAINER_4" }, + [1398] = { "EVENT_BEAT_MT_MOON_1_TRAINER_5" }, + [1399] = { "EVENT_BEAT_MT_MOON_1_TRAINER_6" }, + [1401] = { "EVENT_BEAT_MT_MOON_EXIT_SUPER_NERD" }, + [1402] = { "EVENT_BEAT_MT_MOON_3_TRAINER_0" }, + [1403] = { "EVENT_BEAT_MT_MOON_3_TRAINER_1" }, + [1404] = { "EVENT_BEAT_MT_MOON_3_TRAINER_2" }, + [1405] = { "EVENT_BEAT_MT_MOON_3_TRAINER_3" }, + [1406] = { "EVENT_GOT_DOME_FOSSIL" }, + [1407] = { "EVENT_GOT_HELIX_FOSSIL" }, + [1476] = { "EVENT_BEAT_SS_ANNE_5_TRAINER_0" }, + [1477] = { "EVENT_BEAT_SS_ANNE_5_TRAINER_1" }, + [1504] = { "EVENT_GOT_HM01" }, + [1505] = { "EVENT_RUBBED_CAPTAINS_BACK" }, + [1506] = { "EVENT_SS_ANNE_LEFT" }, + [1507] = { "EVENT_WALKED_PAST_GUARD_AFTER_SS_ANNE_LEFT" }, + [1508] = { "EVENT_STARTED_WALKING_OUT_OF_DOCK" }, + [1509] = { "EVENT_WALKED_OUT_OF_DOCK" }, + [1521] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_0" }, + [1522] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_1" }, + [1523] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_2" }, + [1524] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_3" }, + [1537] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_0" }, + [1538] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_1" }, + [1539] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_2" }, + [1540] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_3" }, + [1553] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_0" }, + [1554] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_1" }, + [1555] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_2" }, + [1556] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_3" }, + [1557] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_4" }, + [1558] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_5" }, + [1632] = { "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1" }, + [1633] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_0" }, + [1634] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_1" }, + [1635] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_2" }, + [1636] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_3" }, + [1638] = { "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2" }, + [1649] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_0" }, + [1650] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_1" }, + [1651] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_2" }, + [1652] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_3" }, + [1653] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4" }, + [1655] = { "EVENT_ENTERED_ROCKET_HIDEOUT" }, + [1663] = { "EVENT_67F" }, + [1665] = { "EVENT_BEAT_ROCKET_HIDEOUT_2_TRAINER_0" }, + [1681] = { "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_0" }, + [1682] = { "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_1" }, + [1698] = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0" }, + [1699] = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1" }, + [1700] = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2" }, + [1701] = { "EVENT_ROCKET_HIDEOUT_4_DOOR_UNLOCKED" }, + [1702] = { "EVENT_ROCKET_DROPPED_LIFT_KEY" }, + [1703] = { "EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI" }, + [1778] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_0" }, + [1779] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_1" }, + [1780] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_2" }, + [1781] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_3" }, + [1789] = { "EVENT_SILPH_CO_2_UNLOCKED_DOOR1" }, + [1790] = { "EVENT_SILPH_CO_2_UNLOCKED_DOOR2" }, + [1791] = { "EVENT_GOT_TM36" }, + [1794] = { "EVENT_BEAT_SILPH_CO_3F_TRAINER_0" }, + [1795] = { "EVENT_BEAT_SILPH_CO_3F_TRAINER_1" }, + [1800] = { "EVENT_SILPH_CO_3_UNLOCKED_DOOR1" }, + [1801] = { "EVENT_SILPH_CO_3_UNLOCKED_DOOR2" }, + [1810] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_0" }, + [1811] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_1" }, + [1812] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_2" }, + [1816] = { "EVENT_SILPH_CO_4_UNLOCKED_DOOR1" }, + [1817] = { "EVENT_SILPH_CO_4_UNLOCKED_DOOR2" }, + [1826] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_0" }, + [1827] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_1" }, + [1828] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_2" }, + [1829] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_3" }, + [1832] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR1" }, + [1833] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR2" }, + [1834] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR3" }, + [1846] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_0" }, + [1847] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_1" }, + [1848] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_2" }, + [1855] = { "EVENT_SILPH_CO_6_UNLOCKED_DOOR" }, + [1856] = { "EVENT_BEAT_SILPH_CO_RIVAL" }, + [1861] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_0" }, + [1862] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_1" }, + [1863] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_2" }, + [1864] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_3" }, + [1868] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR1" }, + [1869] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR2" }, + [1870] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR3" }, + [1874] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_0" }, + [1875] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_1" }, + [1876] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_2" }, + [1880] = { "EVENT_SILPH_CO_8_UNLOCKED_DOOR" }, + [1890] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_0" }, + [1891] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_1" }, + [1892] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_2" }, + [1896] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR1" }, + [1897] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR2" }, + [1898] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR3" }, + [1899] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR4" }, + [1905] = { "EVENT_BEAT_SILPH_CO_10F_TRAINER_0" }, + [1906] = { "EVENT_BEAT_SILPH_CO_10F_TRAINER_1" }, + [1912] = { "EVENT_SILPH_CO_10_UNLOCKED_DOOR" }, + [1924] = { "EVENT_BEAT_SILPH_CO_11F_TRAINER_0" }, + [1925] = { "EVENT_BEAT_SILPH_CO_11F_TRAINER_1" }, + [1928] = { "EVENT_SILPH_CO_11_UNLOCKED_DOOR" }, + [1933] = { "EVENT_GOT_MASTER_BALL" }, + [1935] = { "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + [2049] = { "EVENT_BEAT_MANSION_2_TRAINER_0" }, + [2065] = { "EVENT_BEAT_MANSION_3_TRAINER_0" }, + [2066] = { "EVENT_BEAT_MANSION_3_TRAINER_1" }, + [2081] = { "EVENT_BEAT_MANSION_4_TRAINER_0" }, + [2082] = { "EVENT_BEAT_MANSION_4_TRAINER_1" }, + [2176] = { "EVENT_GOT_HM03" }, + [2241] = { "EVENT_BEAT_MEWTWO" }, + [2273] = { "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0" }, + [2278] = { "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM" }, + [2281] = { "EVENT_BEAT_BRUNOS_ROOM_TRAINER_0" }, + [2286] = { "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM" }, + [2289] = { "EVENT_BEAT_AGATHAS_ROOM_TRAINER_0" }, + [2294] = { "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM" }, + [2297] = { "EVENT_BEAT_LANCES_ROOM_TRAINER_0" }, + [2302] = { "EVENT_BEAT_LANCE" }, + [2303] = { "EVENT_LANCES_ROOM_LOCK_DOOR" }, + [2305] = { "EVENT_BEAT_CHAMPION_RIVAL" }, + [2321] = { "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_0" }, + [2322] = { "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_1" }, + [2327] = { "EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH" }, + [2481] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_0" }, + [2482] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_1" }, + [2483] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_2" }, + [2484] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_3" }, + [2485] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_4" }, + [2486] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_5" }, + [2487] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_6" }, + [2488] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_7" }, + [2496] = { "EVENT_SEAFOAM2_BOULDER1_DOWN_HOLE" }, + [2497] = { "EVENT_SEAFOAM2_BOULDER2_DOWN_HOLE" }, + [2504] = { "EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE" }, + [2505] = { "EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE" }, + [2512] = { "EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE" }, + [2513] = { "EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE" }, + [2522] = { "EVENT_BEAT_ARTICUNO" }, + }, +} diff --git a/src/mods/flag_names/yellow.lua b/src/mods/flag_names/yellow.lua new file mode 100644 index 000000000..21cbb4acd --- /dev/null +++ b/src/mods/flag_names/yellow.lua @@ -0,0 +1,529 @@ +-- Generated flag identifiers only; no engine implementation. +-- Source: bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 +-- Regenerate with tools/flag-catalog-check/generate.py. +return { + records = { + [0] = { "EVENT_FOLLOWED_OAK_INTO_LAB" }, + [3] = { "EVENT_HALL_OF_FAME_DEX_RATING" }, + [5] = { "EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN" }, + [6] = { "EVENT_PALLET_AFTER_GETTING_POKEBALLS" }, + [24] = { "EVENT_GOT_TOWN_MAP" }, + [25] = { "EVENT_ENTERED_BLUES_HOUSE" }, + [26] = { "EVENT_DAISY_WALKING" }, + [32] = { "EVENT_FOLLOWED_OAK_INTO_LAB_2" }, + [33] = { "EVENT_OAK_ASKED_TO_CHOOSE_MON" }, + [34] = { "EVENT_GOT_STARTER" }, + [35] = { "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, + [36] = { "EVENT_GOT_POKEBALLS_FROM_OAK" }, + [37] = { "EVENT_GOT_POKEDEX" }, + [38] = { "EVENT_PALLET_AFTER_GETTING_POKEBALLS_2" }, + [39] = { "EVENT_OAK_APPEARED_IN_PALLET" }, + [40] = { "EVENT_VIRIDIAN_GYM_OPEN" }, + [41] = { "EVENT_GOT_TM42" }, + [44] = { "EVENT_SPAWNED_OLD_MAN_1" }, + [45] = { "EVENT_COMPLETED_CATCH_TRAINING" }, + [46] = { "EVENT_COMPLETED_CATCH_TRAINING_AGAIN" }, + [47] = { "EVENT_INITIAL_CATCH_TRAINING" }, + [56] = { "EVENT_OAK_GOT_PARCEL" }, + [57] = { "EVENT_GOT_OAKS_PARCEL" }, + [80] = { "EVENT_GOT_TM27" }, + [81] = { "EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI" }, + [82] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_0" }, + [83] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_1" }, + [84] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_2" }, + [85] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_3" }, + [86] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_4" }, + [87] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_5" }, + [88] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_6" }, + [89] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_7" }, + [104] = { "EVENT_BOUGHT_MUSEUM_TICKET" }, + [105] = { "EVENT_GOT_OLD_AMBER" }, + [114] = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" }, + [118] = { "EVENT_GOT_TM34" }, + [119] = { "EVENT_BEAT_BROCK" }, + [152] = { "EVENT_BEAT_CERULEAN_RIVAL" }, + [167] = { "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, + [168] = { "EVENT_GOT_BULBASAUR_IN_CERULEAN" }, + [186] = { "EVENT_BEAT_CERULEAN_GYM_TRAINER_0" }, + [187] = { "EVENT_BEAT_CERULEAN_GYM_TRAINER_1" }, + [190] = { "EVENT_GOT_TM11" }, + [191] = { "EVENT_BEAT_MISTY" }, + [192] = { "EVENT_GOT_BICYCLE" }, + [238] = { "EVENT_POKEMON_TOWER_RIVAL_ON_LEFT" }, + [239] = { "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, + [241] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_0" }, + [242] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_1" }, + [243] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_2" }, + [249] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_0" }, + [250] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_1" }, + [251] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_2" }, + [258] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_0" }, + [259] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_1" }, + [260] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_2" }, + [261] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_3" }, + [263] = { "EVENT_IN_PURIFIED_ZONE" }, + [265] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_0" }, + [266] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_1" }, + [267] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_2" }, + [271] = { "EVENT_BEAT_GHOST_MAROWAK" }, + [273] = { "EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES" }, + [274] = { "EVENT_POKEMONTOWER_7_JESSIE_JAMES_ON_LEFT" }, + [279] = { "EVENT_RESCUED_MR_FUJI_2" }, + [296] = { "EVENT_GOT_POKE_FLUTE" }, + [327] = { "EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY" }, + [337] = { "EVENT_GOT_BIKE_VOUCHER" }, + [338] = { "EVENT_LEFT_FANCLUB_AFTER_BIKE_VOUCHER" }, + [342] = { "EVENT_SEEL_FAN_BOAST" }, + [343] = { "EVENT_PIKACHU_FAN_BOAST" }, + [352] = { "EVENT_2ND_LOCK_OPENED" }, + [353] = { "EVENT_1ST_LOCK_OPENED" }, + [354] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_0" }, + [355] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_1" }, + [356] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_2" }, + [358] = { "EVENT_GOT_TM24" }, + [359] = { "EVENT_BEAT_LT_SURGE" }, + [384] = { "EVENT_GOT_TM41" }, + [396] = { "EVENT_GOT_TM13" }, + [397] = { "EVENT_GOT_TM48" }, + [398] = { "EVENT_GOT_TM49" }, + [399] = { "EVENT_GOT_TM18" }, + [424] = { "EVENT_GOT_TM21" }, + [425] = { "EVENT_BEAT_ERIKA" }, + [426] = { "EVENT_BEAT_CELADON_GYM_TRAINER_0" }, + [427] = { "EVENT_BEAT_CELADON_GYM_TRAINER_1" }, + [428] = { "EVENT_BEAT_CELADON_GYM_TRAINER_2" }, + [429] = { "EVENT_BEAT_CELADON_GYM_TRAINER_3" }, + [430] = { "EVENT_BEAT_CELADON_GYM_TRAINER_4" }, + [431] = { "EVENT_BEAT_CELADON_GYM_TRAINER_5" }, + [432] = { "EVENT_BEAT_CELADON_GYM_TRAINER_6" }, + [440] = { "EVENT_1B8" }, + [441] = { "EVENT_FOUND_ROCKET_HIDEOUT" }, + [442] = { "EVENT_GOT_10_COINS" }, + [443] = { "EVENT_GOT_20_COINS" }, + [444] = { "EVENT_GOT_20_COINS_2" }, + [447] = { "EVENT_1BF" }, + [480] = { "EVENT_GOT_COIN_CASE" }, + [568] = { "EVENT_GOT_HM04" }, + [569] = { "EVENT_GAVE_GOLD_TEETH" }, + [590] = { "EVENT_SAFARI_GAME_OVER" }, + [591] = { "EVENT_IN_SAFARI_ZONE" }, + [600] = { "EVENT_GOT_TM06" }, + [601] = { "EVENT_BEAT_KOGA" }, + [602] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_0" }, + [603] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_1" }, + [604] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_2" }, + [605] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_3" }, + [606] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_4" }, + [607] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_5" }, + [632] = { "EVENT_MANSION_SWITCH_ON" }, + [649] = { "EVENT_BEAT_MANSION_1_TRAINER_0" }, + [664] = { "EVENT_GOT_TM38" }, + [665] = { "EVENT_BEAT_BLAINE" }, + [666] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_0" }, + [667] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_1" }, + [668] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_2" }, + [669] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_3" }, + [670] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_4" }, + [671] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_5" }, + [672] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_6" }, + [679] = { "EVENT_2A7" }, + [680] = { "EVENT_CINNABAR_GYM_GATE0_UNLOCKED" }, + [681] = { "EVENT_CINNABAR_GYM_GATE1_UNLOCKED" }, + [682] = { "EVENT_CINNABAR_GYM_GATE2_UNLOCKED" }, + [683] = { "EVENT_CINNABAR_GYM_GATE3_UNLOCKED" }, + [684] = { "EVENT_CINNABAR_GYM_GATE4_UNLOCKED" }, + [685] = { "EVENT_CINNABAR_GYM_GATE5_UNLOCKED" }, + [686] = { "EVENT_CINNABAR_GYM_GATE6_UNLOCKED" }, + [727] = { "EVENT_GOT_TM35" }, + [736] = { "EVENT_GAVE_FOSSIL_TO_LAB" }, + [737] = { "EVENT_LAB_STILL_REVIVING_FOSSIL" }, + [738] = { "EVENT_LAB_HANDING_OVER_FOSSIL_MON" }, + [832] = { "EVENT_GOT_TM31" }, + [848] = { "EVENT_DEFEATED_FIGHTING_DOJO" }, + [849] = { "EVENT_BEAT_KARATE_MASTER" }, + [850] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_0" }, + [851] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_1" }, + [852] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_2" }, + [853] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_3" }, + [854] = { "EVENT_GOT_HITMONLEE" }, + [855] = { "EVENT_GOT_HITMONCHAN" }, + [864] = { "EVENT_GOT_TM46" }, + [865] = { "EVENT_BEAT_SABRINA" }, + [866] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_0" }, + [867] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_1" }, + [868] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_2" }, + [869] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_3" }, + [870] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_4" }, + [871] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_5" }, + [872] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_6" }, + [919] = { "EVENT_SILPH_CO_RECEPTIONIST_AT_DESK" }, + [944] = { "EVENT_GOT_TM29" }, + [960] = { "EVENT_GOT_POTION_SAMPLE" }, + [984] = { "EVENT_GOT_HM05" }, + [994] = { "EVENT_BEAT_ROUTE_3_TRAINER_0" }, + [995] = { "EVENT_BEAT_ROUTE_3_TRAINER_1" }, + [996] = { "EVENT_BEAT_ROUTE_3_TRAINER_2" }, + [997] = { "EVENT_BEAT_ROUTE_3_TRAINER_3" }, + [998] = { "EVENT_BEAT_ROUTE_3_TRAINER_4" }, + [999] = { "EVENT_BEAT_ROUTE_3_TRAINER_5" }, + [1000] = { "EVENT_BEAT_ROUTE_3_TRAINER_6" }, + [1001] = { "EVENT_BEAT_ROUTE_3_TRAINER_7" }, + [1010] = { "EVENT_BEAT_ROUTE_4_TRAINER_0" }, + [1023] = { "EVENT_BOUGHT_MAGIKARP" }, + [1041] = { "EVENT_BEAT_ROUTE_6_TRAINER_0" }, + [1042] = { "EVENT_BEAT_ROUTE_6_TRAINER_1" }, + [1043] = { "EVENT_BEAT_ROUTE_6_TRAINER_2" }, + [1044] = { "EVENT_BEAT_ROUTE_6_TRAINER_3" }, + [1045] = { "EVENT_BEAT_ROUTE_6_TRAINER_4" }, + [1046] = { "EVENT_BEAT_ROUTE_6_TRAINER_5" }, + [1073] = { "EVENT_BEAT_ROUTE_8_TRAINER_0" }, + [1074] = { "EVENT_BEAT_ROUTE_8_TRAINER_1" }, + [1075] = { "EVENT_BEAT_ROUTE_8_TRAINER_2" }, + [1076] = { "EVENT_BEAT_ROUTE_8_TRAINER_3" }, + [1077] = { "EVENT_BEAT_ROUTE_8_TRAINER_4" }, + [1078] = { "EVENT_BEAT_ROUTE_8_TRAINER_5" }, + [1079] = { "EVENT_BEAT_ROUTE_8_TRAINER_6" }, + [1080] = { "EVENT_BEAT_ROUTE_8_TRAINER_7" }, + [1081] = { "EVENT_BEAT_ROUTE_8_TRAINER_8" }, + [1089] = { "EVENT_BEAT_ROUTE_9_TRAINER_0" }, + [1090] = { "EVENT_BEAT_ROUTE_9_TRAINER_1" }, + [1091] = { "EVENT_BEAT_ROUTE_9_TRAINER_2" }, + [1092] = { "EVENT_BEAT_ROUTE_9_TRAINER_3" }, + [1093] = { "EVENT_BEAT_ROUTE_9_TRAINER_4" }, + [1094] = { "EVENT_BEAT_ROUTE_9_TRAINER_5" }, + [1095] = { "EVENT_BEAT_ROUTE_9_TRAINER_6" }, + [1096] = { "EVENT_BEAT_ROUTE_9_TRAINER_7" }, + [1097] = { "EVENT_BEAT_ROUTE_9_TRAINER_8" }, + [1105] = { "EVENT_BEAT_ROUTE_10_TRAINER_0" }, + [1106] = { "EVENT_BEAT_ROUTE_10_TRAINER_1" }, + [1107] = { "EVENT_BEAT_ROUTE_10_TRAINER_2" }, + [1108] = { "EVENT_BEAT_ROUTE_10_TRAINER_3" }, + [1109] = { "EVENT_BEAT_ROUTE_10_TRAINER_4" }, + [1110] = { "EVENT_BEAT_ROUTE_10_TRAINER_5" }, + [1113] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_0" }, + [1114] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_1" }, + [1115] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_2" }, + [1116] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_3" }, + [1117] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_4" }, + [1118] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_5" }, + [1119] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_6" }, + [1121] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_0" }, + [1122] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_1" }, + [1123] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_2" }, + [1124] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_3" }, + [1125] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_4" }, + [1126] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_5" }, + [1127] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_6" }, + [1128] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_7" }, + [1129] = { "EVENT_BEAT_ZAPDOS" }, + [1137] = { "EVENT_BEAT_ROUTE_11_TRAINER_0" }, + [1138] = { "EVENT_BEAT_ROUTE_11_TRAINER_1" }, + [1139] = { "EVENT_BEAT_ROUTE_11_TRAINER_2" }, + [1140] = { "EVENT_BEAT_ROUTE_11_TRAINER_3" }, + [1141] = { "EVENT_BEAT_ROUTE_11_TRAINER_4" }, + [1142] = { "EVENT_BEAT_ROUTE_11_TRAINER_5" }, + [1143] = { "EVENT_BEAT_ROUTE_11_TRAINER_6" }, + [1144] = { "EVENT_BEAT_ROUTE_11_TRAINER_7" }, + [1145] = { "EVENT_BEAT_ROUTE_11_TRAINER_8" }, + [1146] = { "EVENT_BEAT_ROUTE_11_TRAINER_9" }, + [1151] = { "EVENT_GOT_ITEMFINDER" }, + [1152] = { "EVENT_GOT_TM39" }, + [1154] = { "EVENT_BEAT_ROUTE_12_TRAINER_0" }, + [1155] = { "EVENT_BEAT_ROUTE_12_TRAINER_1" }, + [1156] = { "EVENT_BEAT_ROUTE_12_TRAINER_2" }, + [1157] = { "EVENT_BEAT_ROUTE_12_TRAINER_3" }, + [1158] = { "EVENT_BEAT_ROUTE_12_TRAINER_4" }, + [1159] = { "EVENT_BEAT_ROUTE_12_TRAINER_5" }, + [1160] = { "EVENT_BEAT_ROUTE_12_TRAINER_6" }, + [1166] = { "EVENT_FIGHT_ROUTE12_SNORLAX" }, + [1167] = { "EVENT_BEAT_ROUTE12_SNORLAX" }, + [1169] = { "EVENT_BEAT_ROUTE_13_TRAINER_0" }, + [1170] = { "EVENT_BEAT_ROUTE_13_TRAINER_1" }, + [1171] = { "EVENT_BEAT_ROUTE_13_TRAINER_2" }, + [1172] = { "EVENT_BEAT_ROUTE_13_TRAINER_3" }, + [1173] = { "EVENT_BEAT_ROUTE_13_TRAINER_4" }, + [1174] = { "EVENT_BEAT_ROUTE_13_TRAINER_5" }, + [1175] = { "EVENT_BEAT_ROUTE_13_TRAINER_6" }, + [1176] = { "EVENT_BEAT_ROUTE_13_TRAINER_7" }, + [1177] = { "EVENT_BEAT_ROUTE_13_TRAINER_8" }, + [1178] = { "EVENT_BEAT_ROUTE_13_TRAINER_9" }, + [1185] = { "EVENT_BEAT_ROUTE_14_TRAINER_0" }, + [1186] = { "EVENT_BEAT_ROUTE_14_TRAINER_1" }, + [1187] = { "EVENT_BEAT_ROUTE_14_TRAINER_2" }, + [1188] = { "EVENT_BEAT_ROUTE_14_TRAINER_3" }, + [1189] = { "EVENT_BEAT_ROUTE_14_TRAINER_4" }, + [1190] = { "EVENT_BEAT_ROUTE_14_TRAINER_5" }, + [1191] = { "EVENT_BEAT_ROUTE_14_TRAINER_6" }, + [1192] = { "EVENT_BEAT_ROUTE_14_TRAINER_7" }, + [1193] = { "EVENT_BEAT_ROUTE_14_TRAINER_8" }, + [1194] = { "EVENT_BEAT_ROUTE_14_TRAINER_9" }, + [1200] = { "EVENT_GOT_EXP_ALL" }, + [1201] = { "EVENT_BEAT_ROUTE_15_TRAINER_0" }, + [1202] = { "EVENT_BEAT_ROUTE_15_TRAINER_1" }, + [1203] = { "EVENT_BEAT_ROUTE_15_TRAINER_2" }, + [1204] = { "EVENT_BEAT_ROUTE_15_TRAINER_3" }, + [1205] = { "EVENT_BEAT_ROUTE_15_TRAINER_4" }, + [1206] = { "EVENT_BEAT_ROUTE_15_TRAINER_5" }, + [1207] = { "EVENT_BEAT_ROUTE_15_TRAINER_6" }, + [1208] = { "EVENT_BEAT_ROUTE_15_TRAINER_7" }, + [1209] = { "EVENT_BEAT_ROUTE_15_TRAINER_8" }, + [1210] = { "EVENT_BEAT_ROUTE_15_TRAINER_9" }, + [1217] = { "EVENT_BEAT_ROUTE_16_TRAINER_0" }, + [1218] = { "EVENT_BEAT_ROUTE_16_TRAINER_1" }, + [1219] = { "EVENT_BEAT_ROUTE_16_TRAINER_2" }, + [1220] = { "EVENT_BEAT_ROUTE_16_TRAINER_3" }, + [1221] = { "EVENT_BEAT_ROUTE_16_TRAINER_4" }, + [1222] = { "EVENT_BEAT_ROUTE_16_TRAINER_5" }, + [1224] = { "EVENT_FIGHT_ROUTE16_SNORLAX" }, + [1225] = { "EVENT_BEAT_ROUTE16_SNORLAX" }, + [1230] = { "EVENT_GOT_HM02" }, + [1231] = { "EVENT_RESCUED_MR_FUJI" }, + [1233] = { "EVENT_BEAT_ROUTE_17_TRAINER_0" }, + [1234] = { "EVENT_BEAT_ROUTE_17_TRAINER_1" }, + [1235] = { "EVENT_BEAT_ROUTE_17_TRAINER_2" }, + [1236] = { "EVENT_BEAT_ROUTE_17_TRAINER_3" }, + [1237] = { "EVENT_BEAT_ROUTE_17_TRAINER_4" }, + [1238] = { "EVENT_BEAT_ROUTE_17_TRAINER_5" }, + [1239] = { "EVENT_BEAT_ROUTE_17_TRAINER_6" }, + [1240] = { "EVENT_BEAT_ROUTE_17_TRAINER_7" }, + [1241] = { "EVENT_BEAT_ROUTE_17_TRAINER_8" }, + [1242] = { "EVENT_BEAT_ROUTE_17_TRAINER_9" }, + [1249] = { "EVENT_BEAT_ROUTE_18_TRAINER_0" }, + [1250] = { "EVENT_BEAT_ROUTE_18_TRAINER_1" }, + [1251] = { "EVENT_BEAT_ROUTE_18_TRAINER_2" }, + [1265] = { "EVENT_BEAT_ROUTE_19_TRAINER_0" }, + [1266] = { "EVENT_BEAT_ROUTE_19_TRAINER_1" }, + [1267] = { "EVENT_BEAT_ROUTE_19_TRAINER_2" }, + [1268] = { "EVENT_BEAT_ROUTE_19_TRAINER_3" }, + [1269] = { "EVENT_BEAT_ROUTE_19_TRAINER_4" }, + [1270] = { "EVENT_BEAT_ROUTE_19_TRAINER_5" }, + [1271] = { "EVENT_BEAT_ROUTE_19_TRAINER_6" }, + [1272] = { "EVENT_BEAT_ROUTE_19_TRAINER_7" }, + [1273] = { "EVENT_BEAT_ROUTE_19_TRAINER_8" }, + [1274] = { "EVENT_BEAT_ROUTE_19_TRAINER_9" }, + [1280] = { "EVENT_IN_SEAFOAM_ISLANDS" }, + [1281] = { "EVENT_BEAT_ROUTE_20_TRAINER_0" }, + [1282] = { "EVENT_BEAT_ROUTE_20_TRAINER_1" }, + [1283] = { "EVENT_BEAT_ROUTE_20_TRAINER_2" }, + [1284] = { "EVENT_BEAT_ROUTE_20_TRAINER_3" }, + [1285] = { "EVENT_BEAT_ROUTE_20_TRAINER_4" }, + [1286] = { "EVENT_BEAT_ROUTE_20_TRAINER_5" }, + [1287] = { "EVENT_BEAT_ROUTE_20_TRAINER_6" }, + [1288] = { "EVENT_BEAT_ROUTE_20_TRAINER_7" }, + [1289] = { "EVENT_BEAT_ROUTE_20_TRAINER_8" }, + [1290] = { "EVENT_BEAT_ROUTE_20_TRAINER_9" }, + [1294] = { "EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE" }, + [1295] = { "EVENT_SEAFOAM1_BOULDER2_DOWN_HOLE" }, + [1297] = { "EVENT_BEAT_ROUTE_21_TRAINER_0" }, + [1298] = { "EVENT_BEAT_ROUTE_21_TRAINER_1" }, + [1299] = { "EVENT_BEAT_ROUTE_21_TRAINER_2" }, + [1300] = { "EVENT_BEAT_ROUTE_21_TRAINER_3" }, + [1301] = { "EVENT_BEAT_ROUTE_21_TRAINER_4" }, + [1302] = { "EVENT_BEAT_ROUTE_21_TRAINER_5" }, + [1303] = { "EVENT_BEAT_ROUTE_21_TRAINER_6" }, + [1304] = { "EVENT_BEAT_ROUTE_21_TRAINER_7" }, + [1305] = { "EVENT_BEAT_ROUTE_21_TRAINER_8" }, + [1312] = { "EVENT_1ST_ROUTE22_RIVAL_BATTLE" }, + [1313] = { "EVENT_2ND_ROUTE22_RIVAL_BATTLE" }, + [1317] = { "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, + [1318] = { "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE" }, + [1319] = { "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" }, + [1328] = { "EVENT_PASSED_CASCADEBADGE_CHECK" }, + [1329] = { "EVENT_PASSED_THUNDERBADGE_CHECK" }, + [1330] = { "EVENT_PASSED_RAINBOWBADGE_CHECK" }, + [1331] = { "EVENT_PASSED_SOULBADGE_CHECK" }, + [1332] = { "EVENT_PASSED_MARSHBADGE_CHECK" }, + [1333] = { "EVENT_PASSED_VOLCANOBADGE_CHECK" }, + [1334] = { "EVENT_PASSED_EARTHBADGE_CHECK" }, + [1336] = { "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1" }, + [1337] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_0" }, + [1338] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_1" }, + [1339] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_2" }, + [1340] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_3" }, + [1341] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_4" }, + [1342] = { "EVENT_BEAT_MOLTRES" }, + [1343] = { "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2" }, + [1344] = { "EVENT_GOT_NUGGET" }, + [1345] = { "EVENT_BEAT_ROUTE24_ROCKET" }, + [1346] = { "EVENT_BEAT_ROUTE_24_TRAINER_0" }, + [1347] = { "EVENT_BEAT_ROUTE_24_TRAINER_1" }, + [1348] = { "EVENT_BEAT_ROUTE_24_TRAINER_2" }, + [1349] = { "EVENT_BEAT_ROUTE_24_TRAINER_3" }, + [1350] = { "EVENT_BEAT_ROUTE_24_TRAINER_4" }, + [1351] = { "EVENT_BEAT_ROUTE_24_TRAINER_5" }, + [1353] = { "EVENT_NUGGET_REWARD_AVAILABLE" }, + [1359] = { "EVENT_54F" }, + [1360] = { "EVENT_MET_BILL" }, + [1361] = { "EVENT_BEAT_ROUTE_25_TRAINER_0" }, + [1362] = { "EVENT_BEAT_ROUTE_25_TRAINER_1" }, + [1363] = { "EVENT_BEAT_ROUTE_25_TRAINER_2" }, + [1364] = { "EVENT_BEAT_ROUTE_25_TRAINER_3" }, + [1365] = { "EVENT_BEAT_ROUTE_25_TRAINER_4" }, + [1366] = { "EVENT_BEAT_ROUTE_25_TRAINER_5" }, + [1367] = { "EVENT_BEAT_ROUTE_25_TRAINER_6" }, + [1368] = { "EVENT_BEAT_ROUTE_25_TRAINER_7" }, + [1369] = { "EVENT_BEAT_ROUTE_25_TRAINER_8" }, + [1371] = { "EVENT_USED_CELL_SEPARATOR_ON_BILL" }, + [1372] = { "EVENT_GOT_SS_TICKET" }, + [1373] = { "EVENT_MET_BILL_2" }, + [1374] = { "EVENT_BILL_SAID_USE_CELL_SEPARATOR" }, + [1375] = { "EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING" }, + [1378] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_0" }, + [1379] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_1" }, + [1380] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_2" }, + [1381] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_3" }, + [1382] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_4" }, + [1393] = { "EVENT_BEAT_MT_MOON_1_TRAINER_0" }, + [1394] = { "EVENT_BEAT_MT_MOON_1_TRAINER_1" }, + [1395] = { "EVENT_BEAT_MT_MOON_1_TRAINER_2" }, + [1396] = { "EVENT_BEAT_MT_MOON_1_TRAINER_3" }, + [1397] = { "EVENT_BEAT_MT_MOON_1_TRAINER_4" }, + [1398] = { "EVENT_BEAT_MT_MOON_1_TRAINER_5" }, + [1399] = { "EVENT_BEAT_MT_MOON_1_TRAINER_6" }, + [1400] = { "EVENT_GOT_DOME_FOSSIL" }, + [1401] = { "EVENT_BEAT_MT_MOON_EXIT_SUPER_NERD" }, + [1402] = { "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" }, + [1403] = { "EVENT_BEAT_MT_MOON_3_TRAINER_0" }, + [1404] = { "EVENT_BEAT_MT_MOON_3_TRAINER_1" }, + [1405] = { "EVENT_BEAT_MT_MOON_3_TRAINER_2" }, + [1406] = { "EVENT_57E" }, + [1407] = { "EVENT_GOT_HELIX_FOSSIL" }, + [1476] = { "EVENT_BEAT_SS_ANNE_5_TRAINER_0" }, + [1477] = { "EVENT_BEAT_SS_ANNE_5_TRAINER_1" }, + [1504] = { "EVENT_GOT_HM01" }, + [1505] = { "EVENT_RUBBED_CAPTAINS_BACK" }, + [1506] = { "EVENT_SS_ANNE_LEFT" }, + [1507] = { "EVENT_WALKED_PAST_GUARD_AFTER_SS_ANNE_LEFT" }, + [1508] = { "EVENT_STARTED_WALKING_OUT_OF_DOCK" }, + [1509] = { "EVENT_WALKED_OUT_OF_DOCK" }, + [1521] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_0" }, + [1522] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_1" }, + [1523] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_2" }, + [1524] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_3" }, + [1537] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_0" }, + [1538] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_1" }, + [1539] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_2" }, + [1540] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_3" }, + [1553] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_0" }, + [1554] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_1" }, + [1555] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_2" }, + [1556] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_3" }, + [1557] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_4" }, + [1558] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_5" }, + [1632] = { "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1" }, + [1633] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_0" }, + [1634] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_1" }, + [1635] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_2" }, + [1636] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_3" }, + [1638] = { "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2" }, + [1649] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_0" }, + [1650] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_1" }, + [1651] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_2" }, + [1652] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_3" }, + [1653] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4" }, + [1655] = { "EVENT_ENTERED_ROCKET_HIDEOUT" }, + [1663] = { "EVENT_67F" }, + [1665] = { "EVENT_BEAT_ROCKET_HIDEOUT_2_TRAINER_0" }, + [1681] = { "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_0" }, + [1682] = { "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_1" }, + [1696] = { "EVENT_6A0" }, + [1698] = { "EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES" }, + [1699] = { "EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT" }, + [1700] = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2" }, + [1701] = { "EVENT_ROCKET_HIDEOUT_4_DOOR_UNLOCKED" }, + [1702] = { "EVENT_ROCKET_DROPPED_LIFT_KEY" }, + [1703] = { "EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI" }, + [1778] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_0" }, + [1779] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_1" }, + [1780] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_2" }, + [1781] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_3" }, + [1789] = { "EVENT_SILPH_CO_2_UNLOCKED_DOOR1" }, + [1790] = { "EVENT_SILPH_CO_2_UNLOCKED_DOOR2" }, + [1791] = { "EVENT_GOT_TM36" }, + [1794] = { "EVENT_BEAT_SILPH_CO_3F_TRAINER_0" }, + [1795] = { "EVENT_BEAT_SILPH_CO_3F_TRAINER_1" }, + [1800] = { "EVENT_SILPH_CO_3_UNLOCKED_DOOR1" }, + [1801] = { "EVENT_SILPH_CO_3_UNLOCKED_DOOR2" }, + [1810] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_0" }, + [1811] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_1" }, + [1812] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_2" }, + [1816] = { "EVENT_SILPH_CO_4_UNLOCKED_DOOR1" }, + [1817] = { "EVENT_SILPH_CO_4_UNLOCKED_DOOR2" }, + [1826] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_0" }, + [1827] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_1" }, + [1828] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_2" }, + [1829] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_3" }, + [1832] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR1" }, + [1833] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR2" }, + [1834] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR3" }, + [1846] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_0" }, + [1847] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_1" }, + [1848] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_2" }, + [1855] = { "EVENT_SILPH_CO_6_UNLOCKED_DOOR" }, + [1856] = { "EVENT_BEAT_SILPH_CO_RIVAL" }, + [1861] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_0" }, + [1862] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_1" }, + [1863] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_2" }, + [1864] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_3" }, + [1868] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR1" }, + [1869] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR2" }, + [1870] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR3" }, + [1874] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_0" }, + [1875] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_1" }, + [1876] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_2" }, + [1880] = { "EVENT_SILPH_CO_8_UNLOCKED_DOOR" }, + [1890] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_0" }, + [1891] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_1" }, + [1892] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_2" }, + [1896] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR1" }, + [1897] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR2" }, + [1898] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR3" }, + [1899] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR4" }, + [1905] = { "EVENT_BEAT_SILPH_CO_10F_TRAINER_0" }, + [1906] = { "EVENT_BEAT_SILPH_CO_10F_TRAINER_1" }, + [1912] = { "EVENT_SILPH_CO_10_UNLOCKED_DOOR" }, + [1920] = { "EVENT_780" }, + [1921] = { "EVENT_781" }, + [1922] = { "EVENT_782" }, + [1924] = { "EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES" }, + [1925] = { "EVENT_BEAT_SILPH_CO_11F_TRAINER_0" }, + [1928] = { "EVENT_SILPH_CO_11_UNLOCKED_DOOR" }, + [1933] = { "EVENT_GOT_MASTER_BALL" }, + [1935] = { "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + [2049] = { "EVENT_BEAT_MANSION_2_TRAINER_0" }, + [2065] = { "EVENT_BEAT_MANSION_3_TRAINER_0" }, + [2066] = { "EVENT_BEAT_MANSION_3_TRAINER_1" }, + [2081] = { "EVENT_BEAT_MANSION_4_TRAINER_0" }, + [2082] = { "EVENT_BEAT_MANSION_4_TRAINER_1" }, + [2176] = { "EVENT_GOT_HM03" }, + [2241] = { "EVENT_BEAT_MEWTWO" }, + [2273] = { "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0" }, + [2278] = { "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM" }, + [2281] = { "EVENT_BEAT_BRUNOS_ROOM_TRAINER_0" }, + [2286] = { "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM" }, + [2289] = { "EVENT_BEAT_AGATHAS_ROOM_TRAINER_0" }, + [2294] = { "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM" }, + [2297] = { "EVENT_BEAT_LANCES_ROOM_TRAINER_0" }, + [2302] = { "EVENT_BEAT_LANCE" }, + [2303] = { "EVENT_LANCES_ROOM_LOCK_DOOR" }, + [2305] = { "EVENT_BEAT_CHAMPION_RIVAL" }, + [2321] = { "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_0" }, + [2322] = { "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_1" }, + [2327] = { "EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH" }, + [2481] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_0" }, + [2482] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_1" }, + [2483] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_2" }, + [2484] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_3" }, + [2485] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_4" }, + [2486] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_5" }, + [2487] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_6" }, + [2488] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_7" }, + [2496] = { "EVENT_SEAFOAM2_BOULDER1_DOWN_HOLE" }, + [2497] = { "EVENT_SEAFOAM2_BOULDER2_DOWN_HOLE" }, + [2504] = { "EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE" }, + [2505] = { "EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE" }, + [2512] = { "EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE" }, + [2513] = { "EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE" }, + [2522] = { "EVENT_BEAT_ARTICUNO" }, + }, +} diff --git a/tests/api3_contract/flags.lua b/tests/api3_contract/flags.lua new file mode 100644 index 000000000..ec36d5ddf --- /dev/null +++ b/tests/api3_contract/flags.lua @@ -0,0 +1,72 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/flags%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "flags") + if backend == "gen1" and f.gen == 2 then + f.ow.events = require("src.world.gen2.Events").new() + elseif backend == "gen1" and f.gen == 3 then + package.loaded["src.core.game3.scripting.space"].store = { flags = f.game.save.flags, vars = {} } + end + if f.gen == 1 then + f.number, f.symbol = 0, "EVENT_FOLLOWED_OAK_INTO_LAB" + elseif f.gen == 2 then + f.number, f.symbol = 16, "EVENT_GOT_HM01_CUT" + else + f.symbol = "FLAG_SYS_POKEDEX_GET" + local catalog = dofile( + codeRoot .. "/../../src/mods/flag_names/" .. (version == "emerald" and "emerald" or "firered") .. ".lua" + ) + for number, row in pairs(catalog.records) do + for _, alias in ipairs(row) do + if alias == f.symbol then + f.number = number + end + end + end + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local report = { invalid = {} } + for _, name in ipairs({ -1, 0.5, math.huge, "UNKNOWN_NATIVE_FLAG", {}, false }) do + local value, why = f.exports.set(name, true) + assert( + value == nil + and ( + why == "invalid or unknown flag" + or version == "crystal" + and type(name) == "string" + and why == "symbolic event flags unavailable for Crystal; use native ids" + ), + tostring(why) + ) + assert(#f.exports.events() == 0) + report.invalid[#report.invalid + 1] = why + end + assert(f.exports.get(f.number) == false) + assert(f.exports.set(f.number, true)) + assert(f.exports.get(f.number) == true) + assert(f.exports.set(f.number, true)) + assert(#f.exports.events() == 1) + if version == "crystal" then + local value, why = f.exports.get(f.symbol) + assert(value == nil and why == "symbolic event flags unavailable for Crystal; use native ids") + report.symbol = why + else + assert(f.exports.get(f.symbol) == true) + assert(f.exports.set(f.symbol, false)) + assert(f.exports.get(f.number) == false) + assert(#f.exports.events() == 2) + report.symbol = true + end + report.events = f.exports.events() + assert(f.exports.set("mod:quest", true)) + assert(f.exports.get("mod.flags.quest") == true) + local value, why = f.exports.set("mod.other.quest", true) + assert(value == nil and why == "foreign mod flag") + report.private = true + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/mods/flags/main.lua b/tests/api3_contract/mods/flags/main.lua new file mode 100644 index 000000000..d15a56810 --- /dev/null +++ b/tests/api3_contract/mods/flags/main.lua @@ -0,0 +1,17 @@ +local mod = ... +local world = mod.world +local value, why = world:getFlag(1) +assert(value == nil and why == "no game") +local events = {} +mod.events:on("flag.changed", function(ev) + events[#events + 1] = { name = ev.id or ev.name, value = ev.value } +end) +mod.exports.get = function(name) + return world:getFlag(name) +end +mod.exports.set = function(name, value) + return world:setFlag(name, value) +end +mod.exports.events = function() + return events +end diff --git a/tests/api3_contract/mods/flags/manifest.json b/tests/api3_contract/mods/flags/manifest.json new file mode 100644 index 000000000..0394a27f6 --- /dev/null +++ b/tests/api3_contract/mods/flags/manifest.json @@ -0,0 +1 @@ +{"id":"flags","name":"Native flag contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index 42bcaf08b..01b53092d 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -15,11 +15,14 @@ assert( or mode == "npcs" or mode == "wild_battle" or mode == "invalidate" + or mode == "flags" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "invalidate" then +if mode == "flags" then + dofile(codeRoot .. "/flags.lua").run(root, backend, version) +elseif mode == "invalidate" then dofile(codeRoot .. "/invalidate.lua").run(root, backend, version) elseif mode == "wild_battle" then dofile(codeRoot .. "/wild_battle.lua").run(root, backend, version) diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua index 5bc2d8895..83d92fc1b 100644 --- a/tests/engine/api3_compat.lua +++ b/tests/engine/api3_compat.lua @@ -195,7 +195,7 @@ do ) 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("NATIVE_FLAG", true), nil, "unknown native flag 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") diff --git a/tests/engine/api3_flags.lua b/tests/engine/api3_flags.lua new file mode 100644 index 000000000..aa9366cf2 --- /dev/null +++ b/tests/engine/api3_flags.lua @@ -0,0 +1,113 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.flags") +local F = require("src.mods.Api3Flags") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +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 native flags: " .. tostring(why)) +end +-- Every generated catalog alias resolves to the live VM/save-converter key. +local vm = require("src.script.Gen3ScriptVM") +for _, version in ipairs({ "red", "blue", "yellow", "firered", "emerald" }) do + local key = version == "blue" and "red" or version + local data = require("src.mods.flag_names." .. key) + local gen = Version.generation(version) + for number, row in pairs(data.records) do + local expected = gen == 1 and row[1] or vm.flagName(number) + if row[1] then + T.eq(F.resolve(gen, version, number), expected, "catalog numeric resolves to native key") + end + for _, alias in ipairs(row) do + if alias then + T.eq(F.resolve(gen, version, alias), expected, "catalog alias resolves to native key") + end + end + end +end +for _, version in ipairs({ "gold", "silver", "crystal" }) do + for number = 0, 2047 do + T.eq( + F.resolve(2, version, number), + require("src.script.Gen2Flags").eventFlag(number), + "native Gen2 bit key" + ) + end +end +for _, version in ipairs({ "prism", "polishedcrystal" }) do + T.eq(F.resolve(2, version, 16), nil, "no cartridge flag numbering guessed for hacks") +end +for _, bad in ipairs({ {}, false, 0 / 0, math.huge, -1, 0.5, string.rep("A", 257), "FLAG\0BAD" }) do + local f = fixture() + T.eq(f.exports.set(bad, true), nil, "malformed native ref refused") + T.eq(next(f.game.save.flags), nil, "refusal leaves native store untouched") +end +for _, bad in ipairs({ 0, "yes", {}, function() end }) do + local f = fixture() + T.eq(f.exports.set(f.number, bad), nil, "real boolean required") + T.eq(next(f.game.save.flags), nil, "bad value not stored") +end +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + local storage = assert(F.resolve(f.gen, version, f.number)) + T.check(f.exports.set(f.number, true), "native set") + T.eq(f.game.save.flags[storage], true, "native VM sees same flag") + T.check(f.exports.set(f.number, false), "native clear") + T.eq(f.game.save.flags[storage], false, "clear distinguished from untouched for visibility") + T.eq(f.exports.get(f.number), false, "public clear boolean") + f.game.save.flags = false + T.eq(f.exports.get(f.number), nil, "malformed native store refused") + T.eq(f.exports.set(f.number, true), nil, "malformed native store not overwritten") + T.eq(f.game.save.flags, false, "malformed native store retained") +end +do + local f = fixture("firered") + local storage = assert(F.resolve(3, "firered", f.number)) + local own = { index = 1, eventFlag = storage } + local other = { index = 2, eventFlag = "unrelated" } + f.ow.map.def.objects = { own, other } + local calls = {} + function f.ow:syncObjectVisibility(object) + calls[#calls + 1] = object + end + T.check(f.exports.set(f.symbol, true), "visibility flag set") + T.eq(#calls, 1, "only affected actor synchronized") + T.eq(calls[1], own, "synchronization scoped to native flag") + T.eq(f.ow.map.def.objects[2], other, "unrelated template unchanged") +end +do + local f = fixture() + local observed = 0 + f.loader.events:on("flag.changed", function() + observed = observed + 1 + local ok, why = f.exports.set(f.number, false) + T.eq(ok, nil, "flag event cannot reenter a write") + T.eq(why, "world is busy", "flag reentry diagnosed") + end, 0, "probe") + T.check(f.exports.set(f.number, true), "outer write accepted") + T.eq(observed, 1, "no recursive event loop") + T.eq(f.exports.get(f.number), true, "reentrant attempt cannot alter committed flag") +end +for _, action in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + if action == "rollback" then + f.loader:_rollback("flags") + elseif action == "reload" then + fixture() + else + Loader.teardown() + end + T.eq(f.exports.set(f.number, true), nil, "retired native setter") + T.eq(f.exports.set("mod:key", true), nil, "retired private setter") + T.eq(next(f.game.save.flags), nil, "retired setter no effects") +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 native flags") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 290c3c3a8..bc062183a 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", "npcs", "wild_battle", "invalidate"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures diff --git a/tools/flag-catalog-check/generate.py b/tools/flag-catalog-check/generate.py new file mode 100644 index 000000000..e385d9931 --- /dev/null +++ b/tools/flag-catalog-check/generate.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Reproduce/check flag identifier catalogs against the clean pinned reference. + +Exports identifiers only. No Gen1 engine code or cartridge ROM is vendored. +""" +import argparse +import json +from pathlib import Path +import subprocess + +PIN = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" +EXPORT = r""" +package.path = './?.lua;./?/init.lua;' .. package.path +local out = { reference = '79ea62f45fb2ea8f65d081a109431e4eb1bbdb36', catalogs = {} } +for _, version in ipairs({'red','yellow'}) do + local suffix = version == 'yellow' and '_yellow' or '' + local names=require('src.save_convert.data.event_flags'..suffix).byBit + local encoded={} for k,v in pairs(names) do encoded['n'..k]=v end + out.catalogs[version] = { names=encoded } +end +for _, version in ipairs({'firered','emerald'}) do + require('src.core.GameVersion').set(version) + local t = require('src.core.game3.scripting.flags').forVersion(version) + local ids,names,count={},{},t.FLAGS_COUNT or 0x900 + for k,v in pairs(t.IDS) do + if type(k)=='string' and type(v)=='number' and v>=1 and v=1 and k Date: Thu, 22 Oct 2026 15:16:17 +0200 Subject: [PATCH 19/42] Add prevalidated portable flag script commands --- docs/api3-portability.md | 4 +- docs/portable-world-scripts.md | 45 +++++- src/mods/Api3.lua | 4 +- src/mods/Api3Flags.lua | 45 ++++-- src/mods/PortableScripts.lua | 60 +++++++- src/mods/ScriptProgram.lua | 33 +++- .../api3_contract/mods/script_flags/main.lua | 20 +++ .../mods/script_flags/manifest.json | 1 + tests/api3_contract/script_flags.lua | 89 +++++++++++ tests/api3_contract/worker.lua | 5 +- tests/engine/portable_script_flags.lua | 141 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 12 files changed, 420 insertions(+), 29 deletions(-) create mode 100644 tests/api3_contract/mods/script_flags/main.lua create mode 100644 tests/api3_contract/mods/script_flags/manifest.json create mode 100644 tests/api3_contract/script_flags.lua create mode 100644 tests/engine/portable_script_flags.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 4a15a97c3..b23fdd2a0 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -134,8 +134,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 and eight NPC cases extend this to 72 comparisons -and 144 isolated loader runs (including wild-battle, map-invalidation and flag cases). It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 80 comparisons +and 160 isolated loader runs (including wild-battle, map-invalidation, flag and script-flag cases). 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 diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index 1391146ba..bf411dc1d 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -144,7 +144,7 @@ Unknown/unimplemented portable verbs return false. The existing native ## 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, +version selections (Gen 1–5). 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. @@ -162,3 +162,46 @@ 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. + +## Portable flag commands + +The validated language also supports: + +| Row | Meaning | +| --- | --- | +| `{ "set_flag", reference }` / `{ "setflag", reference }` | Set a private/native boolean flag. | +| `{ "clear_flag", reference }` / `{ "clearflag", reference }` | Clear it. | +| `{ "check_flag", reference }` | Set the conditional result to the flag's boolean value. | +| `{ "wait_flag", reference, timeout? }` | Yield until true or until the optional timeout expires; set conditional result true/false. | + +References use [API-3 flag addressing](api3-flags.md): `mod:` is private to +the initiating mod, while native symbols/numbers retain their version-specific +meaning. Crystal native references remain numeric-only; unknown hack mappings +are declined. These rows are available through the unified script opt-in and +API 3. Gen1's pinned reference does not offer the host's `scripts` capability +query surface; portable API-3 mods can use the common world/queueScript query, +while host-specific tools may inspect scripts/. + +Every reference, including unreachable rows, is validated and copied before +admission. All referenced save stores are checked before the first effect; +an invalid later row never permits an earlier set_flag to execute. Native and +private writes share the public flag implementation, including private ownership, +native storage, reentry protection and Gen3 visibility synchronization. Native +flag handling uses the already resolved world and never asks the loader to boot +Game. + +A timeout is a whole number from 0 to 36000 logic frames. Omission waits until +signaled or cancelled. Even a zero timeout or already-set flag yields once; +on the next condition poll a true flag wins over timeout expiry. Waiting pauses +under a foreign covering screen, preserving its lifetime and timeout. Waits do +not consume the script instruction budget on every frame. Existing cancellation, +owner/session checks, one-shot completion and instruction/slice budgets apply. +A flag store that becomes malformed after admission fails the run and restores +the original runner; already executed native writes are not rolled back. + +The identical public-only shared mod verifies branching, both aliases, signal +resume, timeout and complete-list refusal on both engines for eight versions. +It extends the pinned suite to 80 comparisons /160 isolated loader runs. +Local tests additionally verify detached rows, malformed stores/timeouts, paused +foreign overlays, native flag conditions and stale-session/owner cancellation. +The existing 5268-check dialogue suite remains unchanged and passing. diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index 906983463..3118f4cde 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -117,7 +117,9 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) end if name == "setFlag" or name == "getFlag" then local key, value = ... - return Flags.call(game, api.id, gen, key, value, name == "setFlag", licensed, native) + return Flags.call(game, api.id, gen, key, value, name == "setFlag", licensed, function(live) + return native(live):overworld() + end) end if name == "queueScript" then return scripts:queueScript(...) diff --git a/src/mods/Api3Flags.lua b/src/mods/Api3Flags.lua index d619025b2..c7b937c73 100644 --- a/src/mods/Api3Flags.lua +++ b/src/mods/Api3Flags.lua @@ -81,17 +81,8 @@ function F.resolve(generation, version, name) end return nil, "invalid or unknown flag" end -function F.call(game, owner, gen, name, value, write, licensed, native) - if write and not licensed() then - return nil, "world facade is retired" - end - if write and type(value) ~= "boolean" then - return nil, "flag value must be boolean" - end - local save = game.save - if not save then - return nil, "no save" - end +-- Resolve without a Game/save for whole-program script preflight. +function F.address(owner, gen, version, name) local key if type(name) == "string" then if name:sub(1, 4) == "mod:" then @@ -108,6 +99,31 @@ function F.call(game, owner, gen, name, value, write, licensed, native) if not identifier(key) then return nil, "invalid mod flag" end + return { kind = "mod", key = key } + end + local storage, public, number = F.resolve(gen, version, name) + if not storage then + return nil, public + end + return { kind = "native", key = storage, public = public, number = number } +end +function F.call(game, owner, gen, name, value, write, licensed, overworld) + if write and not licensed() then + return nil, "world facade is retired" + end + if write and type(value) ~= "boolean" then + return nil, "flag value must be boolean" + end + local save = game.save + if not save then + return nil, "no save" + end + local address, reason = F.address(owner, gen, Version.get(), name) + if not address then + return nil, reason + end + if address.kind == "mod" then + local key = address.key local store = save.modData if store ~= nil and type(store) ~= "table" then return nil, "invalid mod save store" @@ -143,10 +159,7 @@ function F.call(game, owner, gen, name, value, write, licensed, native) end return true end - local storage, public, number = F.resolve(gen, Version.get(), name) - if not storage then - return nil, public - end + local storage, public, number = address.key, address.public, address.number if type(save.flags) ~= "table" then return nil, "invalid flag store" end @@ -162,7 +175,7 @@ function F.call(game, owner, gen, name, value, write, licensed, native) -- Native clear keeps false: visibility distinguishes cleared from untouched. save.flags[storage] = value if gen == 3 then - local ow = native(game):overworld() + local ow = overworld(game) if ow and ow.syncObjectVisibility then for _, object in ipairs(ow.map and ow.map.def and ow.map.def.objects or {}) do if object.eventFlag == storage then diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index bfa6c6181..46d77fb24 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -2,6 +2,7 @@ -- foreground until completion, then returns the idle runner it borrowed. local Program = require("src.mods.ScriptProgram") local Runtime = require("src.mods.Runtime") +local Flags = require("src.mods.Api3Flags") local GameVersion = require("src.core.GameVersion") local Scripts = {} local runs = setmetatable({}, { __mode = "k" }) @@ -187,7 +188,10 @@ local function facade(loader, modId, native) if not licensed() then return nil, "script facade is retired" end - local program, why = Program.compile(rows) + local program, why = Program.compile( + rows, + { owner = modId, generation = GameVersion.generation(), version = GameVersion.get() } + ) if not program then return nil, why end @@ -208,6 +212,25 @@ local function facade(loader, modId, native) if not ow then return nil, why end + local function flag(name, value, write) + return Flags.call(game, modId, GameVersion.generation(), name, value, write, licensed, function() + return ow + end) + end + -- Check stores for every row, including unreachable branches, before a prefix runs. + for i, row in ipairs(program) do + if + row[1] == "set_flag" + or row[1] == "clear_flag" + or row[1] == "check_flag" + or row[1] == "wait_flag" + then + local value, reason = flag(row[2]) + if value == nil then + return nil, "row " .. i .. ": " .. reason + end + end + end local Runner = require("src.script.ScriptRunner") local runner = Runner.new(game, ow) local token = { @@ -418,7 +441,40 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if verb == "wait" then + if verb == "set_flag" or verb == "clear_flag" then + local ok, reason = flag(row[2], verb == "set_flag", true) + if not ok then + error(reason, 0) + end + elseif verb == "check_flag" then + local value, reason = flag(row[2]) + if value == nil then + error(reason, 0) + end + ctx.lastCheck = value + elseif verb == "wait_flag" then + local left = row[3] + self.waitingCheck = function() + if not check() then + return false + end + local value, reason = flag(row[2]) + if value == nil then + error(reason, 0) + end + if value then + return true, true + end + if left then + left = left - 1 + if left <= 0 then + return true, false + end + end + return false + end + ctx.lastCheck = self:yield() + elseif verb == "wait" then self.waitingFrames = row[2] self:yield() elseif verb == "show_text" or verb == "ask" then diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index 4bfc0a813..22418d75b 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -2,6 +2,10 @@ local Program = {} Program.MAX_ROWS, Program.MAX_STEPS, Program.SLICE = 1024, 65536, 128 local ARITY = { + set_flag = 2, + clear_flag = 2, + check_flag = 2, + wait_flag = 3, show_text = 2, ask = 2, choice = 3, @@ -12,8 +16,9 @@ local ARITY = { jump_if_false = 2, ["end"] = 1, } +local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) - return type(verb) == "string" and (ARITY[verb] ~= nil or verb == "text") + return type(verb) == "string" and (ARITY[aliases[verb] or verb] ~= nil) end local function integer(n, lo, hi) return type(n) == "number" and n == n and n % 1 == 0 and n >= lo and n <= hi @@ -69,7 +74,8 @@ local function text(value, max) end return true end -function Program.compile(raw) +function Program.compile(raw, context) + context = context or {} 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" @@ -83,15 +89,32 @@ function Program.compile(raw) 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] + local verb = aliases[source[1]] 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 + if count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") 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 verb == "set_flag" or verb == "clear_flag" or verb == "check_flag" or verb == "wait_flag" then + local Version = require("src.core.GameVersion") + local address, reason = require("src.mods.Api3Flags").address( + context.owner or "", + context.generation or Version.generation(), + context.version or Version.get(), + source[2] + ) + if not address then + return fail(i, reason) + end + if verb == "wait_flag" then + if source[3] ~= nil and not integer(source[3], 0, 36000) then + return fail(i, "invalid flag timeout") + end + row[3] = source[3] + end + elseif 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 diff --git a/tests/api3_contract/mods/script_flags/main.lua b/tests/api3_contract/mods/script_flags/main.lua new file mode 100644 index 000000000..3a96b1e6b --- /dev/null +++ b/tests/api3_contract/mods/script_flags/main.lua @@ -0,0 +1,20 @@ +local mod = ... +local world = mod.world +assert(mod:supports("world", "queueScript")) +local done, success = 0 +mod.exports.queue = function(rows) + return world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.set = function(name, value) + return world:setFlag(name, value) +end +mod.exports.get = function(name) + return world:getFlag(name) +end +mod.exports.result = function() + return { done = done, success = success } +end diff --git a/tests/api3_contract/mods/script_flags/manifest.json b/tests/api3_contract/mods/script_flags/manifest.json new file mode 100644 index 000000000..2dcca5648 --- /dev/null +++ b/tests/api3_contract/mods/script_flags/manifest.json @@ -0,0 +1 @@ +{"id":"script_flags","name":"Portable script flags","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/script_flags.lua b/tests/api3_contract/script_flags.lua new file mode 100644 index 000000000..3d2ef8bbc --- /dev/null +++ b/tests/api3_contract/script_flags.lua @@ -0,0 +1,89 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_flags%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "script_flags") + f.loader.game = f.game + f.ow.runner.update = function() end + if f.field then + function f.field.lock(token) + f.field._locks[token] = true + end + function f.field.unlock(token) + f.field._locks[token] = nil + end + end + function f:tick() + if backend == "gen1" then + self.loader.hooks:call("core.update", function() end, self.game, 1 / 60) + else + self.loader.hooks:call("input.step", function() + self.ow.runner:update() + end, self.game, 1 / 60) + end + end + function f:drain() + for _ = 1, 100 do + self:tick() + if self.exports.result().done > 0 then + return + end + end + error("script did not complete") + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local rows = { + { "set_flag", "mod:ready" }, + { "check_flag", "mod:ready" }, + { "jump_if_false", "failed" }, + { "clearflag", "mod:ready" }, + { "check_flag", "mod:ready" }, + { "jump_if_true", "failed" }, + { "setflag", "mod:result" }, + { "jump", "end" }, + { "label", "failed" }, + { "set_flag", "mod:failure" }, + } + assert(f.exports.queue(rows)) + f:drain() + assert(f.exports.get("mod:result") == true and f.exports.get("mod:failure") == false) + assert(f.exports.get("mod:ready") == false and f.exports.result().success == true) + local report = { branch = true } + f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "wait_flag", "mod:signal", 20 }, + { "jump_if_false", "end" }, + { + "set_flag", + "mod:resumed", + }, + })) + f:tick() + assert(f.exports.result().done == 0) + assert(f.exports.set("mod:signal", true)) + f:drain() + assert(f.exports.get("mod:resumed") == true) + report.resumed = f.exports.result() + f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "wait_flag", "mod:signal", 2 }, + { "jump_if_true", "end" }, + { + "set_flag", + "mod:timeout", + }, + })) + f:drain() + assert(f.exports.get("mod:timeout") == true) + report.timeout = f.exports.result() + f = M.fixture(root, backend, version) + local value, why = f.exports.queue({ { "set_flag", "mod:prefix" }, { "set_flag", "not_a_real_flag" } }) + assert(value == nil and type(why) == "string") + assert(f.exports.get("mod:prefix") == false and f.exports.result().done == 0) + report.preflight = true + 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 01b53092d..c5812249d 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -16,11 +16,14 @@ assert( or mode == "wild_battle" or mode == "invalidate" or mode == "flags" + or mode == "script_flags" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "flags" then +if mode == "script_flags" then + dofile(codeRoot .. "/script_flags.lua").run(root, backend, version) +elseif mode == "flags" then dofile(codeRoot .. "/flags.lua").run(root, backend, version) elseif mode == "invalidate" then dofile(codeRoot .. "/invalidate.lua").run(root, backend, version) diff --git a/tests/engine/portable_script_flags.lua b/tests/engine/portable_script_flags.lua new file mode 100644 index 000000000..0f25d1d66 --- /dev/null +++ b/tests/engine/portable_script_flags.lua @@ -0,0 +1,141 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_flags") +local Program = require("src.mods.ScriptProgram") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +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 script flags: " .. tostring(why)) +end +for _, verb in ipairs({ "set_flag", "clear_flag", "check_flag", "wait_flag", "setflag", "clearflag" }) do + T.eq(Program.supports(verb), true, "flag verb advertised") +end +for _, rows in ipairs({ + { { "set_flag", "mod:prefix" }, { "set_flag", "no_such_flag" } }, + { { "jump", "end" }, { "set_flag", "no_such_flag" } }, + { { "set_flag", "mod:prefix" }, { "check_flag", "mod.other.secret" } }, + { { "set_flag", {} } }, + { { "set_flag", "mod:" } }, + { { "set_flag", "mod:x", true } }, + { { "wait_flag", "mod:x", -1 } }, + { { "wait_flag", "mod:x", 0.5 } }, + { { "wait_flag", "mod:x", math.huge } }, + { { "wait_flag", "mod:x", 36001 } }, + { { "wait_flag", "mod:x", "5" } }, +}) do + local f = fixture() + local runner = f.ow.runner + local value, why = f.exports.queue(rows) + T.eq(value, nil, "entire invalid list declined") + T.check(type(why) == "string", "invalid row explained") + T.eq(f.exports.get("mod:prefix"), false, "no accepted prefix executes") + T.eq(f.ow.runner, runner, "no runner replacement on refusal") + T.eq(f.exports.result().done, 0, "no completion on refusal") +end +do + local f = fixture() + f.game.save.modData = false + T.eq(f.exports.queue({ { "set_flag", "mod:prefix" } }), nil, "malformed store rejected before admission") + T.eq(f.game.save.modData, false, "malformed store not overwritten") +end +do + local f = fixture() + local rows = { { "wait_flag", "mod:signal" }, { "set_flag", "mod:copied" } } + T.check(f.exports.queue(rows), "indefinite wait admitted") + rows[2][2] = "mod:mutated" + for _ = 1, 5 do + f:tick() + end + T.eq(f.exports.result().done, 0, "omitted timeout keeps waiting") + T.check(f.exports.set("mod:signal", true), "external signal") + f:drain() + T.eq(f.exports.get("mod:copied"), true, "admitted rows detached") + T.eq(f.exports.get("mod:mutated"), false, "caller mutation ignored") +end +for _, timeout in ipairs({ 0, 1, 2 }) do + local f = fixture() + T.check( + f.exports.queue({ + { "wait_flag", "mod:signal", timeout }, + { "jump_if_true", "end" }, + { + "set_flag", + "mod:timed", + }, + }), + "timeout admitted" + ) + T.eq(f.exports.result().done, 0, "even zero yields once") + f:drain() + T.eq(f.exports.get("mod:timed"), true, "timeout sets lastCheck false") +end +do + local f = fixture() + T.check( + f.exports.queue({ { "wait_flag", "mod:signal", 1 }, { "set_flag", "mod:resumed" } }), + "wait admitted before overlay" + ) + local overlay = {} + f.game.stack.states[2] = overlay + for _ = 1, 5 do + f:tick() + end + T.eq(f.exports.result().done, 0, "foreign overlay pauses timeout") + T.eq(f.game.stack.states[2], overlay, "owned wait does not pop foreign screen") + f.game.stack.states[2] = nil + f:drain() + T.eq(f.exports.result().success, true, "wait resumes after uncovering") +end +for _, change in ipairs({ "save", "data", "map", "version", "rollback", "teardown" }) do + local f = fixture() + local original = f.ow.runner + T.check( + f.exports.queue({ { "wait_flag", "mod:signal" }, { "set_flag", "mod:late" } }), + "wait starts before " .. change + ) + local owned = f.ow.runner + if change == "version" then + Version.set("blue") + elseif change == "rollback" then + f.loader:_rollback("script_flags") + elseif change == "teardown" then + Loader.teardown() + elseif change == "map" then + f.ow.map = { id = f.home, def = f.ow.map.def } + else + f.game[change] = {} + end + f:tick() + T.eq(f.exports.result().done, 1, "changed context completes once") + T.eq(f.exports.result().success, false, "changed context cancels wait") + T.eq(f.ow.runner, original, "original runner restored") + owned:resume(true) + T.eq(f.exports.result().done, 1, "late condition cannot replay completion") +end +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + local ref = version == "red" and 0 or (f.gen == 2 and 16 or 1) + T.check( + f.exports.queue({ + { "set_flag", ref }, + { "check_flag", ref }, + { "jump_if_false", "end" }, + { "set_flag", "mod:native" }, + { "clear_flag", ref }, + }), + "native flag program admitted" + ) + f:drain() + T.eq(f.exports.get("mod:native"), true, "native condition drives control flow") + T.eq(f.exports.get(ref), false, "native flag cleared") +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("Portable script flags") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index bc062183a..222f47222 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", "npcs", "wild_battle", "invalidate", "flags"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 235b8f975f9e4c74cf0775dc6f18e9cc7220439b Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:26:29 +0200 Subject: [PATCH 20/42] Add portable wallet, inventory and party script actions --- docs/api3-portability.md | 4 +- docs/portable-world-scripts.md | 38 ++++ src/mods/PortableScripts.lua | 9 +- src/mods/ScriptProgram.lua | 17 +- src/mods/ScriptState.lua | 193 ++++++++++++++++++ src/pokemon/Pokemon.lua | 4 +- .../api3_contract/mods/script_state/main.lua | 16 ++ .../mods/script_state/manifest.json | 1 + tests/api3_contract/script_flags.lua | 4 +- tests/api3_contract/script_state.lua | 109 ++++++++++ tests/api3_contract/worker.lua | 5 +- tests/engine/portable_script_state.lua | 109 ++++++++++ tools/api3-contract-check/check.py | 2 +- 13 files changed, 500 insertions(+), 11 deletions(-) create mode 100644 src/mods/ScriptState.lua create mode 100644 tests/api3_contract/mods/script_state/main.lua create mode 100644 tests/api3_contract/mods/script_state/manifest.json create mode 100644 tests/api3_contract/script_state.lua create mode 100644 tests/engine/portable_script_state.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index b23fdd2a0..f80db8650 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -134,8 +134,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 and eight NPC cases extend this to 80 comparisons -and 160 isolated loader runs (including wild-battle, map-invalidation, flag and script-flag cases). It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 88 comparisons +and 176 isolated loader runs (including world-action, flag, script-flag and script-state cases). 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 diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index bf411dc1d..a9445b950 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -205,3 +205,41 @@ It extends the pinned suite to 80 comparisons /160 isolated loader runs. Local tests additionally verify detached rows, malformed stores/timeouts, paused foreign overlays, native flag conditions and stale-session/owner cancellation. The existing 5268-check dialogue suite remains unchanged and passing. + +## Wallet, inventory and party state commands + +The bounded language also accepts these synchronous rows: + +| Row | Meaning | +| --- | --- | +| `{ "give_money", amount }` / `{ "take_money", amount }` | Add/subtract money; subtraction saturates at zero. | +| `{ "check_money", amount }` | Set the conditional result to whether the current wallet covers the amount. | +| `{ "take_coins", amount }` | Subtract coins, saturating at zero. | +| `{ "check_item", itemId }` | Set the conditional result to whether the bag contains the item. | +| `{ "take_item", itemId, count? }` | Remove up to count (default one), using native Bag.remove/order cleanup. | +| `{ "check_dex_owned", amount? }` | Check the owned/caught species count, default threshold one. | +| `{ "mark_seen", speciesId }` | Mark an existing species seen using the host's native storage key. | +| `{ "heal_party" }` | Heal native party HP/status and restore PP from the current Game's move data. | + +Amounts are finite whole nonnegative numbers up to Lua's exact integer limit +(2^53-1). Item count is 1–999. Item/species IDs are canonical strings, resolved +through the existing loader index rather than assumed cartridge numbers. +Missing/ambiguous content, malformed referenced wallets/inventory/dex/party +stores or malformed arguments reject the complete list before any effect, +including unreachable rows. Accepted rows are detached; live stores are checked +again when dispatching after a wait. Native effects already executed before a +later runtime failure are not rolled back. + +Wallet writes retain the verified Gen1 API's generation behavior: Gen2/3 +addition caps at 999999; Gen1 retains its native unbounded script wallet. +Queries and item/coin removal do not create gifts or UI. Inventory and party +structures retain this host's native layouts. The optional moves argument on +Pokemon.heal lets this path use Game.data instead of an unrelated Data singleton; +existing native callers keep their default behavior. + +The same public script compares branches and resulting money, coins, item +quantity, HP/PP and seen state through both native backends on eight versions. +The full pinned checker now runs 88 comparisons /176 isolated loader runs. +Host tests add malformed/unreachable-row refusal with no prefix effects, wallet +caps, saturation, actual Bag order removal, live-store failure and argument +copying. These are ROM-free runtime/store checks, not a story playthrough. diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index 46d77fb24..82eadbebb 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -3,6 +3,7 @@ local Program = require("src.mods.ScriptProgram") local Runtime = require("src.mods.Runtime") local Flags = require("src.mods.Api3Flags") +local State = require("src.mods.ScriptState") local GameVersion = require("src.core.GameVersion") local Scripts = {} local runs = setmetatable({}, { __mode = "k" }) @@ -231,6 +232,10 @@ local function facade(loader, modId, native) end end end + local state, reason = State.prepare(loader, game, ow, program) + if not state then + return nil, reason + end local Runner = require("src.script.ScriptRunner") local runner = Runner.new(game, ow) local token = { @@ -441,7 +446,9 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if verb == "set_flag" or verb == "clear_flag" then + if State.ARITY[verb] then + state(ctx, row) + elseif verb == "set_flag" or verb == "clear_flag" then local ok, reason = flag(row[2], verb == "set_flag", true) if not ok then error(reason, 0) diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index 22418d75b..a7101fedb 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -1,5 +1,6 @@ -- Pure preflight for the bounded portable dialogue/control-flow language. local Program = {} +local State = require("src.mods.ScriptState") Program.MAX_ROWS, Program.MAX_STEPS, Program.SLICE = 1024, 65536, 128 local ARITY = { set_flag = 2, @@ -16,6 +17,9 @@ local ARITY = { jump_if_false = 2, ["end"] = 1, } +for verb, arity in pairs(State.ARITY) do + ARITY[verb] = arity +end local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) return type(verb) == "string" and (ARITY[aliases[verb] or verb] ~= nil) @@ -93,11 +97,20 @@ function Program.compile(raw, context) if type(verb) ~= "string" or not ARITY[verb] then return fail(i, "unsupported portable command") end - if count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb]) then + if + not State.ARITY[verb] + and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) + then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if verb == "set_flag" or verb == "clear_flag" or verb == "check_flag" or verb == "wait_flag" then + if State.ARITY[verb] then + local value, reason = State.syntax(verb, source) + if not value then + return fail(i, reason) + end + row = value + elseif verb == "set_flag" or verb == "clear_flag" or verb == "check_flag" or verb == "wait_flag" then local Version = require("src.core.GameVersion") local address, reason = require("src.mods.Api3Flags").address( context.owner or "", diff --git a/src/mods/ScriptState.lua b/src/mods/ScriptState.lua new file mode 100644 index 000000000..dbf73a6e5 --- /dev/null +++ b/src/mods/ScriptState.lua @@ -0,0 +1,193 @@ +-- Synchronous portable script actions over the host's native save/party/bag. +local Ids = require("src.mods.UnifiedIds") +local Version = require("src.core.GameVersion") +local S = {} +S.ARITY = { + give_money = 2, + take_money = 2, + check_money = 2, + take_coins = 2, + check_item = 2, + take_item = 3, + check_dex_owned = 2, + mark_seen = 2, + heal_party = 1, +} +local function integer(value, minimum, maximum) + return type(value) == "number" + and value == value + and value % 1 == 0 + and value >= minimum + and value < math.huge + 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 +function S.syntax(verb, source) + local n = S.ARITY[verb] + local minimum = (verb == "take_item" or verb == "check_dex_owned") and 2 or n + if verb == "check_dex_owned" then + minimum = 1 + end + if #source < minimum or #source > n then + return nil, "invalid command arity" + end + if verb == "check_item" or verb == "take_item" or verb == "mark_seen" then + if not identifier(source[2]) then + return nil, "invalid content id" + end + if verb == "take_item" and source[3] ~= nil and not integer(source[3], 1, 999) then + return nil, "invalid item count" + end + elseif verb ~= "heal_party" then + if + not (verb == "check_dex_owned" and source[2] == nil) and not integer(source[2], 0, 9007199254740991) + then + return nil, "invalid script amount" + end + end + return { verb, source[2], source[3] } +end +local function tableOrNil(value) + return value == nil or type(value) == "table" +end +function S.prepare(loader, game, world, rows) + local ids = Ids.new(loader) + local plans = {} + local save = game.save + local function number(value) + return value == nil or integer(value, 0, 9007199254740991) + end + local function validate(row) + local verb = row[1] + if S.ARITY[verb] then + local reason, key + if verb == "give_money" or verb == "take_money" or verb == "check_money" then + if not number(save.money) then + reason = "invalid money store" + elseif + verb == "give_money" + and Version.generation() == 1 + and not integer((save.money or 0) + row[2], 0, 9007199254740991) + then + reason = "money overflow" + end + elseif verb == "take_coins" then + if not number(save.coins) then + reason = "invalid coin store" + end + elseif verb == "check_item" or verb == "take_item" or verb == "mark_seen" then + local kind = verb == "mark_seen" and "pokemon" or "items" + local group, why = ids:resolve(kind, row[2]) + if not group or not (game.data[kind] and game.data[kind][group.key]) then + reason = why or "content unavailable" + else + key = group.key + end + if not reason and kind == "items" then + if + type(save.inventory) ~= "table" + or not number(save.inventory[key]) + or not tableOrNil(save.bagOrder) + then + reason = "invalid inventory store" + end + end + end + if verb == "mark_seen" or verb == "check_dex_owned" then + local dex = save.pokedex + if + not tableOrNil(dex) + or dex and (not tableOrNil(dex.seen) or not tableOrNil(dex.owned) or not tableOrNil(dex.caught)) + then + reason = "invalid pokedex store" + end + elseif verb == "heal_party" then + if type(save.party) ~= "table" then + reason = "invalid party" + else + for _, mon in ipairs(save.party) do + if + type(mon) ~= "table" + or type(mon.stats) ~= "table" + or not integer(mon.stats.hp, 1, 65535) + or not tableOrNil(mon.moves) + then + reason = "invalid party" + break + end + for _, move in ipairs(mon.moves or {}) do + if type(move) ~= "table" or not (game.data.moves and game.data.moves[move.id]) then + reason = "invalid party move" + break + end + end + end + end + end + if reason then + return nil, reason + end + return { key = key } + end + end + for i, row in ipairs(rows) do + if S.ARITY[row[1]] then + local plan, reason = validate(row) + if not plan then + return nil, "row " .. i .. ": " .. reason + end + plans[row] = plan + end + end + return function(ctx, row) + local verb, a = row[1], row[2] + local plan = plans[row] + if not plan then + return false + end + -- Revalidate live stores too: another mod can mutate them while this script waits. + local checked, reason = validate(row) + if not checked then + error(reason, 0) + end + if verb == "give_money" or verb == "take_money" then + local value = math.max(0, (save.money or 0) + (verb == "give_money" and a or -a)) + if Version.generation() ~= 1 then + value = math.min(999999, value) + end + save.money = value + elseif verb == "check_money" then + ctx.lastCheck = (save.money or 0) >= a + elseif verb == "take_coins" then + save.coins = math.max(0, (save.coins or 0) - a) + elseif verb == "check_item" then + ctx.lastCheck = (save.inventory[plan.key] or 0) > 0 + elseif verb == "take_item" then + local n = math.min(row[3] or 1, save.inventory[plan.key] or 0) + if n > 0 then + require("src.inventory.Bag").remove(save, plan.key, n) + end + elseif verb == "mark_seen" then + save.pokedex = save.pokedex or {} + save.pokedex.seen = save.pokedex.seen or {} + save.pokedex.seen[plan.key] = true + elseif verb == "check_dex_owned" then + local dex = save.pokedex + local n = 0 + for _, value in pairs(dex and (dex.owned or dex.caught) or {}) do + if value then + n = n + 1 + end + end + ctx.lastCheck = n >= (a or 1) + elseif verb == "heal_party" then + for _, mon in ipairs(save.party) do + require("src.pokemon.Pokemon").heal(mon, game.data.moves) + end + end + return true + end +end +return S diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index 79c91e7fe..2ed5e2137 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -467,10 +467,10 @@ function Pokemon.restorePP(mon, moves) end end -function Pokemon.heal(mon) +function Pokemon.heal(mon, moves) mon.hp = mon.stats.hp mon.status = nil - Pokemon.restorePP(mon) + Pokemon.restorePP(mon, moves) end function Pokemon.isFainted(mon) diff --git a/tests/api3_contract/mods/script_state/main.lua b/tests/api3_contract/mods/script_state/main.lua new file mode 100644 index 000000000..4c1039daf --- /dev/null +++ b/tests/api3_contract/mods/script_state/main.lua @@ -0,0 +1,16 @@ +local mod = ... +local world = mod.world +local done, success = 0 +mod.exports.queue = function(rows) + return world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.get = function(name) + return world:getFlag(name) +end +mod.exports.result = function() + return { done = done, success = success } +end diff --git a/tests/api3_contract/mods/script_state/manifest.json b/tests/api3_contract/mods/script_state/manifest.json new file mode 100644 index 000000000..3f29a475f --- /dev/null +++ b/tests/api3_contract/mods/script_state/manifest.json @@ -0,0 +1 @@ +{"id":"script_state","name":"Portable state actions","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/script_flags.lua b/tests/api3_contract/script_flags.lua index 3d2ef8bbc..af3020908 100644 --- a/tests/api3_contract/script_flags.lua +++ b/tests/api3_contract/script_flags.lua @@ -1,7 +1,7 @@ local M = {} -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, modId) local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_flags%.lua$") - local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "script_flags") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, modId or "script_flags") f.loader.game = f.game f.ow.runner.update = function() end if f.field then diff --git a/tests/api3_contract/script_state.lua b/tests/api3_contract/script_state.lua new file mode 100644 index 000000000..33bb3eb28 --- /dev/null +++ b/tests/api3_contract/script_state.lua @@ -0,0 +1,109 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_state%.lua$") + local f = dofile(codeRoot .. "/script_flags.lua").fixture(root, backend, version, "script_state") + local save, data = f.game.save, f.game.data + save.money, save.coins = 100, 50 + save.inventory, save.bagOrder = { POTION = 3 }, { "POTION" } + save.pokedex = { seen = {}, owned = { MEW = true } } + save.party = + { { species = "MEW", hp = 1, status = "PSN", stats = { hp = 20 }, moves = { { id = "POUND", pp = 1 } } } } + f.pp = function() + return save.party[1].moves[1].pp + end + f.seen = function() + return save.pokedex.seen.MEW == true + end + f.quantity = function() + return save.inventory.POTION or 0 + end + f.wallet = function() + return save.money, save.coins + end + if backend == "gen1" and f.gen == 1 then + require("src.core.Data").moves = data.moves -- Native heal uses the active data singleton. + elseif backend == "gen1" and f.gen == 2 then + f.ow.game = f.game + save.player = { money = 100, coins = 50 } + local World = require("src.world.gen2.World") + f.ow.money, f.ow.setMoney = World.money, World.setMoney + f.ow.coins, f.ow.setCoins = World.coins, World.setCoins + f.ow.healParty = World.healParty + save.party[1].maxHp = 20 + f.wallet = function() + return save.player.money, save.player.coins + end + elseif backend == "gen1" and f.gen == 3 then + local pokemon = require("src.core.game3.pokemon") + for key, value in pairs(data.gen3Pokemon) do + pokemon[key] = value + end + pokemon._byName = { MEW = 151 } + require("src.core.game3.items_data").installPack({ items = data.gen3Items._byId }) + local Bag = require("src.core.game3.bag") + save.bag = Bag.new() + save.bag.pockets.ITEMS = { { id = 13, qty = 3 } } + save.dex = { seen = {}, caught = { [151] = true }, owned = { [151] = true } } + save.party = + { { species = 151, hp = 1, maxHp = 20, status = 8, moves = { 1 }, pp = { 1 }, maxPp = { 35 } } } + f.pp = function() + return save.party[1].pp[1] + end + f.seen = function() + return require("src.core.game3.dex").isSeen(save.dex, 151) + end + f.quantity = function() + return Bag.get(save.bag, 13) + end + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "give_money", 50 }, + { "take_money", 20 }, + { "check_money", 130 }, + { "jump_if_false", "failed" }, + { "take_coins", 12 }, + { "check_item", "POTION" }, + { "jump_if_false", "failed" }, + { "take_item", "POTION", 2 }, + { "check_dex_owned", 1 }, + { "jump_if_false", "failed" }, + { "mark_seen", "MEW" }, + { "heal_party" }, + { "set_flag", "mod:passed" }, + { "jump", "end" }, + { "label", "failed" }, + { "set_flag", "mod:failed" }, + })) + f:drain() + assert(f.exports.result().success == true) + assert(f.exports.get("mod:passed") == true and f.exports.get("mod:failed") == false) + local money, coins = f.wallet() + local report = { + money = money, + coins = coins, + items = f.quantity(), + hp = f.game.save.party[1].hp, + pp = f.pp(), + seen = f.seen(), + } + assert( + money == 130 + and coins == 38 + and report.items == 1 + and report.hp == 20 + and report.pp == 35 + and report.seen == true + ) + f = M.fixture(root, backend, version) + local value, why = f.exports.queue({ { "give_money", 1 }, { "take_item", "NO_SUCH_ITEM" } }) + assert(value == nil and type(why) == "string") + assert(f.wallet() == 100 and f.quantity() == 3 and f.exports.result().done == 0) + report.preflight = true + 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 c5812249d..23aeea734 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -17,11 +17,14 @@ assert( or mode == "invalidate" or mode == "flags" or mode == "script_flags" + or mode == "script_state" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_flags" then +if mode == "script_state" then + dofile(codeRoot .. "/script_state.lua").run(root, backend, version) +elseif mode == "script_flags" then dofile(codeRoot .. "/script_flags.lua").run(root, backend, version) elseif mode == "flags" then dofile(codeRoot .. "/flags.lua").run(root, backend, version) diff --git a/tests/engine/portable_script_state.lua b/tests/engine/portable_script_state.lua new file mode 100644 index 000000000..4dd84c487 --- /dev/null +++ b/tests/engine/portable_script_state.lua @@ -0,0 +1,109 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_state") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +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 state actions: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "give_money", 1 }, { "take_item", "UNKNOWN" } }, + { { "jump", "end" }, { "mark_seen", "UNKNOWN" } }, + { { "give_money", -1 } }, + { { "take_money", 0.5 } }, + { { "give_money", math.huge } }, + { { "check_money", "10" } }, + { { "take_coins", false } }, + { { "take_item", "POTION", 0 } }, + { { "take_item", "POTION", 1000 } }, + { { "check_dex_owned", -1 } }, + { { "heal_party", true } }, + { { "check_item", {} } }, +}) do + local f = fixture() + local runner = f.ow.runner + T.eq(f.exports.queue(rows), nil, "whole invalid program declined") + T.eq(f.wallet(), 100, "money prefix not executed") + T.eq(f.quantity(), 3, "inventory untouched") + T.eq(f.ow.runner, runner, "no runner installed on refusal") + T.eq(f.exports.result().done, 0, "no completion on refusal") +end +for _, store in ipairs({ "money", "coins", "inventory", "bagOrder", "pokedex", "party" }) do + local f = fixture() + f.game.save[store] = false + local row = ({ + money = { "give_money", 1 }, + coins = { "take_coins", 1 }, + inventory = { "take_item", "POTION" }, + bagOrder = { "take_item", "POTION" }, + pokedex = { "mark_seen", "MEW" }, + party = { "heal_party" }, + })[store] + T.eq(f.exports.queue({ { "set_flag", "mod:prefix" }, row }), nil, "malformed " .. store .. " declined") + T.eq(f.exports.get("mod:prefix"), false, "store failure prevents prefix") + T.eq(f.game.save[store], false, "malformed store not overwritten") +end +do + local f = fixture() + f.game.save.party[1].stats = nil + T.eq( + f.exports.queue({ { "give_money", 1 }, { "heal_party" } }), + nil, + "malformed party record refused up front" + ) + T.eq(f.wallet(), 100, "party refusal preserves wallet") +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + local f = fixture(version) + f.game.save.money = 999990 + T.check( + f.exports.queue({ { "give_money", 50 }, { "take_coins", 999 }, { "take_item", "POTION", 999 } }), + "bounded state operations admitted" + ) + f:drain() + T.eq(f.wallet(), version == "red" and 1000040 or 999999, "native generation wallet cap") + T.eq(f.game.save.coins, 0, "coin subtraction saturates at zero") + T.eq(f.quantity(), 0, "item subtraction removes available quantity only") + T.eq(#f.game.save.bagOrder, 0, "native bag order entry removed") +end +do + local f = fixture() + f.game.save.money = 9007199254740991 + T.eq(f.exports.queue({ { "set_flag", "mod:prefix" }, { "give_money", 1 } }), nil, "wallet overflow refused") + T.eq(f.exports.get("mod:prefix"), false, "overflow refusal prevents prefix") + T.eq(f.game.save.money, 9007199254740991, "overflow cannot round native wallet") +end +do + local f = fixture() + T.check( + f.exports.queue({ { "check_dex_owned" }, { "jump_if_false", "end" }, { "set_flag", "mod:owned" } }), + "default dex threshold admitted" + ) + f:drain() + T.eq(f.exports.get("mod:owned"), true, "default dex threshold is one") +end +do + local f = fixture() + T.check(f.exports.queue({ { "wait", 1 }, { "take_item", "POTION", 1 } }), "state action waits") + f.game.save.inventory = false + f:drain() + T.eq(f.exports.result().success, false, "changed live store fails safely") + T.eq(f.game.save.inventory, false, "changed malformed store not overwritten") +end +do + local f = fixture() + local rows = { { "wait", 1 }, { "give_money", 10 } } + T.check(f.exports.queue(rows), "copy program admitted") + rows[2][2] = 100 + f:drain() + T.eq(f.wallet(), 110, "accepted scalar arguments detached") +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("Portable script state actions") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 222f47222..1ee1e75b4 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", "npcs", "wild_battle", "invalidate", "flags", "script_flags"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 76717887264f26b93bc0343e27e23b93c607b88a Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:48:09 +0200 Subject: [PATCH 21/42] Add portable native gift scripts with owned UI continuations --- docs/api3-portability.md | 4 +- docs/portable-world-scripts.md | 38 ++++ src/mods/PortableScripts.lua | 77 ++++++- src/mods/ScriptGifts.lua | 135 ++++++++++++ src/mods/ScriptProgram.lua | 23 +- src/script/Commands.lua | 39 ++-- .../api3_contract/mods/script_gifts/main.lua | 16 ++ .../mods/script_gifts/manifest.json | 1 + tests/api3_contract/script_gifts.lua | 57 +++++ tests/api3_contract/script_state.lua | 4 +- tests/api3_contract/worker.lua | 5 +- tests/engine/portable_script_gifts.lua | 199 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 13 files changed, 579 insertions(+), 21 deletions(-) create mode 100644 src/mods/ScriptGifts.lua create mode 100644 tests/api3_contract/mods/script_gifts/main.lua create mode 100644 tests/api3_contract/mods/script_gifts/manifest.json create mode 100644 tests/api3_contract/script_gifts.lua create mode 100644 tests/engine/portable_script_gifts.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index f80db8650..2e35ab5aa 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -134,8 +134,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 and eight NPC cases extend this to 88 comparisons -and 176 isolated loader runs (including world-action, flag, script-flag and script-state cases). It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 96 comparisons +and 192 isolated loader runs (including world-action, flag and portable-script action cases). 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 diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index a9445b950..350da5f24 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -243,3 +243,41 @@ The full pinned checker now runs 88 comparisons /176 isolated loader runs. Host tests add malformed/unreachable-row refusal with no prefix effects, wallet caps, saturation, actual Bag order removal, live-store failure and argument copying. These are ROM-free runtime/store checks, not a story playthrough. + +## Item and Pokemon gifts + +- `{ "give_item", itemId, count?, gotText?, bagFullText? }`: use the native + bag gift command. Count defaults to one (1–999). Optional receipt/full-bag + text is literal UTF-8 up to 4096 bytes; `gotText=false` suppresses the receipt. + A full bag shows its native failure message and halts before later gift flags. +- `{ "give_pokemon", speciesId, level, skipNickname?, gotText? }`: use native + creation, OT stamping, party/box placement and nickname flow. Level is 1–100; + the optional flags are actual booleans. `gotText=true` requests a receipt + before the nickname question. A full party sends the gift through native box + storage and waits for its sent-to-box text; completely full storage leaves + the native conditional result false for the script to branch on. + +The complete row list's content references, counts/options and referenced +inventory/party/player/dex/box store shapes are checked before a prefix runs. +IDs are canonical, but native bag capacities, native Pokemon fields and native +storage layouts remain the host's. Already entered native effects are not a +transaction. Scripts cannot pass executable callbacks or native options into +these rows. Gifts do not introduce new species constructors or storage slots. + +The isolated runner adopts exact native screens created by the gift operation +and screens subsequently created by those owned screens. Native receipt, +YES/NO and naming callbacks receive per-operation, per-phase one-shot guards; +an old receipt/question/naming callback cannot mutate a nickname twice, bypass +a later screen or skip the next row's wait. Cancellation removes owned screens +and retains a foreign overlay. A mod retired by its before-give hook cannot +continue into native Pokemon construction. Native callers outside the portable +context retain their existing callback/default behavior. + +The same public mod on both engines verifies item and Pokemon gifts with +receipt/nickname suppressed, real native bag/party/dex effects and whole-list +preflight refusal. This extends the pinned suite to 96 comparisons /192 isolated +loader runs. Host tests additionally drive actual native receipts, bag-full +messages, YES/NO, naming, box deposits, callback replay and unload/session +cancellation on synthetic data. Rendering/pixel equivalence and retail-ROM +playthroughs are not claimed. Direct mutations of engine objects by other mods +remain outside the portable runner's ownership guarantees. diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index 82eadbebb..fe9743491 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -4,6 +4,7 @@ local Program = require("src.mods.ScriptProgram") local Runtime = require("src.mods.Runtime") local Flags = require("src.mods.Api3Flags") local State = require("src.mods.ScriptState") +local Gifts = require("src.mods.ScriptGifts") local GameVersion = require("src.core.GameVersion") local Scripts = {} local runs = setmetatable({}, { __mode = "k" }) @@ -236,6 +237,10 @@ local function facade(loader, modId, native) if not state then return nil, reason end + local gift, giftReason = Gifts.prepare(loader, game, ow, program) + if not gift then + return nil, giftReason + end local Runner = require("src.script.ScriptRunner") local runner = Runner.new(game, ow) local token = { @@ -256,6 +261,7 @@ local function facade(loader, modId, native) player = ow.player, version = GameVersion.get(), ui = {}, + ownedUI = {}, steps = 0, } function token.valid() @@ -325,20 +331,82 @@ local function facade(loader, modId, native) end return valid end + local discover + local function snapshot() + local before = {} + for _, state in ipairs(game.stack.states) do + before[state] = true + end + return before + end local function owned(state, step) + if token.ownedUI[state] then + return state + end + token.ownedUI[state] = true 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 before = token.nativeStep == step and snapshot() local ok, err = engine(update, self, ...) + if before then + discover(before, step) + end if not ok then token.finish(false, "native UI failed: " .. message(err)) end end return state end + discover = function(before, step) + if token.done or token.step ~= step then + return + end + for _, state in ipairs(game.stack.states) do + if not before[state] then + owned(state, step) + if token.nativeStep == step then + token.waitingUI = state + end + end + end + end + local function nativeGift(ctx, row) + local step = token.step + local bound = setmetatable({ + resume = function(_, ...) + if token.step == step and check() then + runner:resume(...) + end + end, + }, { __index = runner, __newindex = runner }) + local revision = 0 + local proxy = setmetatable({ + portableCallback = function(callback) + local expected, fired = revision, false + return function(...) + if fired or expected ~= revision or token.step ~= step or not check() then + return + end + fired = true + revision = revision + 1 + return callback(...) + end + end, + runner = bound, + portableAlive = function() + return token.step == step and check() + end, + }, { __index = ctx, __newindex = ctx }) + token.nativeStep = step + local result = gift(proxy, row) + token.nativeStep = nil + token.waitingUI = nil + return result + end local function dialog(ctx, text, asking, step) local TextBox = require("src.render.TextBox") local box, answered @@ -446,7 +514,11 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if State.ARITY[verb] then + if Gifts.ARITY[verb] then + if nativeGift(ctx, row) == math.huge then + return + end + elseif State.ARITY[verb] then state(ctx, row) elseif verb == "set_flag" or verb == "clear_flag" then local ok, reason = flag(row[2], verb == "set_flag", true) @@ -505,7 +577,10 @@ local function facade(loader, modId, native) if not check() or not self.co or coroutine.status(self.co) == "running" then return end + local before, step = snapshot(), token.step + token.waitingUI = nil local ok, err = coroutine.resume(self.co, ...) + discover(before, token.step or step) if not ok then Runtime.reportError(modId, "portable script failed: " .. message(err)) token.finish(false, "script failed: " .. message(err)) diff --git a/src/mods/ScriptGifts.lua b/src/mods/ScriptGifts.lua new file mode 100644 index 000000000..63b4b535d --- /dev/null +++ b/src/mods/ScriptGifts.lua @@ -0,0 +1,135 @@ +-- Closed portable gift rows; execute through native gift/bag/party/box commands. +local Ids = require("src.mods.UnifiedIds") +local G = { ARITY = { give_item = 5, give_pokemon = 5 } } +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 id(s) + return type(s) == "string" and #s > 0 and #s <= 256 and not s:find("%c") +end +local function text(s) + return type(s) == "string" and #s <= 4096 and not s:find("%z") +end +function G.syntax(verb, row) + if not id(row[2]) then + return nil, "invalid gift id" + end + if verb == "give_item" then + if #row < 2 or #row > 5 then + return nil, "invalid command arity" + end + if row[3] ~= nil and not integer(row[3], 1, 999) then + return nil, "invalid item count" + end + if row[4] ~= nil and row[4] ~= false and not text(row[4]) then + return nil, "invalid gift text" + end + if row[5] ~= nil and not text(row[5]) then + return nil, "invalid bag full text" + end + else + if #row < 3 or #row > 5 then + return nil, "invalid command arity" + end + if not integer(row[3], 1, 100) then + return nil, "invalid gift level" + end + if row[4] ~= nil and type(row[4]) ~= "boolean" or row[5] ~= nil and type(row[5]) ~= "boolean" then + return nil, "invalid gift option" + end + end + return { row[1], row[2], row[3], row[4], row[5] } +end +function G.prepare(loader, game, world, rows) + local ids = Ids.new(loader) + local plans = {} + local function validate(row) + if G.ARITY[row[1]] then + local kind = row[1] == "give_item" and "items" or "pokemon" + local group, reason = ids:resolve(kind, row[2]) + if not group or not (game.data[kind] and game.data[kind][group.key]) then + return nil, reason or "gift content unavailable" + end + local save = game.save + if row[1] == "give_item" then + if type(save.inventory) ~= "table" or save.bagOrder ~= nil and type(save.bagOrder) ~= "table" then + return nil, "invalid inventory store" + end + for _, count in pairs(save.inventory) do + if not integer(count, 0, 999999) then + return nil, "invalid inventory count" + end + end + elseif + type(save.party) ~= "table" + or type(save.pokedex) ~= "table" + or type(save.pokedex.seen) ~= "table" + or type(save.pokedex.owned) ~= "table" + then + return nil, "invalid gift party/dex store" + end + if row[1] == "give_pokemon" then + if type(save.player) ~= "table" then + return nil, "invalid gift player store" + end + if #save.party > 6 then + return nil, "invalid gift party" + end + for index, mon in pairs(save.party) do + if not integer(index, 1, #save.party) or type(mon) ~= "table" then + return nil, "invalid gift party" + end + end + for index = 1, #save.party do + if type(save.party[index]) ~= "table" then + return nil, "invalid gift party" + end + end + for _, name in ipairs({ "boxes", "box", "boxNames" }) do + if save[name] ~= nil and type(save[name]) ~= "table" then + return nil, "invalid box store" + end + end + if save.currentBox ~= nil and not integer(save.currentBox, 1, 65535) then + return nil, "invalid box index" + end + for _, box in pairs(save.boxes or {}) do + if type(box) ~= "table" then + return nil, "invalid box store" + end + end + end + return group.key + end + end + for i, row in ipairs(rows) do + if G.ARITY[row[1]] then + local key, reason = validate(row) + if not key then + return nil, "row " .. i .. ": " .. reason + end + plans[row] = key + end + end + return function(ctx, row) + local key = plans[row] + if not key then + error("unprepared gift", 0) + end + if not game.data[row[1] == "give_item" and "items" or "pokemon"][key] then + error("gift content unavailable", 0) + end + local ready, reason = validate(row) + if not ready then + error(reason, 0) + end + local Commands = require("src.script.Commands") + if row[1] == "give_item" then + ctx.portableBagFullText = row[5] + return Commands.give_item(ctx, key, row[3], row[4]) + end + ctx.portableGiftText = row[5] + return Commands.give_pokemon(ctx, key, row[3], row[4]) + end +end +return G diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index a7101fedb..6ca6d6c33 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -1,6 +1,7 @@ -- Pure preflight for the bounded portable dialogue/control-flow language. local Program = {} local State = require("src.mods.ScriptState") +local Gifts = require("src.mods.ScriptGifts") Program.MAX_ROWS, Program.MAX_STEPS, Program.SLICE = 1024, 65536, 128 local ARITY = { set_flag = 2, @@ -20,6 +21,9 @@ local ARITY = { for verb, arity in pairs(State.ARITY) do ARITY[verb] = arity end +for verb, arity in pairs(Gifts.ARITY) do + ARITY[verb] = arity +end local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) return type(verb) == "string" and (ARITY[aliases[verb] or verb] ~= nil) @@ -89,7 +93,7 @@ function Program.compile(raw, context) return nil, "row " .. i .. ": " .. message end for i = 1, n do - local source, count = raw[i], array(raw[i], 3) + local source, count = raw[i], array(raw[i], 5) if not count or count == 0 then return fail(i, "expected a dense plain command row") end @@ -99,12 +103,27 @@ function Program.compile(raw, context) end if not State.ARITY[verb] + and not Gifts.ARITY[verb] and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if State.ARITY[verb] then + if Gifts.ARITY[verb] then + local value, reason = Gifts.syntax(verb, source) + if not value then + return fail(i, reason) + end + row = value + for _, index in ipairs({ 4, 5 }) do + if type(row[index]) == "string" then + if not text(row[index], 4096) then + return fail(i, "gift text must be valid UTF-8 without NUL") + end + bytes = bytes + #row[index] + end + end + elseif State.ARITY[verb] then local value, reason = State.syntax(verb, source) if not value then return fail(i, reason) diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 568ad5dfc..983bc3fc1 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -370,9 +370,9 @@ function Commands.show_text(ctx, textId, subs, extraOpts) -- ctx.lastCheck untouched -- the prompt never appears and the script reads -- the stale value as the answer. if opts and opts.choice then opts.auto = nil end - ctx.game.stack:push(TextBox.new(ctx.game, text, function() - runner:resume() - end, opts)) + local onDone = function() runner:resume() end + if ctx.portableCallback then onDone = ctx.portableCallback(onDone) end + ctx.game.stack:push(TextBox.new(ctx.game, text, onDone, opts)) runner:yield() end @@ -554,7 +554,7 @@ function Commands.give_item(ctx, itemId, count, gotText) if not require("src.inventory.Bag").add( ctx.save, itemId, count or 1, ctx.game.data) then ctx.lastCheck = false - Commands.show_text(ctx, ctx.game.data.text + Commands.show_text(ctx, ctx.portableBagFullText or ctx.game.data.text and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!")) return math.huge end @@ -1221,21 +1221,24 @@ local function askNickname(ctx, mon) -- opts.choice (TextBox.lua): the YES/NO box rides over the still-visible -- question instead of a bare ChoiceBox popping up after an A press -- already cleared it (same fix as Commands.ask). - Commands.show_text(ctx, textId, subs, { choice = function(yes) + local choice = function(yes) if not yes then ctx.lastCheck = success runner:resume() return end + local onDone = function(nick) + if nick and #nick > 0 then mon.nickname = nick end + ctx.lastCheck = success + runner:resume() + end + if ctx.portableCallback then onDone = ctx.portableCallback(onDone) end Screens.push(ctx.game, "NamingScreen", { - title = Strings("NICKNAME?"), maxLen = 10, - onDone = function(nick) - if nick and #nick > 0 then mon.nickname = nick end - ctx.lastCheck = success - runner:resume() - end, + title = Strings("NICKNAME?"), maxLen = 10, onDone = onDone, }) - end }) + end + if ctx.portableCallback then choice = ctx.portableCallback(choice) end + Commands.show_text(ctx, textId, subs, { choice = choice }) end -- give_pokemon : _GivePokemon (engine/events/ @@ -1265,6 +1268,7 @@ function Commands.give_pokemon(ctx, species, level, skipNickname, opts) ctx.game.mods.events:emit("pokemon.before_give", gift) species, level = gift.species, gift.level end + if ctx.portableAlive and not ctx.portableAlive() then return end local Pokemon = require("src.pokemon.Pokemon") local Party = require("src.pokemon.Party") local Boxes = require("src.pokemon.Boxes") @@ -1314,6 +1318,17 @@ function Commands.give_pokemon(ctx, species, level, skipNickname, opts) ctx.addedToParty = addedToParty require('src.pokemon.Gen4PoketchState').remember(ctx.game, mon) ctx.boxNum = boxNum + if ctx.portableGiftText and ctx.runner then + local wait = true + if not gift.nickname and not skipNickname then wait = false end + ctx.textOpts = ctx.textOpts or {} + ctx.textOpts.auto = { + sound = function() return require("src.core.Sound").play(ctx.game.data, "Get_Item1") end, + wait = wait, + } + Commands.show_text(ctx, ctx.game.data.text and ctx.game.data.text._GotMonText + or Strings("{PLAYER} received\n%s!", ctx.game.stringBuffer)) + end -- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks -- and callback-style callers with no script runner to yield on. if not gift.nickname and not skipNickname and ctx.runner then diff --git a/tests/api3_contract/mods/script_gifts/main.lua b/tests/api3_contract/mods/script_gifts/main.lua new file mode 100644 index 000000000..4c1039daf --- /dev/null +++ b/tests/api3_contract/mods/script_gifts/main.lua @@ -0,0 +1,16 @@ +local mod = ... +local world = mod.world +local done, success = 0 +mod.exports.queue = function(rows) + return world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.get = function(name) + return world:getFlag(name) +end +mod.exports.result = function() + return { done = done, success = success } +end diff --git a/tests/api3_contract/mods/script_gifts/manifest.json b/tests/api3_contract/mods/script_gifts/manifest.json new file mode 100644 index 000000000..7129948d2 --- /dev/null +++ b/tests/api3_contract/mods/script_gifts/manifest.json @@ -0,0 +1 @@ +{"id":"script_gifts","name":"Portable gifts","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/script_gifts.lua b/tests/api3_contract/script_gifts.lua new file mode 100644 index 000000000..9aee71bb0 --- /dev/null +++ b/tests/api3_contract/script_gifts.lua @@ -0,0 +1,57 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_gifts%.lua$") + local f = dofile(codeRoot .. "/script_state.lua").fixture(root, backend, version, "script_gifts") + love = love or require("tests.love_stub") + local data, save = f.game.data, f.game.save + data.text = data.text or {} + data.font = data.font or dofile(codeRoot .. "/../fixture_data/font.lua") + if not getmetatable(data) then + setmetatable(data, { __index = require("src.core.Data") }) + end + save.player = save.player or {} + save.player.name, save.player.id = "TEST", 12345 + f.game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + local Stack = require("src.core.StateStack") + setmetatable(f.game.stack, { __index = Stack }) + f.ow.update = function(self) + self.runner:update() + end + require("src.render.Font").load(data) + if backend == "gen1" and f.gen == 3 then + local pokemon = require("src.core.game3.pokemon") + pokemon._learnsets, pokemon._national, pokemon._abilityNames = {}, { [151] = 151 }, {} + end + f.party = function() + local mon = save.party[#save.party] + return { count = #save.party, level = mon.level, species = (mon.species == 151 and "MEW" or mon.species) } + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "give_item", "POTION", 2, false }, + { "give_pokemon", "MEW", 5, true, false }, + { "set_flag", "mod:gifted" }, + })) + f:drain() + assert(f.exports.result().success == true and f.exports.get("mod:gifted") == true) + assert(f.quantity() == 5 and #f.game.save.party == 2) + local report = { items = f.quantity(), party = f.party() } + f = M.fixture(root, backend, version) + local value, why = + f.exports.queue({ { "give_item", "POTION", 1, false }, { "give_pokemon", "UNKNOWN", 5, true, false } }) + assert(value == nil and type(why) == "string" and f.quantity() == 3 and #f.game.save.party == 1) + report.preflight = true + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/script_state.lua b/tests/api3_contract/script_state.lua index 33bb3eb28..dc7148928 100644 --- a/tests/api3_contract/script_state.lua +++ b/tests/api3_contract/script_state.lua @@ -1,7 +1,7 @@ local M = {} -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, modId) local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_state%.lua$") - local f = dofile(codeRoot .. "/script_flags.lua").fixture(root, backend, version, "script_state") + local f = dofile(codeRoot .. "/script_flags.lua").fixture(root, backend, version, modId or "script_state") local save, data = f.game.save, f.game.data save.money, save.coins = 100, 50 save.inventory, save.bagOrder = { POTION = 3 }, { "POTION" } diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index 23aeea734..0b9872292 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -18,11 +18,14 @@ assert( or mode == "flags" or mode == "script_flags" or mode == "script_state" + or mode == "script_gifts" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_state" then +if mode == "script_gifts" then + dofile(codeRoot .. "/script_gifts.lua").run(root, backend, version) +elseif mode == "script_state" then dofile(codeRoot .. "/script_state.lua").run(root, backend, version) elseif mode == "script_flags" then dofile(codeRoot .. "/script_flags.lua").run(root, backend, version) diff --git a/tests/engine/portable_script_gifts.lua b/tests/engine/portable_script_gifts.lua new file mode 100644 index 000000000..f1a42964e --- /dev/null +++ b/tests/engine/portable_script_gifts.lua @@ -0,0 +1,199 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_gifts") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +local function tick(f, key) + f.game.input.wasPressed = function(_, button) + return button == key + end + f.loader.hooks:call("input.step", function() end, f.game, 1 / 60) + f.game.stack:update(1 / 60) + f.game.input.wasPressed = function() + return false + end +end +local function close(f) + for _ = 1, 2000 do + if f.exports.result().done > 0 then + return + end + tick(f, "a") + end + error("gift UI did not close") +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 gift contract: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "give_item", "POTION", 1, false }, { "give_pokemon", "UNKNOWN", 5, true, false } }, + { { "give_item", "POTION", 0 } }, + { { "give_item", "POTION", 1000 } }, + { { "give_item", "POTION", 1, true } }, + { { "give_item", "POTION", 1, "\255" } }, + { { "give_pokemon", "MEW", 0 } }, + { { "give_pokemon", "MEW", 101 } }, + { { "give_pokemon", "MEW", 5, "yes" } }, + { { "give_pokemon", "MEW", 5, true, "text" } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid gift list refused") + T.eq(f.quantity(), 3, "gift prefix does not write inventory") + T.eq(#f.game.save.party, 1, "gift prefix does not create party mon") + T.eq(f.exports.result().done, 0, "refusal has no completion") +end +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + T.check( + f.exports.queue({ { "give_item", "POTION", 1, "RECEIVED POTION!" }, { "set_flag", "mod:after" } }), + "native receipt admitted" + ) + T.eq(f.quantity(), 4, "native bag already contains received item") + T.eq(f.exports.result().done, 0, "script waits for native receipt") + close(f) + T.eq(f.exports.result().success, true, "receipt completes native script") + T.eq(f.exports.get("mod:after"), true, "next command runs after receipt only") + T.eq(f.game.stack:top(), f.ow, "owned receipt removed") +end +do + local f = fixture() + T.check( + f.exports.queue({ { "give_pokemon", "MEW", 5, true, true }, { "set_flag", "mod:after" } }), + "native mon receipt admitted" + ) + T.eq(#f.game.save.party, 2, "native mon gift created once") + T.eq(f.exports.result().done, 0, "native mon receipt waits") + close(f) + T.eq(f.exports.result().success, true, "native mon receipt complete") + T.eq(f.exports.get("mod:after"), true, "mon gift resumes script") +end +do + local f = fixture() + f.game.save.inventory.POTION = 99 + T.check( + f.exports.queue({ { "give_item", "POTION", 1, false, "BAG IS FULL!" }, { "set_flag", "mod:burned" } }), + "full bag admitted for native refusal UI" + ) + T.eq(f.quantity(), 99, "full bag adds nothing") + T.eq(f.exports.result().done, 0, "bag full message awaited") + close(f) + T.eq(f.exports.result().success, true, "native full bag halts cleanly") + T.eq(f.exports.get("mod:burned"), false, "full bag cannot burn later gift flag") +end +for _, action in ipairs({ "rollback", "teardown", "save" }) do + local f = fixture() + local original = f.ow.runner + T.check( + f.exports.queue({ { "give_item", "POTION", 1, "RECEIVED!" }, { "set_flag", "mod:after" } }), + "receipt before " .. action + ) + local box = f.game.stack:top() + local overlay = { update = function() end } + f.game.stack:push(overlay) + if action == "rollback" then + f.loader:_rollback("script_gifts") + elseif action == "teardown" then + Loader.teardown() + else + f.game.save = {} + tick(f) + end + T.eq(f.game.stack:top(), overlay, "foreign overlay retained") + T.eq(#f.game.stack.states, 2, "only owned receipt removed") + T.eq(f.ow.runner, original, "native original runner restored") + T.eq(f.exports.result().done, 1, "cancelled receipt completes once") + T.eq(f.exports.result().success, false, "cancelled receipt reports failure") + box.onDone() + T.eq(f.exports.result().done, 1, "retained native callback cannot replay cancellation") + T.eq(f.game.stack:top(), overlay, "retained native callback preserves foreign overlay") +end +for _, field in ipairs({ "player", "party", "pokedex", "boxes", "boxNames", "currentBox" }) do + local f = fixture() + f.game.save[field] = false + T.eq( + f.exports.queue({ { "give_item", "POTION", 1, false }, { "give_pokemon", "MEW", 5, true, false } }), + nil, + "malformed " .. field .. " preflight" + ) + T.eq(f.quantity(), 3, "malformed gift destination prevents prefix") +end +do + local f = fixture() + T.check( + f.exports.queue({ { "give_pokemon", "MEW", 5, false, false }, { "wait", 5 }, { "set_flag", "mod:after" } }), + "native nickname flow admitted" + ) + local question = f.game.stack:top() + local Choice = require("src.ui.ChoiceBox") + for _ = 1, 2000 do + if getmetatable(f.game.stack:top()) == Choice then + break + end + tick(f, "a") + end + T.eq(getmetatable(f.game.stack:top()), Choice, "native question opens native choice") + tick(f, "a") + for _ = 1, 40 do + tick(f) + end + local naming = f.game.stack:top() + T.check( + naming ~= f.ow and naming ~= question and getmetatable(naming) ~= Choice, + "native naming screen opened" + ) + T.eq(type(naming.onDone), "function", "native naming completion exists") + question.onDone() + question.choice(false) + T.eq(f.game.stack:top(), naming, "old question callbacks cannot bypass naming") + T.eq(f.exports.result().done, 0, "old question cannot complete gift") + f.game.stack:pop() + naming.onDone("SCOUT") + naming.onDone("REPLAY") + T.eq(f.game.save.party[2].nickname, "SCOUT", "native naming callback commits once") + T.eq(f.exports.result().done, 0, "stale callback cannot skip later wait") + for _ = 1, 5 do + tick(f) + end + T.eq(f.exports.get("mod:after"), true, "native nickname flow resumes normally") +end +do + local f = fixture() + for i = 2, 6 do + f.game.save.party[i] = require("src.mods.Merge").deepCopy(f.game.save.party[1]) + end + T.check( + f.exports.queue({ { "give_pokemon", "MEW", 5, true, false }, { "set_flag", "mod:boxed" } }), + "native box gift admitted" + ) + T.eq(#f.game.save.party, 6, "full party retained") + T.check(f.game.save.boxes[1][1], "actual native box receives gift") + T.eq(f.exports.result().done, 0, "native sent-to-box message awaited") + close(f) + T.eq(f.exports.get("mod:boxed"), true, "box message resumes script") +end +do + local f = fixture() + local party = f.game.save.party + f.game.mods = { events = f.loader.events } + f.loader.events:on("pokemon.before_give", function() + f.loader:_rollback("script_gifts") + end, 0, "probe") + T.check( + f.exports.queue({ { "give_pokemon", "MEW", 5, true, false } }), + "gift admission reaches native hook" + ) + T.eq(#party, 1, "owner retired in before_give cannot construct a gift") + T.eq(f.exports.result().done, 1, "mid-construction cancellation completes once") + T.eq(f.exports.result().success, false, "mid-construction cancellation reported") + T.eq(f.game.stack:top(), f.ow, "cancelled native gift opens no late UI") +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("Portable script gifts") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 1ee1e75b4..fe0fec4e6 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", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 25af724d2ae554ec206ec4715ac10fc63b679aaa Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:03:33 +0200 Subject: [PATCH 22/42] Add validated portable object and block script commands --- docs/api3-portability.md | 2 +- docs/portable-world-scripts.md | 19 +++ src/mods/Api3WorldEdits.lua | 84 ++++++++----- src/mods/PortableScripts.lua | 57 ++++++++- src/mods/ScriptProgram.lua | 21 +++- src/mods/ScriptWorldEdits.lua | 85 +++++++++++++ .../api3_contract/mods/script_world/main.lua | 26 ++++ .../mods/script_world/manifest.json | 1 + tests/api3_contract/script_world.lua | 66 ++++++++++ tests/api3_contract/worker.lua | 5 +- tests/api3_contract/world_edits.lua | 4 +- tests/engine/portable_script_world.lua | 117 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 13 files changed, 451 insertions(+), 38 deletions(-) create mode 100644 src/mods/ScriptWorldEdits.lua create mode 100644 tests/api3_contract/mods/script_world/main.lua create mode 100644 tests/api3_contract/mods/script_world/manifest.json create mode 100644 tests/api3_contract/script_world.lua create mode 100644 tests/engine/portable_script_world.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 2e35ab5aa..32258f6ae 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -135,7 +135,7 @@ 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 and eight NPC cases extend this to 96 comparisons -and 192 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +and 208 isolated loader runs (including world-action, flag and portable-script action cases). 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 diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index 350da5f24..d245a750f 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -281,3 +281,22 @@ messages, YES/NO, naming, box deposits, callback replay and unload/session cancellation on synthetic data. Rendering/pixel equivalence and retail-ROM playthroughs are not claimed. Direct mutations of engine objects by other mods remain outside the portable runner's ownership guarantees. + + +## Object and block scripts + +API 3 also accepts `show_object(mapId, objectRef)`, +`hide_object(mapId, objectRef)` and `replace_block(bx, by, blockId)` on +Gen 1–3. Object references must name an existing definition; Gen 2/3 +require the active map. Block coordinates use the same geometry and limits +as `mod.world:replaceBlock`. Validation covers every row before any prefix +runs, then repeats against current stores at dispatch. + +These rows run through the validated native edit plans under the script's +existing foreground ownership. Ordinary public world actions retain their +idle-world admission guard. Gen 1's seamless object refresh may replace +both the map and its default runner: the script continues on that map and +restores the new idle runner when finished. Foreign overlays pause the +program; unloading, changed saves/maps and invalid live stores cancel it. +Edits already committed before cancellation are retained, and public world +events follow the native edit behavior. diff --git a/src/mods/Api3WorldEdits.lua b/src/mods/Api3WorldEdits.lua index 4b48c180d..77d8ce2eb 100644 --- a/src/mods/Api3WorldEdits.lua +++ b/src/mods/Api3WorldEdits.lua @@ -44,7 +44,9 @@ local function toggle(game, ow, api, gen, req, mapId, ref, visible) end if gen == 1 then -- Gen 1 permits off-map keys and resolves their visibility on map load. - return api:toggleObject(id, ref, visible) + return function() + return api:toggleObject(id, ref, visible) + end end if ow.map.id ~= id then return nil, "map is not active: " .. mapId @@ -68,13 +70,12 @@ local function toggle(game, ow, api, gen, req, mapId, ref, visible) return nil, "object runtime unavailable" end local ctx = { game = game, save = save, overworld = ow, mapId = id } + local index = object.index 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 @@ -85,13 +86,19 @@ local function toggle(game, ow, api, gen, req, mapId, ref, visible) 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 + return function() + if gen == 2 then + req("src.script.Gen2Commands").setObjectVisibility(ctx, object, id, visible) + else + 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 end local function replace(game, ow, api, gen, req, bx, by, block) if not integer(block, gen == 3 and 1023 or 255) then @@ -113,22 +120,26 @@ local function replace(game, ow, api, gen, req, bx, by, block) 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 + return function() + return api:replaceBlock(bx, by, block) + end + end + return function() + -- 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 end local function invalidate(game, ow, api, gen, req, mapId) if not identifier(mapId) then @@ -148,7 +159,9 @@ local function invalidate(game, ow, api, gen, req, mapId) if type(ow.reloadMap) ~= "function" then return nil, "map reload unavailable" end - return api:invalidateMap(id) + return function() + return api:invalidateMap(id) + end end -- Shared admission/commit guard for synchronous API-3 world mutations. function E.ready(game, ow) @@ -170,6 +183,15 @@ function E.run(game, operation, ...) end return value, why end +-- Pure validation shared by public edits and an already owned script run. +function E.plan(game, api, generation, req, name, ...) + local ow = api:overworld() + if not ow then + return nil, "no overworld" + end + local operation = name == "toggleObject" and toggle or name == "invalidateMap" and invalidate or replace + return operation(game, ow, api, generation, req, ...) +end function E.call(game, api, generation, req, name, ...) local ow = api:overworld() if not ow then @@ -179,7 +201,13 @@ function E.call(game, api, generation, req, name, ...) if not ok then return nil, reason end - local operation = name == "toggleObject" and toggle or name == "invalidateMap" and invalidate or replace - return E.run(game, operation, game, ow, api, generation, req, ...) + local function execute(...) + local operation, reason = E.plan(game, api, generation, req, name, ...) + if not operation then + return nil, reason + end + return operation() + end + return E.run(game, execute, ...) end return E diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index fe9743491..fa78194bd 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -5,6 +5,7 @@ local Runtime = require("src.mods.Runtime") local Flags = require("src.mods.Api3Flags") local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local WorldEdits = require("src.mods.ScriptWorldEdits") local GameVersion = require("src.core.GameVersion") local Scripts = {} local runs = setmetatable({}, { __mode = "k" }) @@ -241,6 +242,10 @@ local function facade(loader, modId, native) if not gift then return nil, giftReason end + local edit, editReason = WorldEdits.prepare(game, ow, modId, program) + if not edit then + return nil, editReason + end local Runner = require("src.script.ScriptRunner") local runner = Runner.new(game, ow) local token = { @@ -277,12 +282,12 @@ local function facade(loader, modId, native) 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 not token.mutating and 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 not token.mutating and ow.runner ~= runner or GameVersion.get() ~= token.version or Runtime.hooks ~= loader.hooks or Runtime.events ~= loader.events @@ -331,6 +336,44 @@ local function facade(loader, modId, native) end return valid end + local function mutate(operation) + if not check() then + return nil, "script session changed" + end + token.mutating = true + local called, value, reason = engine(operation) + if + not token.done + and current(loader) == game + and game.save == token.save + and game.data == token.data + and game.stack == token.stack + and ow.player == token.player + then + if + called + and value + and ow.map ~= token.map + and ow.map + and ow.map.id == token.mapId + and ow.runner ~= runner + then + token.original = ow.runner + ow.runner = runner + end + if called and value and ow.map and ow.map.id == token.mapId then + token.map = ow.map + end + end + token.mutating = nil + if not check() then + return nil, "script session changed" + end + if not called then + return nil, message(value) + end + return value, reason + end local discover local function snapshot() local before = {} @@ -514,7 +557,15 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if Gifts.ARITY[verb] then + if WorldEdits.ARITY[verb] then + local value, reason = edit(row, mutate) + if not check() then + return + end + if not value then + error(reason, 0) + end + elseif Gifts.ARITY[verb] then if nativeGift(ctx, row) == math.huge then return end diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index 6ca6d6c33..528c5bfe7 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -2,6 +2,7 @@ local Program = {} local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local WorldEdits = require("src.mods.ScriptWorldEdits") Program.MAX_ROWS, Program.MAX_STEPS, Program.SLICE = 1024, 65536, 128 local ARITY = { set_flag = 2, @@ -24,9 +25,18 @@ end for verb, arity in pairs(Gifts.ARITY) do ARITY[verb] = arity end +for verb, arity in pairs(WorldEdits.ARITY) do + ARITY[verb] = arity +end local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) - return type(verb) == "string" and (ARITY[aliases[verb] or verb] ~= nil) + if type(verb) ~= "string" then + return false + end + if WorldEdits.ARITY[verb] and require("src.core.GameVersion").generation() > 3 then + return false + end + return ARITY[aliases[verb] or verb] ~= nil end local function integer(n, lo, hi) return type(n) == "number" and n == n and n % 1 == 0 and n >= lo and n <= hi @@ -104,12 +114,19 @@ function Program.compile(raw, context) if not State.ARITY[verb] and not Gifts.ARITY[verb] + and not WorldEdits.ARITY[verb] and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if Gifts.ARITY[verb] then + if WorldEdits.ARITY[verb] then + local value, reason = WorldEdits.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif Gifts.ARITY[verb] then local value, reason = Gifts.syntax(verb, source) if not value then return fail(i, reason) diff --git a/src/mods/ScriptWorldEdits.lua b/src/mods/ScriptWorldEdits.lua new file mode 100644 index 000000000..193240e70 --- /dev/null +++ b/src/mods/ScriptWorldEdits.lua @@ -0,0 +1,85 @@ +-- Pure plans plus guarded native object/block effects inside an owned script. +local E = require("src.mods.Api3WorldEdits") +local Version = require("src.core.GameVersion") +local W = { ARITY = { show_object = 3, hide_object = 3, replace_block = 4 } } +local function id(value) + return type(value) == "string" and #value > 0 and #value <= 256 and not value:find("%c") +end +local function integer(value) + return type(value) == "number" and value == value and value >= 0 and value < math.huge and value % 1 == 0 +end +function W.syntax(verb, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable world edits unavailable for this generation" + end + if #row ~= W.ARITY[verb] then + return nil, "invalid command arity" + end + if verb == "replace_block" then + if not integer(row[2]) or not integer(row[3]) or not integer(row[4]) then + return nil, "invalid script block" + end + elseif not id(row[2]) or not (id(row[3]) or integer(row[3])) then + return nil, "invalid script object" + end + return { row[1], row[2], row[3], row[4] } +end +function W.prepare(game, world, owner, rows) + local needed = false + for _, row in ipairs(rows) do + needed = needed or W.ARITY[row[1]] ~= nil + end + if not needed then + return function() end + end + local gen = Version.generation() + local api = require("src.world.WorldAPI").new(game, owner) + local function plan(row) + if row[1] == "replace_block" then + return E.plan(game, api, gen, require, "replaceBlock", row[2], row[3], row[4]) + end + local mapId = row[2] + local maps = game.data.maps or {} + if gen == 3 and not maps[mapId] then + local prefix = Version.get() == "emerald" and "EM_" or "FR_" + if maps[prefix .. mapId] then + mapId = prefix .. mapId + end + end + local def = maps[mapId] + local found = false + for _, object in ipairs(def and def.objects or {}) do + if + object.name == row[3] + or object.id == row[3] + or object.index == row[3] + or object.localId == row[3] + then + found = true + break + end + end + if not found then + return nil, "native object unavailable: " .. tostring(row[3]) + end + return E.plan(game, api, gen, require, "toggleObject", mapId, row[3], row[1] == "show_object") + end + for i, row in ipairs(rows) do + if W.ARITY[row[1]] then + local operation, reason = plan(row) + if not operation then + return nil, "row " .. i .. ": " .. reason + end + end + end + return function(row, mutate) + local operation, reason = plan(row) + if not operation then + return nil, reason + end + return mutate(function() + return E.run(game, operation) + end) + end +end +return W diff --git a/tests/api3_contract/mods/script_world/main.lua b/tests/api3_contract/mods/script_world/main.lua new file mode 100644 index 000000000..56fd85ea7 --- /dev/null +++ b/tests/api3_contract/mods/script_world/main.lua @@ -0,0 +1,26 @@ +local mod = ... +local world = mod.world +local done, success = 0 +local events = {} +mod.events:on("world.object_toggled", function(ev) + events[#events + 1] = { kind = "object", mapId = ev.mapId, ref = ev.objName, visible = ev.visible } +end) +mod.events:on("world.block_replaced", function(ev) + events[#events + 1] = { kind = "block", mapId = ev.mapId, x = ev.bx, y = ev.by, value = ev.block } +end) +mod.exports.queue = function(rows) + return world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.result = function() + return { done = done, success = success, events = events } +end +mod.exports.get = function(name) + return world:getFlag(name) +end +mod.exports.read = function(...) + return world:activeBlockAt(...) +end diff --git a/tests/api3_contract/mods/script_world/manifest.json b/tests/api3_contract/mods/script_world/manifest.json new file mode 100644 index 000000000..38990279c --- /dev/null +++ b/tests/api3_contract/mods/script_world/manifest.json @@ -0,0 +1 @@ +{"id":"script_world","name":"Portable world edits","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/script_world.lua b/tests/api3_contract/script_world.lua new file mode 100644 index 000000000..e52511375 --- /dev/null +++ b/tests/api3_contract/script_world.lua @@ -0,0 +1,66 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_world%.lua$") + local f = dofile(codeRoot .. "/world_edits.lua").fixture(root, backend, version, "script_world") + f.loader.game = f.game + f.ow.runner.update = function() end + if f.field then + function f.field.lock(token) + f.field._locks[token] = true + end + function f.field.unlock(token) + f.field._locks[token] = nil + end + end + if f.gen == 1 then + local native = f.ow.setMap + function f.ow:setMap(...) + local old = self.map + native(self, ...) + self.map = require("src.world.Map").new(old.def, old.tileset) + self.map.renderer = old.renderer + self.runner = require("src.script.ScriptRunner").new(f.game, self) + end + end + function f:tick() + if backend == "gen1" then + self.loader.hooks:call("core.update", function() end, self.game, 1 / 60) + else + self.loader.hooks:call("input.step", function() + self.ow.runner:update() + end, self.game, 1 / 60) + end + end + function f:drain() + for _ = 1, 100 do + if self.exports.result().done > 0 then + return + end + self:tick() + end + error("world edit script did not finish") + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "hide_object", f.home, "NPC" }, + { "show_object", f.home, "NPC" }, + { "replace_block", 0, 0, 8 }, + { "wait", 1 }, + { "set_flag", "mod:after" }, + })) + f:drain() + assert(f.exports.result().success == true and f.exports.get("mod:after") == true) + assert(f.visible.NPC == true and f.exports.read(f.home, 0, 0) == 8) + local report = + { events = f.exports.result().events, visible = f.visible.NPC, block = f.exports.read(f.home, 0, 0) } + f = M.fixture(root, backend, version) + local value, why = f.exports.queue({ { "hide_object", f.home, "NPC" }, { "replace_block", 99, 0, 8 } }) + assert(value == nil and type(why) == "string" and f.visible.NPC and #f.exports.result().events == 0) + report.preflight = true + 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 0b9872292..61ab12874 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -19,11 +19,14 @@ assert( or mode == "script_flags" or mode == "script_state" or mode == "script_gifts" + or mode == "script_world" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_gifts" then +if mode == "script_world" then + dofile(codeRoot .. "/script_world.lua").run(root, backend, version) +elseif mode == "script_gifts" then dofile(codeRoot .. "/script_gifts.lua").run(root, backend, version) elseif mode == "script_state" then dofile(codeRoot .. "/script_state.lua").run(root, backend, version) diff --git a/tests/api3_contract/world_edits.lua b/tests/api3_contract/world_edits.lua index 7d77c555b..8f7527a30 100644 --- a/tests/api3_contract/world_edits.lua +++ b/tests/api3_contract/world_edits.lua @@ -1,9 +1,9 @@ -- 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) +function M.fixture(root, backend, version, modId) 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 f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, modId or "edits") local Runtime = require("src.mods.Runtime") local ow, game = f.ow, f.game local def = game.data.maps[f.home] diff --git a/tests/engine/portable_script_world.lua b/tests/engine/portable_script_world.lua new file mode 100644 index 000000000..0faea304c --- /dev/null +++ b/tests/engine/portable_script_world.lua @@ -0,0 +1,117 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_world") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +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 world script contract: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "hide_object", "HOME", "NPC" }, { "show_object", "HOME", "MISSING" } }, + { { "hide_object", "HOME", "NPC" }, { "replace_block", 99, 0, 8 } }, + { { "replace_block", 0, 0, 9 } }, + { { "replace_block", -1, 0, 8 } }, + { { "replace_block", 0, 0, 256 } }, + { { "show_object", "HOME", false } }, + { { "show_object", "MISSING", "NPC" } }, + { { "hide_object", "HOME", "NPC", true } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid list refused before prefix") + T.eq(f.visible.NPC, true, "object unchanged after refusal") + T.eq(f.rebuilds + f.reloads + f.syncs, 0, "no native effects on refusal") +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, target in ipairs({ "object", "block" }) do + local f = fixture(version) + local row = target == "object" and { "hide_object", f.home, "NPC" } or { "replace_block", 0, 0, 8 } + T.check(f.exports.queue({ { "wait", 1 }, row, { "set_flag", "mod:after" } }), "world script queued") + local overlay = {} + f.game.stack.states[2] = overlay + for _ = 1, 5 do + f:tick() + end + T.eq(f.exports.result().done, 0, "foreign overlay pauses native edit") + T.eq(#f.exports.result().events, 0, "paused edit emits nothing") + f.game.stack.states[2] = nil + f:drain() + T.eq(f.exports.result().success, true, "edit resumes after overlay") + T.eq(f.exports.get("mod:after"), true, "script continues after native edit") + T.eq(#f.exports.result().events, 1, "single native event") + end +end +for _, field in ipairs({ "objectToggles", "flags" }) do + local f = fixture() + T.check( + f.exports.queue({ { "wait", 1 }, { "hide_object", f.home, "NPC" } }), + "deferred object edit admitted" + ) + f.game.save[field] = false + f:drain() + T.eq(f.exports.result().success, false, "live malformed store refuses edit") + T.eq(f.visible.NPC, true, "malformed live store has no write") +end +do + local f = fixture() + T.check(f.exports.queue({ { "wait", 1 }, { "replace_block", 0, 0, 8 } }), "deferred block edit admitted") + f.ow.map.renderer = nil + f:drain() + T.eq(f.exports.result().success, false, "live missing renderer refuses edit") + T.eq(f.rebuilds, 0, "no partial replacement") +end +for _, change in ipairs({ "rollback", "save", "map", "runner" }) do + local f = fixture() + local original = f.ow.runner + T.check(f.exports.queue({ { "wait", 1 }, { "hide_object", f.home, "NPC" } }), "owned edit admitted") + local replacement + if change == "rollback" then + f.loader:_rollback("script_world") + elseif change == "save" then + f.game.save = {} + elseif change == "map" then + f.ow.map = { id = f.home } + else + replacement = { update = function() end } + f.ow.runner = replacement + end + f:tick() + T.eq(f.exports.result().done, 1, "changed context cancels once") + T.eq(f.exports.result().success, false, "changed context fails") + T.eq(f.visible.NPC, true, "cancelled edit cannot run") + T.eq(f.ow.runner, replacement or original, "foreign or original runner preserved") +end +do + local f = fixture() + local original = f.ow.runner + f.loader.events:on("world.object_toggled", function() + f.loader:_rollback("script_world") + end, 0, "probe") + T.check( + f.exports.queue({ { "hide_object", f.home, "NPC" }, { "set_flag", "mod:after" } }), + "native event retires owner" + ) + T.eq(f.exports.result().done, 1, "event cancellation completes once") + T.eq(f.exports.result().success, false, "event cancellation reported") + T.check(f.ow.runner ~= original and not f.ow.runner:isRunning(), "reload's new idle runner retained") + T.eq(f.exports.get("mod:after"), false, "retired owner cannot execute following flag") +end +do + local f = fixture() + local old = f.ow.runner + T.check( + f.exports.queue({ { "hide_object", f.home, "NPC" }, { "wait", 2 }, { "set_flag", "mod:after" } }), + "native reload continuation admitted" + ) + f:drain() + T.eq(f.exports.result().success, true, "continuation survives native map and runner replacement") + T.check(f.ow.runner ~= old and not f.ow.runner:isRunning(), "destination idle runner restored") +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("Portable script world edits") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index fe0fec4e6..f407c9168 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", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 4632d3a75be10e7af8161c715de0647b31dc1437 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:13:57 +0200 Subject: [PATCH 23/42] Add owned portable movement and facing scripts --- docs/api3-portability.md | 2 +- docs/portable-world-scripts.md | 26 +++ src/mods/PortableScripts.lua | 171 +++++++++++++++- src/mods/ScriptMovement.lua | 178 +++++++++++++++++ src/mods/ScriptProgram.lua | 17 +- .../api3_contract/mods/script_motion/main.lua | 15 ++ .../mods/script_motion/manifest.json | 1 + tests/api3_contract/npcs.lua | 4 +- tests/api3_contract/script_motion.lua | 108 ++++++++++ tests/api3_contract/worker.lua | 5 +- tests/engine/portable_script_movement.lua | 189 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 12 files changed, 708 insertions(+), 10 deletions(-) create mode 100644 src/mods/ScriptMovement.lua create mode 100644 tests/api3_contract/mods/script_motion/main.lua create mode 100644 tests/api3_contract/mods/script_motion/manifest.json create mode 100644 tests/api3_contract/script_motion.lua create mode 100644 tests/engine/portable_script_movement.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 32258f6ae..312fea02b 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -135,7 +135,7 @@ 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 and eight NPC cases extend this to 96 comparisons -and 208 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +and 224 isolated loader runs (including world-action, flag and portable-script action cases). 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 diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index d245a750f..f98982763 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -300,3 +300,29 @@ restores the new idle runner when finished. Foreign overlays pause the program; unloading, changed saves/maps and invalid live stores cancel it. Edits already committed before cancellation are retained, and public world events follow the native edit behavior. + +## Movement and facing + +On Gen1–3, API 3 accepts `move_player(dir, tiles?)`, +`move_npc(ref, dir, tiles?)`, `move_npc_to(ref, x, y)`, +`place_npc(ref, x, y, facing?)`, `face_object(ref, dir, {hold=frames}?)`, +`face_player_dir(dir)`, `face(dir)`, `face_npc()` and `face_player()`. +`face` turns the talking NPC; `face_npc` turns the player toward that NPC, +and `face_player` turns that NPC toward the player. The last three require +an existing talking-NPC context. Object references resolve existing native +actors on the active map. Counts are 0–1024 cells (default 1). + +All target references and argument shapes are checked before any command +runs. Paths are computed against the live walkable map at dispatch, using +a bounded search; an unavailable path fails the admitted program. Steps +use the native movement queue and walker updates. Each completion is tied +to its exact run, step and actor. Movement only permits its own expected +player position changes. Callbacks are one-shot, and continuation waits +for a foreground logic frame, so foreign overlays pause later effects. + +Cancellation removes only the run's exact queued entries and leaves foreign +tracks intact. Invalid sessions/actors, refused native steps and a stalled +step (3,600 foreground logic frames) terminate the script. A native step +which crosses into a different map ends this run; use explicit `warp` for +portable cross-map scripts when supported. These limits are intentionally +reported rather than silently claiming arbitrary native movement parity. diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index fa78194bd..eee0a639a 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -5,6 +5,7 @@ local Runtime = require("src.mods.Runtime") local Flags = require("src.mods.Api3Flags") local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local Movement = require("src.mods.ScriptMovement") local WorldEdits = require("src.mods.ScriptWorldEdits") local GameVersion = require("src.core.GameVersion") local Scripts = {} @@ -246,6 +247,10 @@ local function facade(loader, modId, native) if not edit then return nil, editReason end + local movement, moveReason = Movement.prepare(ow, program) + if not movement then + return nil, moveReason + end local Runner = require("src.script.ScriptRunner") local runner = Runner.new(game, ow) local token = { @@ -285,9 +290,9 @@ local function facade(loader, modId, native) or not token.mutating and 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.player.cellX ~= token.x or ow.player.cellY ~= token.y) and not (token.move and token.move.position()) or not token.mutating and ow.runner ~= runner + or token.move and not token.move.live() or GameVersion.get() ~= token.version or Runtime.hooks ~= loader.hooks or Runtime.events ~= loader.events @@ -304,6 +309,9 @@ local function facade(loader, modId, native) return end token.done = true + if token.move then + token.move.cancel() + end 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. @@ -534,6 +542,161 @@ local function facade(loader, modId, native) game.stack:push(menu) runner:yield() end + local function move(ctx, row) + local actor = movement.target(row) + if not actor then + error("native movement target unavailable", 0) + end + local verb, dirs = row[1], {} + if verb == "move_player" or verb == "move_npc" then + local at = verb == "move_player" and 2 or 3 + for i = 1, row[at + 1] or 1 do + dirs[i] = row[at] + end + elseif verb == "move_npc_to" then + local reason + dirs, reason = Movement.path(ow, actor, row[3], row[4]) + if not dirs then + error(reason, 0) + end + elseif verb == "place_npc" then + if actor.def and actor.def.index then + require("src.script.Commands").place_npc(ctx, actor.def.index, row[3], row[4], row[5]) + return + end + actor.cellX, actor.cellY, actor.px, actor.py = row[3], row[4], row[3] * 16, row[4] * 16 + actor.moving, actor.marching, actor.progress = false, false, 0 + actor.targetX, actor.targetY = nil, nil + if row[5] then + actor.facing = row[5] + end + return + else + local dir = verb == "face_object" and row[3] or row[2] + if verb == "face_npc" or verb == "face_player" then + local other = verb == "face_npc" and movement.npc or ow.player + local dx, dy = other.cellX - actor.cellX, other.cellY - actor.cellY + dir = math.abs(dx) > math.abs(dy) and (dx > 0 and "right" or "left") or (dy > 0 and "down" or "up") + end + if actor.scriptFace then + actor:scriptFace(dir) + else + actor.facing = dir + end + if verb == "face_object" and row[4] and row[4].hold then + actor.timer = row[4].hold + end + return + end + for _, dir in ipairs(dirs) do + if not check() then + return + end + if actor ~= ow.player and movement.target(row) ~= actor then + error("movement target changed", 0) + end + local step, completed, accepted, fired, pending = token.step, false, nil, false, false + local sx, sy = actor.cellX, actor.cellY + local delta = Movement.directions[dir] + local tx, ty = sx + delta[1], sy + delta[2] + local entries, before = {}, {} + for _, entry in ipairs(ow.scriptMoves or {}) do + before[entry] = true + end + local lease = {} + function lease.live() + return movement.target(row) == actor + and (actor.cellX == sx and actor.cellY == sy or actor.cellX == tx and actor.cellY == ty) + end + function lease.position() + if actor ~= ow.player then + return false + end + local x, y = actor.cellX, actor.cellY + return (x == sx and y == sy or x == tx and y == ty) + end + function lease.cancel() + local removed = false + for i = #(ow.scriptMoves or {}), 1, -1 do + if entries[ow.scriptMoves[i]] then + table.remove(ow.scriptMoves, i) + removed = true + end + end + local foreign = false + for _, entry in ipairs(ow.scriptMoves or {}) do + if entry.entity == actor then + foreign = true + end + end + if removed and not foreign then + actor.moving, actor.marching = false, false + actor.targetX, actor.targetY = nil, nil + actor.px, actor.py = actor.cellX * 16, actor.cellY * 16 + end + if token.move == lease then + token.move = nil + end + end + local function done() + if fired then + return + end + if accepted == nil then + pending = true + return + end + fired = true + if not accepted or token.step ~= step or not check() then + return + end + if actor.cellX ~= tx or actor.cellY ~= ty then + token.finish(false, "native movement did not arrive") + return + end + completed = true + if actor == ow.player then + token.x, token.y = tx, ty + end + lease.cancel() + -- Poll completion on the next foreground logic frame. A callback + -- beneath a foreign overlay cannot resume later script effects. + end + token.move = lease + local called, value, reason = engine(ow.scriptMove, ow, actor, dir, 1, done) + for _, entry in ipairs(ow.scriptMoves or {}) do + if not before[entry] and entry.entity == actor then + entries[entry] = true + end + end + if token.done then + lease.cancel() + return + end + accepted = called and value ~= false and not (value == nil and reason ~= nil) + if not accepted then + lease.cancel() + error(called and (reason or "native movement refused") or message(value), 0) + end + if pending then + done() + end + if not check() then + return + end + if not completed then + local remaining = 3600 + runner.waitingCheck = function() + remaining = remaining - 1 + if remaining <= 0 then + token.finish(false, "native movement timed out") + end + return completed + end + runner:yield() + end + end + end function runner:exec(compiled, ctx) local pc, slice = 1, 0 while pc <= #compiled do @@ -557,7 +720,9 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if WorldEdits.ARITY[verb] then + if Movement.ARITY[verb] then + move(ctx, row) + elseif WorldEdits.ARITY[verb] then local value, reason = edit(row, mutate) if not check() then return diff --git a/src/mods/ScriptMovement.lua b/src/mods/ScriptMovement.lua new file mode 100644 index 000000000..792f39b94 --- /dev/null +++ b/src/mods/ScriptMovement.lua @@ -0,0 +1,178 @@ +-- Closed movement vocabulary over this host's native actor/track services. +local Version = require("src.core.GameVersion") +local unpack = table.unpack or unpack +local M = { + ARITY = { + move_player = 3, + move_npc = 4, + move_npc_to = 4, + place_npc = 5, + face = 2, + face_object = 4, + face_npc = 1, + face_player = 1, + face_player_dir = 2, + }, +} +local directions = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } +local function integer(v, max) + return type(v) == "number" and v == v and v >= 0 and v <= max and v % 1 == 0 +end +local function ref(v) + return type(v) == "string" and #v > 0 and #v <= 256 and not v:find("%c") or integer(v, 1000000) +end +function M.syntax(verb, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable movement unavailable for this generation" + end + local n = #row + local minimum = ( + verb == "move_player" and 2 + or verb == "move_npc" and 3 + or verb == "place_npc" and 4 + or verb == "face_object" and 3 + ) or M.ARITY[verb] + if n < minimum or n > M.ARITY[verb] then + return nil, "invalid command arity" + end + if verb == "move_player" or verb == "move_npc" then + local at = verb == "move_player" and 2 or 3 + if + not directions[row[at]] + or row[at + 1] ~= nil and not integer(row[at + 1], 1024) + or at == 3 and not ref(row[2]) + then + return nil, "invalid script movement" + end + elseif verb == "place_npc" or verb == "move_npc_to" then + if + not ref(row[2]) + or not integer(row[3], 1000000) + or not integer(row[4], 1000000) + or row[5] ~= nil and not directions[row[5]] + then + return nil, "invalid NPC position" + end + elseif verb == "face_object" then + if not ref(row[2]) or not directions[row[3]] then + return nil, "invalid object facing" + end + elseif verb == "face" or verb == "face_player_dir" then + if not directions[row[2]] then + return nil, "invalid facing" + end + end + local out = { row[1], row[2], row[3], row[4], row[5] } + if verb == "face_object" and row[4] ~= nil then + if type(row[4]) ~= "table" or getmetatable(row[4]) ~= nil then + return nil, "invalid facing options" + end + out[4] = {} + for k, v in pairs(row[4]) do + if k ~= "hold" or not integer(v, 36000) then + return nil, "invalid facing options" + end + out[4][k] = v + end + end + return out +end +function M.actor(world, reference) + if reference == "player" then + return world.player + end + for _, actor in ipairs(world.npcs or {}) do + local def = actor.def or {} + if + actor.id == reference + or def.name == reference + or def.id == reference + or def.index == reference + or def.localId == reference + then + return actor + end + end +end +function M.prepare(world, rows) + local npc = world.talkNpc or world.runner and world.runner.ctx and world.runner.ctx.npc + local function target(row) + local verb = row[1] + if verb == "move_player" or verb == "face_player_dir" or verb == "face_npc" then + return world.player + end + if verb == "face" or verb == "face_player" then + return npc + end + return M.actor(world, row[2]) + end + for i, row in ipairs(rows) do + if M.ARITY[row[1]] then + local actor = target(row) + if not actor then + return nil, "row " .. i .. ": native movement target unavailable" + end + if (row[1] == "face_npc" or row[1] == "face_player") and not npc then + return nil, "row " .. i .. ": script NPC unavailable" + end + if not integer(actor.cellX, 1000000) or not integer(actor.cellY, 1000000) then + return nil, "invalid actor position" + end + if row[1]:match("^move_") and type(world.scriptMove) ~= "function" then + return nil, "native movement unavailable" + end + if + row[1] == "move_npc_to" + and (type(world.map.inBounds) ~= "function" or type(world.map.isWalkableCell) ~= "function") + then + return nil, "native path unavailable" + end + end + end + return { target = target, npc = npc } +end +function M.path(world, actor, tx, ty) + -- The native Gen1 command routes around occupied cells; later generations + -- use their walkable grid. Bound the search independently of map dimensions. + local map, gen = world.map, Version.generation() + local function key(x, y) + return x .. ":" .. y + end + local queue, first = { { actor.cellX, actor.cellY } }, 1 + local prev = { [key(actor.cellX, actor.cellY)] = false } + local dirs = { "up", "down", "left", "right" } + while queue[first] and first <= 16384 do + local x, y = unpack(queue[first]) + first = first + 1 + if x == tx and y == ty then + local path, at = {}, key(x, y) + while prev[at] do + table.insert(path, 1, prev[at][2]) + at = prev[at][1] + end + return path + end + for _, dir in ipairs(dirs) do + local d = directions[dir] + local nx, ny = x + d[1], y + d[2] + local k = key(nx, ny) + local goal = nx == tx and ny == ty + if + prev[k] == nil + and map:inBounds(nx, ny) + and (map:isWalkableCell(nx, ny) or gen == 1 and goal) + and ( + gen ~= 1 + or goal + or not require("src.world.Collision").occupied(world.entities or {}, nx, ny, actor) + ) + then + prev[k] = { key(x, y), dir } + queue[#queue + 1] = { nx, ny } + end + end + end + return nil, "native movement path unavailable" +end +M.directions = directions +return M diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index 528c5bfe7..62e4e7eab 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -2,6 +2,7 @@ local Program = {} local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local Movement = require("src.mods.ScriptMovement") local WorldEdits = require("src.mods.ScriptWorldEdits") Program.MAX_ROWS, Program.MAX_STEPS, Program.SLICE = 1024, 65536, 128 local ARITY = { @@ -28,12 +29,17 @@ end for verb, arity in pairs(WorldEdits.ARITY) do ARITY[verb] = arity end +for verb, arity in pairs(Movement.ARITY) do + ARITY[verb] = arity +end local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) if type(verb) ~= "string" then return false end - if WorldEdits.ARITY[verb] and require("src.core.GameVersion").generation() > 3 then + if + (WorldEdits.ARITY[verb] or Movement.ARITY[verb]) and require("src.core.GameVersion").generation() > 3 + then return false end return ARITY[aliases[verb] or verb] ~= nil @@ -115,12 +121,19 @@ function Program.compile(raw, context) not State.ARITY[verb] and not Gifts.ARITY[verb] and not WorldEdits.ARITY[verb] + and not Movement.ARITY[verb] and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if WorldEdits.ARITY[verb] then + if Movement.ARITY[verb] then + local value, reason = Movement.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif WorldEdits.ARITY[verb] then local value, reason = WorldEdits.syntax(verb, source, context) if not value then return fail(i, reason) diff --git a/tests/api3_contract/mods/script_motion/main.lua b/tests/api3_contract/mods/script_motion/main.lua new file mode 100644 index 000000000..be627b337 --- /dev/null +++ b/tests/api3_contract/mods/script_motion/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.result = function() + return { done = done, success = success } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_motion/manifest.json b/tests/api3_contract/mods/script_motion/manifest.json new file mode 100644 index 000000000..f6452d302 --- /dev/null +++ b/tests/api3_contract/mods/script_motion/manifest.json @@ -0,0 +1 @@ +{"id":"script_motion","name":"Portable motion","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 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. diff --git a/tests/api3_contract/script_motion.lua b/tests/api3_contract/script_motion.lua new file mode 100644 index 000000000..ce3ca68e3 --- /dev/null +++ b/tests/api3_contract/script_motion.lua @@ -0,0 +1,108 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_motion%.lua$") + local f = dofile(codeRoot .. "/npcs.lua").fixture(root, backend, version, "script_motion") + f.loader.game = f.game + f.ow.runner.update = function() end + function f.ow:npcByIndex(id) + return id == 1 and f.imported + end + function f.ow:objectEntity(id) + return id == 0 and f.player or id == 1 and f.imported + end + if f.field then + function f.field.lock(token) + f.field._locks[token] = true + end + function f.field.unlock(token) + f.field._locks[token] = nil + end + end + if backend == "gen1" and f.gen == 2 then + function f.ow:beginMovement(id, bytes, callback) + local dir = ({ [0x0C] = "down", [0x0D] = "up", [0x0E] = "left", [0x0F] = "right" })[bytes[1]] + return f:begin(id == 0 and f.player or f.imported, "move", assert(dir), #bytes - 1, callback) + end + function f.ow:moveObject(id, x, y) + local actor = assert(self:objectEntity(id)) + actor.cellX, actor.cellY = x, y + end + elseif backend == "gen1" and f.gen == 3 then + local objects = require("src.core.game3.objects") + objects._defs = { f.imported.def } + objects.find = function(id) + return id == 0xFF and f.player or f.byId[id] + end + objects.setObjectXY = function(id, x, y) + local actor = assert(objects.find(id)) + actor.cellX, actor.cellY = x, y + end + objects.applyMovement = function(id, bytes, callback) + local dir = ({ [0x10] = "down", [0x11] = "up", [0x12] = "left", [0x13] = "right" })[bytes[1]] + return f:begin(assert(objects.find(id)), "move", assert(dir), #bytes - 1, callback) + end + local collision = require("src.core.game3.collision") + collision.inBounds = function(x, y) + return x >= 0 and y >= 0 and x < 8 and y < 8 + end + collision.isWalkable = function() + return true + end + end + function f:tick() + local hook = backend == "gen1" and "core.update" or "input.step" + self.loader.hooks:call(hook, function() + if backend == "gen2" then + self.ow.runner:update() + end + end, self.game, 1 / 60) + end + function f:drain() + for _ = 1, 100 do + if self.exports.result().done > 0 then + return + end + if self.pending and self.pending.actor.moving then + self:complete() + end + self:tick() + end + error("movement script did not finish") + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "face_player_dir", "up" }, + { "move_player", "left", 2 }, + { "place_npc", 1, 2, 2, "left" }, + { "move_npc", 1, "right", 2 }, + { "move_npc_to", 1, 4, 4 }, + { "face_object", 1, "up" }, + { "set_flag", "mod:after" }, + })) + f:drain() + assert(f.exports.result().success and f.exports.get("mod:after")) + assert( + f.player.cellX == 5 + and f.player.cellY == 7 + and f.imported.cellX == 4 + and f.imported.cellY == 4 + and f.imported.facing == "up" + ) + local report = { + player = { f.player.cellX, f.player.cellY }, + npc = { f.imported.cellX, f.imported.cellY, f.imported.facing }, + complete = true, + } + f.restoreRenderer() + f = M.fixture(root, backend, version) + local ok, why = f.exports.queue({ { "face_player_dir", "up" }, { "move_npc", 99, "left", 1 } }) + assert(ok == nil and type(why) == "string" and f.player.facing == "left") + report.preflight = true + 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 61ab12874..4fa181395 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -20,11 +20,14 @@ assert( or mode == "script_state" or mode == "script_gifts" or mode == "script_world" + or mode == "script_motion" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_world" then +if mode == "script_motion" then + dofile(codeRoot .. "/script_motion.lua").run(root, backend, version) +elseif mode == "script_world" then dofile(codeRoot .. "/script_world.lua").run(root, backend, version) elseif mode == "script_gifts" then dofile(codeRoot .. "/script_gifts.lua").run(root, backend, version) diff --git a/tests/engine/portable_script_movement.lua b/tests/engine/portable_script_movement.lua new file mode 100644 index 000000000..3cf4e8581 --- /dev/null +++ b/tests/engine/portable_script_movement.lua @@ -0,0 +1,189 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_motion") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +local function dispose(f) + f.restoreRenderer() +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 .. " motion contract: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "face_player_dir", "up" }, { "move_npc", 99, "down" } }, + { { "move_player", "north" } }, + { { "move_player", "down", -1 } }, + { { "move_player", "down", 1025 } }, + { { "move_npc_to", 1, 0.5, 0 } }, + { { "place_npc", 1, 0, 0, "north" } }, + { { "place_npc", 1, 0, math.huge } }, + { { "face_object", 1, "down", { hold = -1 } } }, + { { "face_object", 1, "down", { other = 1 } } }, + { { "face", "down" } }, + { { "face_player" } }, + { { "face_npc" } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid later command preflight") + T.eq(f.player.facing, "left", "invalid prefix does not turn player") + T.eq(#f.ow.scriptMoves, 0, "preflight creates no native track") + dispose(f) +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, kind in ipairs({ "player", "npc" }) do + local f = fixture(version) + local actor = kind == "player" and f.player or f.imported + local row = kind == "player" and { "move_player", "left", 2 } or { "move_npc", 1, "left", 2 } + local x = actor.cellX + T.check(f.exports.queue({ row, { "set_flag", "mod:after" } }), "two-step movement admitted") + local old = f.pending.callback + f:complete() + local overlay = {} + f.game.stack.states[2] = overlay + old() + old() + for _ = 1, 4 do + f:tick() + end + T.eq(actor.cellX, x - 1, "overlay pauses next owned step") + T.eq(f.exports.result().done, 0, "old callback cannot skip movement") + f.game.stack.states[2] = nil + f:drain() + T.eq(actor.cellX, x - 2, "two native steps completed once") + T.eq(f.exports.result().success, true, "native continuation completes") + T.eq(f.exports.get("mod:after"), true, "only finished walk resumes script") + dispose(f) + end +end +for _, action in ipairs({ "cancel", "rollback", "save", "map", "actor" }) do + local f = fixture() + local original = f.ow.runner + T.check( + f.exports.queue({ { "move_npc", 1, "left", 2 }, { "set_flag", "mod:after" } }), + "cancellable track admitted" + ) + local callback = f.pending.callback + local foreign = { entity = f.imported, dir = "up", remaining = 1, onDone = function() end } + f.ow.scriptMoves[#f.ow.scriptMoves + 1] = foreign + if action == "cancel" then + require("src.mods.PortableScripts").cancelAll(f.loader, "script_motion", "probe cancellation") + elseif action == "rollback" then + f.loader:_rollback("script_motion") + elseif action == "save" then + f.game.save = {} + elseif action == "map" then + f.ow.map = { id = f.home } + else + f.ow.npcs = {} + end + f:tick() + T.eq(f.exports.result().done, 1, "retired movement completes once") + T.eq(f.exports.result().success, false, "retirement fails script") + T.eq(#f.ow.scriptMoves, 1, "only owned track removed") + T.eq(f.ow.scriptMoves[1], foreign, "foreign actor track retained") + callback() + callback() + T.eq(f.exports.result().done, 1, "retained callback cannot replay") + T.eq(f.ow.runner, original, "original runner restored") + dispose(f) +end +do + local f = fixture() + f.synchronous = true + T.check( + f.exports.queue({ { "move_player", "left", 2 }, { "set_flag", "mod:after" } }), + "synchronous native steps admitted" + ) + T.eq(f.exports.result().success, true, "synchronous callback completes without yielding forever") + T.eq(f.player.cellX, 5, "synchronous two-step position") + dispose(f) +end +for _, failure in ipairs({ "refuse", "throw", "unload" }) do + local f = fixture() + local native = f.ow.scriptMove + function f.ow:scriptMove(...) + native(self, ...) + if failure == "refuse" then + return false, "probe refusal" + elseif failure == "throw" then + error("probe native error") + else + f.loader:_rollback("script_motion") + end + end + T.check( + f.exports.queue({ { "move_player", "left", 2 }, { "set_flag", "mod:after" } }), + "native failure after admission" + ) + T.eq(f.exports.result().success, false, "native failure reported") + T.eq(#f.ow.scriptMoves, 0, "late native track cleaned") + T.eq(f.player.moving, false, "owned actor unlocked") + dispose(f) +end +do + local f = fixture() + f.ow.talkNpc = f.imported + T.check( + f.exports.queue({ + { "face", "left" }, + { "face_npc" }, + { "face_player" }, + { + "face_object", + 1, + "up", + { hold = 20 }, + }, + }), + "context facing admitted" + ) + T.eq(f.exports.result().success, true, "context facing completes") + T.eq(f.player.facing, "up", "player faces talking NPC") + T.eq(f.imported.facing, "up", "arbitrary object explicit direction") + T.eq(f.imported.timer, 20, "bounded facing hold carried") + dispose(f) +end +do + local f = fixture() + T.check(f.exports.queue({ { "move_player", "left" } }), "stalled native track admitted") + for _ = 1, 3601 do + f:tick() + end + T.eq(f.exports.result().success, false, "stalled track times out") + T.eq(#f.ow.scriptMoves, 0, "timeout removes owned track") + dispose(f) +end +-- Borrow the actual production movement queue and both walker updates. +local OW = require("src.world.OverworldController") +local Player = require("src.world.Player") +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + f.ow.scriptMove = OW.scriptMove + f.imported.frozen = true + f.player.turnTimer, f.player.progress = 0, 0 + T.check( + f.exports.queue({ { "move_player", "left", 2 }, { "move_npc", 1, "up", 2 }, { "set_flag", "mod:after" } }), + version .. " native queues admitted" + ) + for _ = 1, 100 do + OW.updateScriptMoves(f.ow) + Player.update(f.player) + f.imported:update(f.ow.map, f.ow.entities) + f:tick() + end + T.eq(f.player.cellX, 5, "actual player updater walks exactly two cells") + T.eq(f.imported.cellY, 4, "actual NPC updater walks exactly two cells") + T.eq(#f.ow.scriptMoves, 0, "actual native tracks released") + T.eq(f.exports.result().success, true, "actual native movement resumes script") + T.eq(f.exports.get("mod:after"), true, "native walk continuation runs once") + dispose(f) +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("Portable script movement") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index f407c9168..de26d3014 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", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 7e620268d09f78d8fc01664ba2329eb6e3936763 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:25:28 +0200 Subject: [PATCH 24/42] Add portable native travel with destination-aware preflight --- docs/api3-portability.md | 2 +- docs/portable-world-scripts.md | 27 ++ src/mods/Api3Warp.lua | 71 +++--- src/mods/PortableScripts.lua | 142 ++++++++++- src/mods/ScriptMovement.lua | 12 +- src/mods/ScriptProgram.lua | 16 +- src/mods/ScriptTravel.lua | 88 +++++++ src/mods/ScriptWorldEdits.lua | 4 +- .../api3_contract/mods/script_travel/main.lua | 15 ++ .../mods/script_travel/manifest.json | 1 + tests/api3_contract/npcs.lua | 1 + tests/api3_contract/script_motion.lua | 4 +- tests/api3_contract/script_travel.lua | 119 +++++++++ tests/api3_contract/worker.lua | 5 +- tests/engine/portable_script_travel.lua | 232 ++++++++++++++++++ tests/engine/portable_world_scripts.lua | 3 +- tools/api3-contract-check/check.py | 2 +- 17 files changed, 694 insertions(+), 50 deletions(-) create mode 100644 src/mods/ScriptTravel.lua create mode 100644 tests/api3_contract/mods/script_travel/main.lua create mode 100644 tests/api3_contract/mods/script_travel/manifest.json create mode 100644 tests/api3_contract/script_travel.lua create mode 100644 tests/engine/portable_script_travel.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 312fea02b..b849bf91c 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -135,7 +135,7 @@ 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 and eight NPC cases extend this to 96 comparisons -and 224 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +and 240 isolated loader runs (including world-action, flag and portable-script action cases). 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 diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index f98982763..dc0820dc4 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -326,3 +326,30 @@ step (3,600 foreground logic frames) terminate the script. A native step which crosses into a different map ends this run; use explicit `warp` for portable cross-map scripts when supported. These limits are intentionally reported rather than silently claiming arbitrary native movement parity. + +## Explicit travel + +Gen1–3 accept `warp(mapId, x, y, facing?)` and +`remember_outdoor(mapId, x, y)`. Destination names, 0-based cell coordinates, +dimensions and facing use the public API-3 warp planner. `remember_outdoor` +sets the native outdoor/escape destination without travelling. + +Preflight follows the linear row list's map context, just as the reference +engine does. Object/block and movement rows after a warp are checked against +the destination's definitions; path/block geometry requires the destination +tileset to be available. These plans do not load a live map or run callbacks. +All dispatches still validate their actual live targets. Branches and native +entry scripts can change runtime conditions, so successful preflight is not +a guarantee that an admitted asynchronous program will complete. + +A warp uses the native transition and arrival bookkeeping. Its continuation +binds to the destination map and idle runner only after the native arrival +callback; it never resumes beneath a foreign overlay. A busy destination +runner is preserved and the portable run fails. Talking-NPC context is +cleared at arrival. One-shot callbacks, session checks and a 3,600-frame +native-transition/foreground deadline prevent late script effects. + +The transition belongs to the engine: unloading or timing out a portable +script leaves the already admitted native warp free to finish and release +its own locks. It suppresses the script continuation, rather than undoing +committed native travel or removing the engine's transition screen. diff --git a/src/mods/Api3Warp.lua b/src/mods/Api3Warp.lua index 400465d87..c447cdec1 100644 --- a/src/mods/Api3Warp.lua +++ b/src/mods/Api3Warp.lua @@ -84,6 +84,44 @@ local function observe(loader) end, math.huge, "engine:api3_warp") end end +-- Pure destination validation shared by public warps and portable scripts. +function W.plan(game, ow, generation, mapId, x, y, facing) + 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 + return { + mapId = destination, + x = x, + y = y, + facing = facing or (generation ~= 1 and ow.player.facing) or "down", + def = def, + } +end function W.new(loader, owner, generation, current, native) local context = contexts[loader] if not context then @@ -117,34 +155,11 @@ function W.new(loader, owner, generation, current, native) 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" + local plan, reason = W.plan(game, ow, generation, mapId, x, y, facing) + if not plan then + return nil, reason end + local destination = plan.mapId if options ~= nil and (type(options) ~= "table" or getmetatable(options) ~= nil) then return nil, "invalid options" end @@ -170,7 +185,7 @@ function W.new(loader, owner, generation, current, native) end opts[key] = value end - facing = facing or (generation ~= 1 and ow.player.facing) or "down" + facing = plan.facing local hooks, events = Runtime.hooks, Runtime.events local token = { game = game, loader = loader, owner = owner, destination = destination, callback = opts.onDone } diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index eee0a639a..fad11a146 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -5,6 +5,7 @@ local Runtime = require("src.mods.Runtime") local Flags = require("src.mods.Api3Flags") local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local Travel = require("src.mods.ScriptTravel") local Movement = require("src.mods.ScriptMovement") local WorldEdits = require("src.mods.ScriptWorldEdits") local GameVersion = require("src.core.GameVersion") @@ -143,6 +144,8 @@ local function observe(loader) 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") + elseif token.travel then + token.travel.tick() end end return nextFn(game, dt) @@ -243,11 +246,15 @@ local function facade(loader, modId, native) if not gift then return nil, giftReason end - local edit, editReason = WorldEdits.prepare(game, ow, modId, program) + local travel, travelReason = Travel.prepare(game, ow, modId, program) + if not travel then + return nil, travelReason + end + local edit, editReason = WorldEdits.prepare(game, ow, modId, program, true) if not edit then return nil, editReason end - local movement, moveReason = Movement.prepare(ow, program) + local movement, moveReason = Movement.prepare(ow, program, true) if not movement then return nil, moveReason end @@ -287,18 +294,19 @@ local function facade(loader, modId, native) or game.stack.states ~= token.states or not contains(token.stack, ow) or game.overworld and game.overworld ~= ow - or not token.mutating and ow.map ~= token.map - or ow.map.id ~= token.mapId + or not token.travel and not token.mutating and ow.map ~= token.map + or not token.travel and ow.map.id ~= token.mapId or ow.player ~= token.player - or (ow.player.cellX ~= token.x or ow.player.cellY ~= token.y) and not (token.move and token.move.position()) - or not token.mutating and ow.runner ~= runner + or not token.travel and (ow.player.cellX ~= token.x or ow.player.cellY ~= token.y) and not (token.move and token.move.position()) + or not token.travel and not token.mutating and ow.runner ~= runner or token.move and not token.move.live() 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 + or not token.travel and ow.transitioning + or token.travel and not token.travel.valid() then return nil, "script session changed" end @@ -309,6 +317,9 @@ local function facade(loader, modId, native) return end token.done = true + if token.travel then + token.travel.retired = true + end if token.move then token.move.cancel() end @@ -413,7 +424,7 @@ local function facade(loader, modId, native) return state end discover = function(before, step) - if token.done or token.step ~= step then + if token.done or token.step ~= step or token.travel then return end for _, state in ipairs(game.stack.states) do @@ -542,6 +553,117 @@ local function facade(loader, modId, native) game.stack:push(menu) runner:yield() end + local function warp(ctx, row) + local plan, reason = travel(row) + if not plan then + error(reason, 0) + end + if row[1] == "remember_outdoor" then + token.save.lastOutdoor = { id = plan.mapId, x = plan.x, y = plan.y } + if GameVersion.generation() == 3 then + token.save.escapeWarp = { map = plan.mapId, warpId = 255, x = plan.x, y = plan.y } + elseif ow.rememberOutdoor then + ow:rememberOutdoor(plan.mapId, plan.x, plan.y) + else + ow.lastOutdoor = token.save.lastOutdoor + end + return + end + local origin, step = ow.map, token.step + local accepted, pending, fired, complete = nil, false, false, false + local lease = { ui = {} } + local remaining = 3600 + function lease.tick() + local top = token.stack:top() + if top == ow or lease.ui[top] then + remaining = remaining - 1 + if remaining <= 0 then + token.finish(false, "native warp timed out") + end + end + end + function lease.valid() + if lease.retired then + return false + end + if + ow.map == origin + and ow.player.cellX == token.x + and ow.player.cellY == token.y + and ow.runner == runner + then + return true + end + return ow.map and ow.map.id == plan.mapId and ow.player.cellX == plan.x and ow.player.cellY == plan.y + end + local function done() + if fired or lease.retired then + return + end + if accepted == nil then + pending = true + return + end + fired = true + if not accepted or token.step ~= step or not check() then + return + end + if + not ow.map + or ow.map.id ~= plan.mapId + or ow.player.cellX ~= plan.x + or ow.player.cellY ~= plan.y + or ow.transitioning + then + token.finish(false, "native warp did not arrive") + return + end + if ow.runner ~= runner then + if not ow.runner or ow.runner:isRunning() then + token.finish(false, "destination script is busy") + return + end + token.original = ow.runner + ow.runner = runner + end + token.map, token.mapId, token.x, token.y = ow.map, plan.mapId, plan.x, plan.y + ctx.mapId, ctx.npc = plan.mapId, nil + movement.forgetNpc() + movement.npc = nil + complete = true + end + token.travel = lease + local statesBefore = snapshot() + local called, value, why = + engine(ow.startWarpTo, ow, plan.mapId, plan.x, plan.y, plan.facing, done, { via = "warp" }) + for _, state in ipairs(game.stack.states) do + if not statesBefore[state] then + lease.ui[state] = true + end + end + accepted = called and value ~= false and not (value == nil and why ~= nil) + if not accepted then + lease.retired = true + token.travel = nil + error(called and (why or "native warp refused") or message(value), 0) + end + if pending then + done() + end + if not check() then + return + end + if not complete then + runner.waitingCheck = function() + return complete + end + runner:yield() + end + lease.retired = true + if token.travel == lease then + token.travel = nil + end + end local function move(ctx, row) local actor = movement.target(row) if not actor then @@ -720,7 +842,9 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if Movement.ARITY[verb] then + if Travel.ARITY[verb] then + warp(ctx, row) + elseif Movement.ARITY[verb] then move(ctx, row) elseif WorldEdits.ARITY[verb] then local value, reason = edit(row, mutate) diff --git a/src/mods/ScriptMovement.lua b/src/mods/ScriptMovement.lua index 792f39b94..a459f9ee7 100644 --- a/src/mods/ScriptMovement.lua +++ b/src/mods/ScriptMovement.lua @@ -94,7 +94,7 @@ function M.actor(world, reference) end end end -function M.prepare(world, rows) +function M.prepare(world, rows, prevalidated) local npc = world.talkNpc or world.runner and world.runner.ctx and world.runner.ctx.npc local function target(row) local verb = row[1] @@ -107,7 +107,7 @@ function M.prepare(world, rows) return M.actor(world, row[2]) end for i, row in ipairs(rows) do - if M.ARITY[row[1]] then + if not prevalidated and M.ARITY[row[1]] then local actor = target(row) if not actor then return nil, "row " .. i .. ": native movement target unavailable" @@ -129,7 +129,13 @@ function M.prepare(world, rows) end end end - return { target = target, npc = npc } + return { + target = target, + npc = npc, + forgetNpc = function() + npc = nil + end, + } end function M.path(world, actor, tx, ty) -- The native Gen1 command routes around occupied cells; later generations diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index 62e4e7eab..1ccf08cd2 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -2,6 +2,7 @@ local Program = {} local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local Travel = require("src.mods.ScriptTravel") local Movement = require("src.mods.ScriptMovement") local WorldEdits = require("src.mods.ScriptWorldEdits") Program.MAX_ROWS, Program.MAX_STEPS, Program.SLICE = 1024, 65536, 128 @@ -32,13 +33,17 @@ end for verb, arity in pairs(Movement.ARITY) do ARITY[verb] = arity end +for verb, arity in pairs(Travel.ARITY) do + ARITY[verb] = arity +end local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) if type(verb) ~= "string" then return false end if - (WorldEdits.ARITY[verb] or Movement.ARITY[verb]) and require("src.core.GameVersion").generation() > 3 + (WorldEdits.ARITY[verb] or Movement.ARITY[verb] or Travel.ARITY[verb]) + and require("src.core.GameVersion").generation() > 3 then return false end @@ -122,12 +127,19 @@ function Program.compile(raw, context) and not Gifts.ARITY[verb] and not WorldEdits.ARITY[verb] and not Movement.ARITY[verb] + and not Travel.ARITY[verb] and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if Movement.ARITY[verb] then + if Travel.ARITY[verb] then + local value, reason = Travel.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif Movement.ARITY[verb] then local value, reason = Movement.syntax(verb, source, context) if not value then return fail(i, reason) diff --git a/src/mods/ScriptTravel.lua b/src/mods/ScriptTravel.lua new file mode 100644 index 000000000..31f56630c --- /dev/null +++ b/src/mods/ScriptTravel.lua @@ -0,0 +1,88 @@ +-- Destination preflight, including later world rows against destination data. +local Warp = require("src.mods.Api3Warp") +local Movement = require("src.mods.ScriptMovement") +local Edits = require("src.mods.ScriptWorldEdits") +local Version = require("src.core.GameVersion") +local copy = require("src.mods.Merge").deepCopy +local T = { ARITY = { warp = 5, remember_outdoor = 4 } } +function T.syntax(verb, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable travel unavailable for this generation" + end + if #row < 4 or #row > T.ARITY[verb] then + return nil, "invalid command arity" + end + -- Destination names, coordinates, dimensions and facing are validated with + -- the same pure planner as the public API once a save/world is available. + return { row[1], row[2], row[3], row[4], row[5] } +end +local function future(game, world, plan, cache) + local tiles = game.data.tilesets and game.data.tilesets[plan.def.tileset] + local map = cache[plan.mapId] + if map then + elseif tiles then + map = require("src.world.Map").new(copy(plan.def), copy(tiles)) + else + map = { id = plan.mapId, def = plan.def } + end + map.renderer = { rebuild = function() end } + cache[plan.mapId] = map + local view = { + isOverworld = true, + map = map, + player = { cellX = plan.x, cellY = plan.y, facing = plan.facing }, + npcs = {}, + syncObjectVisibility = world.syncObjectVisibility, + scriptMove = world.scriptMove, + } + for _, def in ipairs(plan.def.objects or {}) do + view.npcs[#view.npcs + 1] = + { def = def, id = plan.mapId .. "_obj_" .. tostring(def.index), cellX = def.x, cellY = def.y } + end + local proxy = { + data = game.data, + save = game.save, + overworld = view, + stack = { + top = function() + return view + end, + states = { view }, + }, + } + return proxy, view +end +function T.prepare(game, world, owner, rows) + local gen = Version.generation() + local model, modelGame = world, game + local cache = {} + for i, row in ipairs(rows) do + local verb = row[1] + if T.ARITY[verb] then + local plan, why = Warp.plan(game, model, gen, row[2], row[3], row[4], row[5]) + if not plan then + return nil, "row " .. i .. ": " .. why + end + if verb == "warp" then + if type(world.startWarpTo) ~= "function" then + return nil, "native warp unavailable" + end + modelGame, model = future(game, world, plan, cache) + end + elseif Edits.ARITY[verb] then + local edit, why = Edits.prepare(modelGame, model, owner, { row }) + if not edit then + return nil, "row " .. i .. ": " .. why + end + elseif Movement.ARITY[verb] then + local movement, why = Movement.prepare(model, { row }) + if not movement then + return nil, "row " .. i .. ": " .. why + end + end + end + return function(row) + return Warp.plan(game, world, gen, row[2], row[3], row[4], row[5]) + end +end +return T diff --git a/src/mods/ScriptWorldEdits.lua b/src/mods/ScriptWorldEdits.lua index 193240e70..0c2c7ba9d 100644 --- a/src/mods/ScriptWorldEdits.lua +++ b/src/mods/ScriptWorldEdits.lua @@ -24,7 +24,7 @@ function W.syntax(verb, row, context) end return { row[1], row[2], row[3], row[4] } end -function W.prepare(game, world, owner, rows) +function W.prepare(game, world, owner, rows, prevalidated) local needed = false for _, row in ipairs(rows) do needed = needed or W.ARITY[row[1]] ~= nil @@ -65,7 +65,7 @@ function W.prepare(game, world, owner, rows) return E.plan(game, api, gen, require, "toggleObject", mapId, row[3], row[1] == "show_object") end for i, row in ipairs(rows) do - if W.ARITY[row[1]] then + if not prevalidated and W.ARITY[row[1]] then local operation, reason = plan(row) if not operation then return nil, "row " .. i .. ": " .. reason diff --git a/tests/api3_contract/mods/script_travel/main.lua b/tests/api3_contract/mods/script_travel/main.lua new file mode 100644 index 000000000..be627b337 --- /dev/null +++ b/tests/api3_contract/mods/script_travel/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.result = function() + return { done = done, success = success } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_travel/manifest.json b/tests/api3_contract/mods/script_travel/manifest.json new file mode 100644 index 000000000..f07cf280a --- /dev/null +++ b/tests/api3_contract/mods/script_travel/manifest.json @@ -0,0 +1 @@ +{"id":"script_travel","name":"Portable travel","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 index 1e3170249..888ad92e5 100644 --- a/tests/api3_contract/npcs.lua +++ b/tests/api3_contract/npcs.lua @@ -29,6 +29,7 @@ function M.fixture(root, backend, version, modId) blockCells = f.gen == 3 and 1 or 2, blockTiles = f.gen == 3 and 2 or 4, } + f.tiles = tiles for i = 1, 16 do tiles.blocks[2][i] = 0 end diff --git a/tests/api3_contract/script_motion.lua b/tests/api3_contract/script_motion.lua index ce3ca68e3..e4f215fc7 100644 --- a/tests/api3_contract/script_motion.lua +++ b/tests/api3_contract/script_motion.lua @@ -1,7 +1,7 @@ local M = {} -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, modId) local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_motion%.lua$") - local f = dofile(codeRoot .. "/npcs.lua").fixture(root, backend, version, "script_motion") + local f = dofile(codeRoot .. "/npcs.lua").fixture(root, backend, version, modId or "script_motion") f.loader.game = f.game f.ow.runner.update = function() end function f.ow:npcByIndex(id) diff --git a/tests/api3_contract/script_travel.lua b/tests/api3_contract/script_travel.lua new file mode 100644 index 000000000..4894ed053 --- /dev/null +++ b/tests/api3_contract/script_travel.lua @@ -0,0 +1,119 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_travel%.lua$") + local f = dofile(codeRoot .. "/script_motion.lua").fixture(root, backend, version, "script_travel") + f.game.data.tilesets = f.game.data.tilesets or {} + f.game.data.tilesets.FIX = f.tiles + for _, def in pairs(f.game.data.maps) do + if def.midLayout then + def.midLayout.width, def.midLayout.height = def.width, def.height + end + end + local dest = f.game.data.maps[f.dest] + dest.objects = { + { + index = 1, + localId = 1, + name = "IMPORTED", + sprite = "PROBE", + x = 4, + y = 4, + movement = "STAY", + range = "DOWN", + }, + } + local arrive = f.arrive + function f:arrive(id) + arrive(self, id) + local def = self.game.data.maps[id or self.dest] + if not def then + return + end + if backend == "gen1" and f.gen == 2 then + self.ow.map = require("src.world.gen2.Map").new(def, self.tiles) + elseif not (backend == "gen1" and f.gen == 3) then + self.ow.map = require("src.world.Map").new(def, self.tiles) + end + if backend == "gen1" and f.gen > 1 then + self.ow.runner = { + isRunning = function() + return false + end, + update = function() end, + } + else + self.ow.runner = require("src.script.ScriptRunner").new(self.game, self.ow) + end + self.imported.cellX, self.imported.cellY = 4, 4 + self.imported.def = def.objects[1] + if self.byId then + require("src.core.game3.objects")._defs = def.objects + end + end + function f:drain() + for _ = 1, 150 do + if self.exports.result().done > 0 then + return + end + if self.destination and self.ow.transitioning then + self:arrive() + self:completeWarp() + end + if self.pending and self.pending.actor.moving then + self:complete() + end + self:tick() + end + error("travel script did not finish") + end + -- npc fixture's complete belongs to movement; preserve the warp seam separately. + function f:completeWarp() + self.busy, self.ow.transitioning = false, false + if self.callback then + self.callback() + end + end + local dispatch = f.dispatch + function f:dispatch(...) + local movementComplete = self.complete + self.complete = self.completeWarp + local called, a, b = pcall(dispatch, self, ...) + self.complete = movementComplete + if not called then + error(a, 0) + end + return a, b + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local ok, why = f.exports.queue({ + { "remember_outdoor", f.home, 1, 1 }, + { "warp", f.dest, 2, 2, "right" }, + { "move_player", "left" }, + { "move_npc", 1, "down" }, + { "face_object", 1, "up" }, + { "set_flag", "mod:after" }, + }) + assert(ok, why) + assert(f.exports.result().done == 0) + f:drain() + assert(f.exports.result().success and f.exports.get("mod:after")) + assert(f.ow.map.id == f.dest and f.player.cellX == 1 and f.player.cellY == 2 and f.imported.cellY == 5) + local report = { + map = f.ow.map.id, + player = { f.player.cellX, f.player.cellY }, + npc = { f.imported.cellX, f.imported.cellY, f.imported.facing }, + complete = true, + } + f.restoreRenderer() + f = M.fixture(root, backend, version) + local value, reason = f.exports.queue({ { "face_player_dir", "up" }, { "warp", "UNKNOWN", 0, 0 } }) + assert(value == nil and type(reason) == "string" and f.player.facing == "left" and #f.trace == 0) + report.preflight = true + 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 4fa181395..d69c8e8dc 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -21,11 +21,14 @@ assert( or mode == "script_gifts" or mode == "script_world" or mode == "script_motion" + or mode == "script_travel" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_motion" then +if mode == "script_travel" then + dofile(codeRoot .. "/script_travel.lua").run(root, backend, version) +elseif mode == "script_motion" then dofile(codeRoot .. "/script_motion.lua").run(root, backend, version) elseif mode == "script_world" then dofile(codeRoot .. "/script_world.lua").run(root, backend, version) diff --git a/tests/engine/portable_script_travel.lua b/tests/engine/portable_script_travel.lua new file mode 100644 index 000000000..16397e435 --- /dev/null +++ b/tests/engine/portable_script_travel.lua @@ -0,0 +1,232 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_travel") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v) + return Contract.fixture("tests/api3_contract", "gen2", v or "red") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared travel: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "face_player_dir", "up" }, { "warp", "UNKNOWN", 0, 0 } }, + { { "warp", "DEST", 0.5, 0 } }, + { { "warp", "DEST", -1, 0 } }, + { { "warp", "DEST", 16, 0 } }, + { { "warp", "DEST", 0, 0, "north" } }, + { { "warp", "DEST", 0, 0, "down", {} } }, + { { "warp", "DEST", 0, 0 }, { "move_npc", 99, "down" } }, + { { "warp", "DEST", 0, 0 }, { "replace_block", 999, 0, 1 } }, + { { "warp", "DEST", 0, 0 }, { "face", "down" } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid destination/later row preflight") + T.eq(#f.trace, 0, "invalid suffix prevents native warp") + T.eq(f.player.facing, "left", "invalid list has no prefix effect") + f.restoreRenderer() +end +for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + local f = fixture(v) + T.check(f.exports.queue({ { "remember_outdoor", f.home, 1, 2 } }), "outdoor memory admitted") + T.same(f.game.save.lastOutdoor, { id = f.home, x = 1, y = 2 }, "canonical remembered destination") + if f.gen == 3 then + T.same(f.game.save.escapeWarp, { map = f.home, warpId = 255, x = 1, y = 2 }, "native Gen3 escape memory") + end + f.restoreRenderer() +end +for _, change in ipairs({ "cancel", "rollback", "save", "data", "stack", "player", "version" }) do + local f = fixture() + local original = f.ow.runner + T.check( + f.exports.queue({ { "warp", f.dest, 2, 2 }, { "set_flag", "mod:after" } }), + "cancellable warp admitted" + ) + local callback = f.callback + if change == "cancel" then + require("src.mods.PortableScripts").cancelAll(f.loader, "script_travel") + elseif change == "rollback" then + f.loader:_rollback("script_travel") + elseif change == "version" then + Version.set("blue") + elseif change == "player" then + f.ow.player = { cellX = 2, cellY = 2 } + else + f.game[change] = {} + end + local ok = pcall(function() + f:tick() + end) + T.check(ok, "changed context checked without crashing") + T.eq(f.exports.result().done, 1, "changed context completes once") + T.eq(f.exports.result().success, false, "changed warp fails script") + callback() + callback() + T.eq(f.exports.result().done, 1, "stale warp callback cannot replay") + T.eq(f.ow.runner, original, "source idle runner restored") + f.restoreRenderer() +end +do + local f = fixture() + T.check(f.exports.queue({ { "warp", f.dest, 2, 2 }, { "set_flag", "mod:after" } }), "warp before overlay") + local old = f.callback + f:arrive() + f:completeWarp() + local replacement = f.ow.runner + local overlay = {} + f.game.stack.states[2] = overlay + old() + old() + for _ = 1, 4 do + f:tick() + end + T.eq(f.exports.get("mod:after"), false, "arrival callback cannot run beneath foreign UI") + f.game.stack.states[2] = nil + f:drain() + T.eq(f.exports.result().success, true, "arrival resumes in foreground") + T.check(f.ow.runner ~= replacement and not f.ow.runner:isRunning(), "new destination idle runner restored") + f.restoreRenderer() +end +for _, failure in ipairs({ "throw", "refuse", "false_arrival", "wrong_map" }) do + local f = fixture() + if failure == "throw" or failure == "refuse" then + f.failure = failure + end + T.check( + f.exports.queue({ { "warp", f.dest, 2, 2 }, { "set_flag", "mod:after" } }), + "native warp admitted before result" + ) + if failure == "false_arrival" then + f:completeWarp() + elseif failure == "wrong_map" then + f:arrive(f.home) + f:completeWarp() + end + f:tick() + T.eq(f.exports.result().success, false, "native failure cannot complete script") + T.eq(f.exports.get("mod:after"), false, "native failure cannot burn later flag") + f.restoreRenderer() +end +do + local f = fixture() + f.synchronous = true + T.check( + f.exports.queue({ { "warp", f.dest, 2, 2 }, { "set_flag", "mod:after" } }), + "synchronous arrival admitted" + ) + T.eq(f.exports.result().success, true, "synchronous warp continuation does not hang") + f.restoreRenderer() +end +for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + local f = fixture(v) + function f.ow:startWarpTo(id, x, y, facing, callback) + self.player.cellX, self.player.cellY, self.player.facing = x, y, facing + callback() + end + T.check( + f.exports.queue({ { "warp", f.home, 1, 1 }, { "set_flag", "mod:after" } }), + "same-map reused runtime admitted" + ) + T.eq(f.exports.result().success, true, "same-map warp can retain native map identity") + T.eq(f.exports.get("mod:after"), true, "same-map native callback resumes once") + f.restoreRenderer() +end +do + local f = fixture() + T.check(f.exports.queue({ { "warp", f.dest, 2, 2 } }), "stalled warp admitted") + for _ = 1, 3601 do + f:tick() + end + T.eq(f.exports.result().success, false, "warp deadline expires") + f:arrive() + f:completeWarp() + T.eq(f.ow.transitioning, false, "engine can still finish timed-out warp") + T.eq(f.exports.result().done, 1, "timed-out script cannot resume") + f.restoreRenderer() +end +for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + local f = fixture(v) + f.tiles.blocks[8] = {} + local arrive = f.arrive + function f:arrive(id) + arrive(self, id) + self.ow.map.renderer = { rebuild = function() end } + end + function f.ow:replaceBlock(x, y, block) + self.map:setBlock(x, y, block) + self.map.renderer:rebuild() + end + T.check( + f.exports.queue({ { "warp", f.dest, 2, 2 }, { "replace_block", 0, 0, 7 }, { "set_flag", "mod:after" } }), + "future destination block preflight" + ) + f:drain() + T.eq(f.exports.result().success, true, "block edit executes on arrived map") + T.eq(f.ow.map:blockAt(0, 0), 7, "native block patch is on destination") + f.restoreRenderer() +end +-- Exercise the actual native transition; only destination materialization is a fixture. +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 _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, cancel in ipairs({ false, true }) do + local f = fixture(v) + 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 } + f:arrive() + end + T.check( + f.exports.queue({ { "warp", f.dest, 2, 2, "right" }, { "set_flag", "mod:after" } }), + "actual transition admitted" + ) + local state = f.game.stack:top() + T.eq(getmetatable(state), Transition, "native warp transition retained") + if cancel then + f.loader:_rollback("script_travel") + T.eq(f.game.stack:top(), state, "unload retains engine-owned warp transition") + end + for _ = 1, state.frames + state.framesIn + 3 do + if f.game.stack:top() == state then + state:update(1 / 60) + end + f:tick() + end + T.eq(f.ow.map.id, f.dest, "actual transition arrives") + T.eq(f.ow.transitioning, false, "native warp releases its lock even after unload") + T.eq(f.exports.result().success, not cancel, "only live script continues") + T.eq(f.exports.get("mod:after"), not cancel, "no late effects after unload") + restore() + f.restoreRenderer() + end +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("Portable script travel") diff --git a/tests/engine/portable_world_scripts.lua b/tests/engine/portable_world_scripts.lua index ccef037d9..ffe1fd55d 100644 --- a/tests/engine/portable_world_scripts.lua +++ b/tests/engine/portable_world_scripts.lua @@ -824,7 +824,8 @@ for _, version in ipairs(GameVersion.ORDER) do }) do T.eq(api:supports("scripts", verb), true, "bounded verb capability") end - T.eq(api:supports("scripts", "warp"), false, "unsupported verb declined") + T.eq(api:supports("scripts", "engine_private_command"), false, "unsupported verb declined") + T.eq(api:supports("scripts", "warp"), GameVersion.generation(version) <= 3, "travel capability matches generation") 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 diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index de26d3014..9e710fcfb 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", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 7f46d4d0a6164ab6101816ebdf09ee8dc7253640 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:37:39 +0200 Subject: [PATCH 25/42] Add portable wild and static battle scripts --- docs/api3-portability.md | 2 +- docs/portable-world-scripts.md | 30 +++ src/mods/Api3WildBattle.lua | 10 +- src/mods/PortableScripts.lua | 121 ++++++++++- src/mods/ScriptBattles.lua | 83 ++++++++ src/mods/ScriptProgram.lua | 15 +- .../mods/script_battles/main.lua | 15 ++ .../mods/script_battles/manifest.json | 1 + tests/api3_contract/script_battles.lua | 64 ++++++ tests/api3_contract/wild_battle.lua | 4 +- tests/api3_contract/worker.lua | 5 +- tests/engine/portable_script_battles.lua | 192 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 13 files changed, 533 insertions(+), 11 deletions(-) create mode 100644 src/mods/ScriptBattles.lua create mode 100644 tests/api3_contract/mods/script_battles/main.lua create mode 100644 tests/api3_contract/mods/script_battles/manifest.json create mode 100644 tests/api3_contract/script_battles.lua create mode 100644 tests/engine/portable_script_battles.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index b849bf91c..bcfee128f 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -135,7 +135,7 @@ 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 and eight NPC cases extend this to 96 comparisons -and 240 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +and 256 isolated loader runs (including world-action, flag and portable-script action cases). 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 diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index dc0820dc4..d38bcbccb 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -353,3 +353,33 @@ The transition belongs to the engine: unloading or timing out a portable script leaves the already admitted native warp free to finish and release its own locks. It suppresses the script continuation, rather than undoing committed native travel or removing the engine's transition screen. + +## Wild and static battles + +Gen1–3 accept `start_battle("wild", speciesId, level)`, +`static_battle(speciesId, level, beatFlag?)` and +`check_battle_result(result, ...)` (1–4 of `win`, `lose`, `run`, `caught`). +Species IDs are canonical strings. Levels are whole numbers 1–100. +All species references and optional beat flags are checked before any +prefix runs. Healthy-party admission is checked at dispatch, so an earlier +`heal_party` can repair the party before the encounter. + +The private script admission guard reuses the public wild-battle adapter, +native constructors, entry transition and `afterBattle`. Ordinary public +calls retain idle-world admission. A static encounter sets its optional +beat flag and hides the talking NPC on win, catch or flee; loss skips both. +Result checks set the condition used by `jump_if_true`/`jump_if_false`. +The foreground continuation waits behind native post-battle and foreign UI. + +Cancelling/unloading suppresses the script continuation while preserving +native battle screens and native cleanup for the same live game/save. +Changed sessions suppress that old-session cleanup. Native constructor, +admission and cleanup errors fail the script; late callbacks are one-shot. +No rollback of already committed battle effects is implied. + +Private beat flags use this host's same namespaced flag service on every +generation. The pinned Gen1 reference delegates Gen1 static encounters to +its native command, which does not route private beat flags through that +namespace. The shared Gen1 static cases therefore exercise a real native +beat flag; host-specific private-flag behavior has separate coverage. +Trainer encounters remain a separate follow-up to this wild/static slice. diff --git a/src/mods/Api3WildBattle.lua b/src/mods/Api3WildBattle.lua index 0cb0ba143..aac918366 100644 --- a/src/mods/Api3WildBattle.lua +++ b/src/mods/Api3WildBattle.lua @@ -47,7 +47,7 @@ local function observe(loader) return unpack(result, 2, result.n) end, math.huge, "engine:api3_wild_battle") end -function B.new(loader, owner, current, native, licensed, req) +function B.new(loader, owner, current, native, licensed, req, ownedReady) local ids = Ids.new(loader) observe(loader) return function(species, level, callback) @@ -62,7 +62,7 @@ function B.new(loader, owner, current, native, licensed, req) if not ow then return nil, "no overworld" end - local ok, reason = Edits.ready(game, ow) + local ok, reason = (ownedReady or Edits.ready)(game, ow) if not ok then return nil, reason end @@ -138,6 +138,9 @@ function B.new(loader, owner, current, native, licensed, req) local called, err = pcall(ow.afterBattle, ow, (...), battle) if not called then Runtime.reportError(owner, "world battle cleanup failed: " .. text(err)) + if ownedReady and valid and fn then + pcall(fn, nil, "native battle cleanup failed: " .. text(err)) + end return end if valid and licensed() and live() and fn then @@ -156,6 +159,9 @@ function B.new(loader, owner, current, native, licensed, req) if not battle or battle.dead then return nil, "battle failed" end + if not token.valid() or ownedReady and not ownedReady(game, ow) then + return nil, "battle session changed" + end battle.onFinish = finish local value, detail = ow:pushBattle(battle) if value == false or value == nil and detail ~= nil then diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index fad11a146..353586284 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -5,6 +5,7 @@ local Runtime = require("src.mods.Runtime") local Flags = require("src.mods.Api3Flags") local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local Battles = require("src.mods.ScriptBattles") local Travel = require("src.mods.ScriptTravel") local Movement = require("src.mods.ScriptMovement") local WorldEdits = require("src.mods.ScriptWorldEdits") @@ -191,6 +192,38 @@ local function facade(loader, modId, native) -- a new observer into a live hook chain could replay its current wrapper. observe(loader) local world, active = {}, nil + local nativeGame, nativeWorld + local function battleWorld(game) + if nativeGame ~= game then + nativeWorld = require("src.world.WorldAPI").new(game, modId) + nativeGame = game + end + return nativeWorld + end + local startWild = require("src.mods.Api3WildBattle").new( + loader, + modId, + function() + return current(loader) + end, + battleWorld, + licensed, + require, + function(game, ow) + if + active + and runs[game] == active + and not active.done + and active.world == ow + and ow.runner == active.runner + and game.stack:top() == ow + and active.valid() + then + return true + end + return nil, "portable script does not own this world" + end + ) local function queue(rows, options) if not licensed() then return nil, "script facade is retired" @@ -246,6 +279,10 @@ local function facade(loader, modId, native) if not gift then return nil, giftReason end + local battleOK, battleReason = Battles.prepare(loader, game, ow, program, flag) + if not battleOK then + return nil, battleReason + end local travel, travelReason = Travel.prepare(game, ow, modId, program) if not travel then return nil, travelReason @@ -424,7 +461,7 @@ local function facade(loader, modId, native) return state end discover = function(before, step) - if token.done or token.step ~= step or token.travel then + if token.done or token.step ~= step or token.travel or token.battling then return end for _, state in ipairs(game.stack.states) do @@ -553,6 +590,84 @@ local function facade(loader, modId, native) game.stack:push(menu) runner:yield() end + local function battle(ctx, row) + local verb = row[1] + if verb == "check_battle_result" then + ctx.lastCheck = false + for i = 2, #row do + if row[i] == ctx.lastBattleResult then + ctx.lastCheck = true + end + end + return + end + local species, level = row[2], row[3] + if verb == "start_battle" then + species, level = row[3], row[4] + end + local step, complete, result = token.step, false, nil + token.battling = true + local ok, why = startWild(species, level, function(outcome, failure) + if complete or token.step ~= step or not check() then + return + end + if not Battles.outcomes[outcome] then + token.finish(false, failure or "invalid native battle result") + return + end + complete, result = true, outcome + end) + if not ok then + token.battling = nil + error(why, 0) + end + if not check() then + return + end + if not complete then + runner.waitingCheck = function() + return complete + end + runner:yield() + end + token.battling = nil + if not check() then + return + end + ctx.lastBattleResult, ctx.lastCheck = result, result == "win" + if verb == "static_battle" and result ~= "lose" then + if row[4] ~= nil then + local value, reason = flag(row[4], true, true) + if not value then + error(reason, 0) + end + end + if not check() then + return + end + local npc = movement.npc + local def = npc and npc.def + local ref = def and (def.name or def.localId or def.index or def.id) + if ref then + local value, reason = require("src.mods.ScriptWorldEdits").prepare( + game, + ow, + modId, + { { "hide_object", ow.map.id, ref } } + ) + if not value then + error(reason, 0) + end + local accepted, detail = value({ "hide_object", ow.map.id, ref }, mutate) + if not check() then + return + end + if not accepted then + error(detail, 0) + end + end + end + end local function warp(ctx, row) local plan, reason = travel(row) if not plan then @@ -842,7 +957,9 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if Travel.ARITY[verb] then + if Battles.ARITY[verb] then + battle(ctx, row) + elseif Travel.ARITY[verb] then warp(ctx, row) elseif Movement.ARITY[verb] then move(ctx, row) diff --git a/src/mods/ScriptBattles.lua b/src/mods/ScriptBattles.lua new file mode 100644 index 000000000..e34efec5d --- /dev/null +++ b/src/mods/ScriptBattles.lua @@ -0,0 +1,83 @@ +-- Portable wild/static battle requests; native battle cleanup owns its lifetime. +local Ids = require("src.mods.UnifiedIds") +local Version = require("src.core.GameVersion") +local B = { ARITY = { start_battle = 4, static_battle = 4, check_battle_result = 5 } } +local outcomes = { win = true, lose = true, run = true, caught = true } +function B.syntax(verb, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable battles unavailable for this generation" + end + if verb == "check_battle_result" then + if #row < 2 or #row > 5 then + return nil, "invalid battle result list" + end + local out = { verb } + for i = 2, #row do + if not outcomes[row[i]] then + return nil, "invalid battle result" + end + out[i] = row[i] + end + return out + end + local species, level = row[2], row[3] + if verb == "start_battle" then + if #row ~= 4 or row[2] ~= "wild" then + return nil, "portable start_battle currently requires wild" + end + species, level = row[3], row[4] + elseif #row < 3 or #row > 4 then + return nil, "invalid command arity" + end + if type(species) ~= "string" or #species == 0 or #species > 256 or species:find("%c") then + return nil, "invalid species id" + end + if type(level) ~= "number" or level ~= level or level < 1 or level > 100 or level % 1 ~= 0 then + return nil, "invalid battle level" + end + return { row[1], row[2], row[3], row[4] } +end +function B.prepare(loader, game, world, rows, flag) + local ids = Ids.new(loader) + local talking = world.talkNpc or world.runner and world.runner.ctx and world.runner.ctx.npc + for i, row in ipairs(rows) do + local verb = row[1] + if verb == "warp" then + talking = nil + end + if verb == "start_battle" or verb == "static_battle" then + local species = verb == "start_battle" and row[3] or row[2] + local group, why = ids:resolve("pokemon", species) + if not group or not (game.data.pokemon and game.data.pokemon[group.key]) then + return nil, "row " .. i .. ": " .. (why or "unknown species") + end + if type(world.pushBattle) ~= "function" or type(world.afterBattle) ~= "function" then + return nil, "battle runtime unavailable" + end + if verb == "static_battle" and talking then + local def = talking.def + local ref = def and (def.name or def.localId or def.index or def.id) + if ref then + local edit, reason = require("src.mods.ScriptWorldEdits").prepare( + game, + world, + "", + { { "hide_object", world.map.id, ref } } + ) + if not edit then + return nil, "row " .. i .. ": " .. reason + end + end + end + if verb == "static_battle" and row[4] ~= nil then + local value, reason = flag(row[4]) + if value == nil then + return nil, "row " .. i .. ": " .. reason + end + end + end + end + return true +end +B.outcomes = outcomes +return B diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index 1ccf08cd2..7b859ce02 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -2,6 +2,7 @@ local Program = {} local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local Battles = require("src.mods.ScriptBattles") local Travel = require("src.mods.ScriptTravel") local Movement = require("src.mods.ScriptMovement") local WorldEdits = require("src.mods.ScriptWorldEdits") @@ -36,13 +37,16 @@ end for verb, arity in pairs(Travel.ARITY) do ARITY[verb] = arity end +for verb, arity in pairs(Battles.ARITY) do + ARITY[verb] = arity +end local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) if type(verb) ~= "string" then return false end if - (WorldEdits.ARITY[verb] or Movement.ARITY[verb] or Travel.ARITY[verb]) + (WorldEdits.ARITY[verb] or Movement.ARITY[verb] or Travel.ARITY[verb] or Battles.ARITY[verb]) and require("src.core.GameVersion").generation() > 3 then return false @@ -128,12 +132,19 @@ function Program.compile(raw, context) and not WorldEdits.ARITY[verb] and not Movement.ARITY[verb] and not Travel.ARITY[verb] + and not Battles.ARITY[verb] and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if Travel.ARITY[verb] then + if Battles.ARITY[verb] then + local value, reason = Battles.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif Travel.ARITY[verb] then local value, reason = Travel.syntax(verb, source, context) if not value then return fail(i, reason) diff --git a/tests/api3_contract/mods/script_battles/main.lua b/tests/api3_contract/mods/script_battles/main.lua new file mode 100644 index 000000000..be627b337 --- /dev/null +++ b/tests/api3_contract/mods/script_battles/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.result = function() + return { done = done, success = success } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_battles/manifest.json b/tests/api3_contract/mods/script_battles/manifest.json new file mode 100644 index 000000000..33210dc51 --- /dev/null +++ b/tests/api3_contract/mods/script_battles/manifest.json @@ -0,0 +1 @@ +{"id":"script_battles","name":"Portable battles","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/script_battles.lua b/tests/api3_contract/script_battles.lua new file mode 100644 index 000000000..d7198dd11 --- /dev/null +++ b/tests/api3_contract/script_battles.lua @@ -0,0 +1,64 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_battles%.lua$") + local f = dofile(codeRoot .. "/wild_battle.lua").fixture(root, backend, version, "script_battles") + f.loader.game = f.game + f.ow.runner.update = function() end + if f.field then + function f.field.lock(token) + f.field._locks[token] = true + end + function f.field.unlock(token) + f.field._locks[token] = nil + end + end + function f:tick() + self.loader.hooks:call(backend == "gen1" and "core.update" or "input.step", function() + if backend == "gen2" then + self.ow.runner:update() + end + end, self.game, 1 / 60) + end + function f:drain() + for _ = 1, 50 do + if self.exports.result().done > 0 then + return + end + self:tick() + end + error("battle script did not finish") + end + return f +end +function M.run(root, backend, version) + local report = {} + for _, outcome in ipairs({ "win", "lose", "run", "caught" }) do + local f = M.fixture(root, backend, version) + local beat = f.gen == 1 and "EVENT_BEAT_MEWTWO" or "mod:beat" + assert(f.exports.queue({ + { "static_battle", "MEW", 5, beat }, + { "check_battle_result", outcome }, + { "jump_if_false", "end" }, + { "set_flag", "mod:result" }, + })) + assert(f.exports.result().done == 0 and f.pushed == 1) + f:complete(outcome) + f:drain() + assert(f.exports.result().success and f.exports.get("mod:result")) + assert( + f.exports.get(beat) == (outcome ~= "lose") and f.after == 1, + outcome .. " beat=" .. tostring(f.exports.get(beat)) .. " cleanup=" .. f.after + ) + report[outcome] = { beat = f.exports.get(beat), after = f.after, result = true } + f.restore() + end + local f = M.fixture(root, backend, version) + local value, why = + f.exports.queue({ { "start_battle", "wild", "MEW", 5 }, { "static_battle", "UNKNOWN", 5 } }) + assert(value == nil and type(why) == "string" and f.constructed == 0) + report.preflight = true + f.restore() + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/wild_battle.lua b/tests/api3_contract/wild_battle.lua index bbfa86a1f..704addeb1 100644 --- a/tests/api3_contract/wild_battle.lua +++ b/tests/api3_contract/wild_battle.lua @@ -1,8 +1,8 @@ -- Production public facades/loaders; native battle entry/timing seams only. local M = {} -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, modId) local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/wild_battle%.lua$") - local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "wild_battle") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, modId or "wild_battle") love = love or require("tests.love_stub") f.game.save.party = { { species = "MEW", hp = 20, level = 10, stats = { hp = 20 } } } f.game.save.pokedex = { seen = {}, caught = {} } diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index d69c8e8dc..0a2522100 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -22,11 +22,14 @@ assert( or mode == "script_world" or mode == "script_motion" or mode == "script_travel" + or mode == "script_battles" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_travel" then +if mode == "script_battles" then + dofile(codeRoot .. "/script_battles.lua").run(root, backend, version) +elseif mode == "script_travel" then dofile(codeRoot .. "/script_travel.lua").run(root, backend, version) elseif mode == "script_motion" then dofile(codeRoot .. "/script_motion.lua").run(root, backend, version) diff --git a/tests/engine/portable_script_battles.lua b/tests/engine/portable_script_battles.lua new file mode 100644 index 000000000..429afb3cf --- /dev/null +++ b/tests/engine/portable_script_battles.lua @@ -0,0 +1,192 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_battles") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Battle = require("src.battle.BattleState") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v) + return Contract.fixture("tests/api3_contract", "gen2", v or "red") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared script battle: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "set_flag", "mod:prefix" }, { "start_battle", "wild", "UNKNOWN", 5 } }, + { { "start_battle", "wild", "MEW", 0 } }, + { { "start_battle", "wild", "MEW", 101 } }, + { { "start_battle", "trainer", "ANY", 1 } }, + { { "static_battle", "MEW", 5, "mod.other.bad" } }, + { { "static_battle", "MEW", 5 }, { "check_battle_result", "invalid" } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid battle list preflight") + T.eq(f.exports.get("mod:prefix"), false, "invalid suffix has no flag prefix") + T.eq(f.constructed, 0, "preflight constructs no native battle") + f.restore() +end +for _, outcome in ipairs({ "win", "lose", "run", "caught" }) do + local f = fixture() + T.check( + f.exports.queue({ + { "static_battle", "MEW", 5, "mod:beat" }, + { "check_battle_result", outcome }, + { "jump_if_false", "end" }, + { "set_flag", "mod:after" }, + }), + "private static beat flag admitted" + ) + f:complete(outcome) + f:drain() + T.eq(f.exports.get("mod:beat"), outcome ~= "lose", "private static flag is generation independent") + T.eq(f.exports.get("mod:after"), true, "battle result branch executes") + f.restore() +end +for _, change in ipairs({ "cancel", "rollback", "save", "data", "player" }) do + local f = fixture() + local original = f.ow.runner + T.check( + f.exports.queue({ { "static_battle", "MEW", 5, "mod:beat" }, { "set_flag", "mod:after" } }), + "battle admitted before retirement" + ) + local finish = f.finish + if change == "cancel" then + require("src.mods.PortableScripts").cancelAll(f.loader, "script_battles") + elseif change == "rollback" then + f.loader:_rollback("script_battles") + elseif change == "player" then + f.ow.player = {} + else + f.game[change] = {} + end + f:tick() + T.eq(f.game.stack:top(), f.battle, "cancelling script preserves native battle screen") + T.eq(f.exports.result().done, 1, "retirement completes once") + T.eq(f.exports.result().success, false, "retired battle cannot finish script") + f:complete("caught") + finish("win") + finish("run") + T.eq( + f.after, + (change == "cancel" or change == "rollback") and 1 or 0, + "cleanup only for retained native session" + ) + T.eq(f.exports.result().done, 1, "native finish cannot replay retired completion") + T.eq(f.ow.runner, original, "idle runner restored") + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ { "start_battle", "wild", "MEW", 5 }, { "set_flag", "mod:after" } }), + "battle before foreign overlay" + ) + local callback = f.finish + f:complete("win") + local overlay = {} + f.game.stack.states[2] = overlay + callback("lose") + callback("run") + for _ = 1, 5 do + f:tick() + end + T.eq(f.exports.get("mod:after"), false, "battle callback cannot resume under foreign UI") + T.eq(f.after, 1, "native cleanup is one-shot") + f.game.stack.states[2] = nil + f:drain() + T.eq(f.exports.get("mod:after"), true, "foreground battle continuation runs") + f.restore() +end +for _, failure in ipairs({ "constructError", "pushFailure", "cleanupError", "unhealthy", "constructUnload" }) do + local f = fixture() + if failure == "unhealthy" then + f.game.save.party[1].hp = 0 + elseif failure == "constructUnload" then + local native = package.loaded["src.battle.BattleState"].newWild + package.loaded["src.battle.BattleState"].newWild = function(...) + local result = native(...) + f.loader:_rollback("script_battles") + return result + end + else + f[failure] = true + end + T.check( + f.exports.queue({ { "start_battle", "wild", "MEW", 5 }, { "set_flag", "mod:after" } }), + "admitted program reports native failure" + ) + if failure == "cleanupError" then + f:complete("win") + end + T.eq(f.exports.result().success, false, "native failure finishes script") + T.eq(f.exports.get("mod:after"), false, "native failure suppresses suffix") + if failure == "constructUnload" then + T.eq(f.pushed, 0, "owner retired during construction cannot push battle") + end + f.restore() +end +do + local f = fixture() + f.synchronous = true + T.check( + f.exports.queue({ { "start_battle", "wild", "MEW", 5 }, { "set_flag", "mod:after" } }), + "synchronous battle admitted" + ) + T.eq(f.exports.result().success, true, "synchronous battle completion handled") + T.eq(f.after, 1, "synchronous cleanup once") + f.restore() +end +for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, outcome in ipairs({ "win", "lose", "run", "caught" }) do + local f = fixture(v) + local def = { name = "NPC", index = 1, localId = 1, x = 1, y = 1, eventFlag = "FLAG_A", flag = 7 } + f.ow.map.def.objects = { def } + f.ow.talkNpc = { def = def, cellX = 1, cellY = 1 } + function f.ow:syncObjectVisibility() + f.hidden = true + end + function f.ow:setMap() + f.hidden = f.game.save.objectToggles[f.home].NPC == false + end + T.check( + f.exports.queue({ { "static_battle", "MEW", 5, "mod:beat" }, { "set_flag", "mod:after" } }), + "static NPC battle admitted" + ) + T.eq(f.hidden, nil, "NPC stays visible during battle") + f:complete(outcome) + f:drain() + T.eq(f.exports.result().success, true, "static NPC continuation succeeds") + T.eq(f.hidden == true, outcome ~= "lose", "static NPC hidden on every non-loss outcome") + T.eq(f.exports.get("mod:after"), true, "static object update resumes later rows") + f.restore() + end +end +-- Actual native constructors and Pokémon creation, retaining the entry timing seam. +for _, v in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(v) + f.restore() + local data = T.fixtures.fresh() + data.pokemon.MEW = require("src.mods.Merge").deepCopy(data.pokemon.FIXMON_A) + data.pokemon.MEW.id, data.pokemon.MEW.name = "MEW", "MEW" + data.constants.gen = Version.generation(v) + f.game.data = data + f.game.save = require("src.core.SaveData").newGame() + f.game.session = f.game.save + f.game.save.party = { require("src.pokemon.Pokemon").new(data, "FIXMON_A", 15) } + require("src.render.Font").load(data) + T.check( + f.exports.queue({ { "static_battle", "MEW", 5, "mod:beat" }, { "set_flag", "mod:after" } }), + v .. " native constructor admitted" + ) + T.eq(f.battle.kind, "wild", "native battle kind") + T.eq(f.battle.enemy.mon.species, "MEW", "native enemy species") + f:complete("caught") + f:drain() + T.eq(f.after, 1, "native cleanup retained") + T.eq(f.exports.get("mod:beat"), true, "actual native battle resumes static suffix") +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("Portable script battles") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 9e710fcfb..89d49cc64 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", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 089a4c9c1dbdbbbd65fd02bee19ad3af3f356d00 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:49:05 +0200 Subject: [PATCH 26/42] Add native portable trainer battle scripts --- docs/api3-portability.md | 2 +- docs/portable-world-scripts.md | 27 ++- src/mods/Api3WildBattle.lua | 60 ++++-- src/mods/PortableScripts.lua | 49 +++-- src/mods/ScriptBattles.lua | 117 ++++++++++- .../mods/script_trainers/main.lua | 15 ++ .../mods/script_trainers/manifest.json | 1 + tests/api3_contract/script_battles.lua | 4 +- tests/api3_contract/script_trainers.lua | 82 ++++++++ tests/api3_contract/worker.lua | 5 +- tests/engine/portable_script_trainers.lua | 198 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 12 files changed, 514 insertions(+), 48 deletions(-) create mode 100644 tests/api3_contract/mods/script_trainers/main.lua create mode 100644 tests/api3_contract/mods/script_trainers/manifest.json create mode 100644 tests/api3_contract/script_trainers.lua create mode 100644 tests/engine/portable_script_trainers.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index bcfee128f..5025399e6 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -135,7 +135,7 @@ 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 and eight NPC cases extend this to 96 comparisons -and 256 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +and 272 isolated loader runs (including world-action, flag and portable-script action cases). 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 diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index d38bcbccb..a00d1b543 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -382,4 +382,29 @@ generation. The pinned Gen1 reference delegates Gen1 static encounters to its native command, which does not route private beat flags through that namespace. The shared Gen1 static cases therefore exercise a real native beat flag; host-specific private-flag behavior has separate coverage. -Trainer encounters remain a separate follow-up to this wild/static slice. +Trainer encounters are described below. + + +## Trainer encounters + +`start_battle("trainer", trainerRef, partyIndex?)` uses this version's native +trainer data and constructor. Gen1 normally selects a class key and numbered +party; Gen2 accepts the native class number (resolved to the host's class +record); Gen3 accepts a native trainer number through `trainerOrder`, with +one party per trainer. Existing host keys are also accepted. These selectors +are game-specific and are not universal trainer IDs. Use an explicit party +index for shared Gen2 scripts: the pinned reference requires that argument. + +Preflight validates every referenced roster before a prefix runs and repeats +that check at dispatch. Unknown species, invalid levels, empty/oversized +parties, ambiguous class aliases and unavailable Gen3 flag metadata are +refused. Native class aliases select the canonical class deterministically. +Gen3 named reference constants are not guessed from display names: use the +native trainer number for engine portability. + +Trainer battles share the same owned continuation, cancellation and native +cleanup lifetime as wild encounters. Gen3 wins use the host's native trainer +victory flag and Match Call registration helpers; loss does not mark a win. +Cleanup remains valid after the originating mod retires, provided the same +native session still exists. Native battle mechanics and game-specific story +rewards remain the responsibility of this host's battle/field code. diff --git a/src/mods/Api3WildBattle.lua b/src/mods/Api3WildBattle.lua index aac918366..8f5ca5ece 100644 --- a/src/mods/Api3WildBattle.lua +++ b/src/mods/Api3WildBattle.lua @@ -1,4 +1,5 @@ -- API-3 encounter admission over native construction, transition and afterBattle. +-- Portable scripts may supply a private owned guard and native trainer plan. local Runtime = require("src.mods.Runtime") local Version = require("src.core.GameVersion") local Edits = require("src.mods.Api3WorldEdits") @@ -47,7 +48,7 @@ local function observe(loader) return unpack(result, 2, result.n) end, math.huge, "engine:api3_wild_battle") end -function B.new(loader, owner, current, native, licensed, req, ownedReady) +function B.new(loader, owner, current, native, licensed, req, ownedReady, ownedNative) local ids = Ids.new(loader) observe(loader) return function(species, level, callback) @@ -74,22 +75,33 @@ function B.new(loader, owner, current, native, licensed, req, ownedReady) retire(token) end end - if type(species) ~= "string" or #species == 0 or #species > 256 or species:find("%c") then - return nil, "invalid species id" - end - if type(level) ~= "number" or level ~= level or level < 1 or level > 100 or level % 1 ~= 0 then - return nil, "level must be a whole number 1..100" - end - if callback ~= nil and type(callback) ~= "function" then - return nil, "invalid callback" - end - local group, why = ids:resolve("pokemon", species) - if not group then - return nil, why:find("^ambiguous") and why or "unknown species: " .. species - end - local key = group.key - if not (game.data.pokemon and game.data.pokemon[key]) then - return nil, "unknown species: " .. species + local key, why + if ownedNative then + key, why = ownedNative.resolve(game, species, level) + if not key then + return nil, why + end + if callback ~= nil and type(callback) ~= "function" then + return nil, "invalid callback" + end + else + if type(species) ~= "string" or #species == 0 or #species > 256 or species:find("%c") then + return nil, "invalid species id" + end + if type(level) ~= "number" or level ~= level or level < 1 or level > 100 or level % 1 ~= 0 then + return nil, "level must be a whole number 1..100" + end + if callback ~= nil and type(callback) ~= "function" then + return nil, "invalid callback" + end + local group, why = ids:resolve("pokemon", species) + if not group then + return nil, why:find("^ambiguous") and why or "unknown species: " .. species + end + key = group.key + if not (game.data.pokemon and game.data.pokemon[key]) then + return nil, "unknown species: " .. species + end end if not Party.firstHealthy(game.save.party or {}) then return nil, "no healthy party" @@ -135,7 +147,13 @@ function B.new(loader, owner, current, native, licensed, req, ownedReady) return end -- Native cleanup still runs after the originating mod has been unloaded. - local called, err = pcall(ow.afterBattle, ow, (...), battle) + local outcome = (...) + local called, err = pcall(function() + if ownedNative and ownedNative.cleanup then + ownedNative.cleanup(game, ow, key, outcome, battle) + end + ow:afterBattle(outcome, battle) + end) if not called then Runtime.reportError(owner, "world battle cleanup failed: " .. text(err)) if ownedReady and valid and fn then @@ -155,7 +173,11 @@ function B.new(loader, owner, current, native, licensed, req, ownedReady) end pending[token] = true local called, accepted, err = pcall(Edits.run, game, function() - battle = req("src.battle.BattleState").newWild(game, key, level) + if ownedNative then + battle = ownedNative.construct(game, key) + else + battle = req("src.battle.BattleState").newWild(game, key, level) + end if not battle or battle.dead then return nil, "battle failed" end diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index 353586284..c6c43101d 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -200,29 +200,41 @@ local function facade(loader, modId, native) end return nativeWorld end + local function ownedBattle(game, ow) + if + active + and runs[game] == active + and not active.done + and active.world == ow + and ow.runner == active.runner + and game.stack:top() == ow + and active.valid() + then + return true + end + return nil, "portable script does not own this world" + end + local function provider() + return current(loader) + end local startWild = require("src.mods.Api3WildBattle").new( loader, modId, - function() - return current(loader) - end, + provider, battleWorld, licensed, require, - function(game, ow) - if - active - and runs[game] == active - and not active.done - and active.world == ow - and ow.runner == active.runner - and game.stack:top() == ow - and active.valid() - then - return true - end - return nil, "portable script does not own this world" - end + ownedBattle + ) + local startTrainer = require("src.mods.Api3WildBattle").new( + loader, + modId, + provider, + battleWorld, + licensed, + require, + ownedBattle, + Battles.nativeTrainer ) local function queue(rows, options) if not licensed() then @@ -607,7 +619,8 @@ local function facade(loader, modId, native) end local step, complete, result = token.step, false, nil token.battling = true - local ok, why = startWild(species, level, function(outcome, failure) + local start = verb == "start_battle" and row[2] == "trainer" and startTrainer or startWild + local ok, why = start(species, level, function(outcome, failure) if complete or token.step ~= step or not check() then return end diff --git a/src/mods/ScriptBattles.lua b/src/mods/ScriptBattles.lua index e34efec5d..2dc34dcfa 100644 --- a/src/mods/ScriptBattles.lua +++ b/src/mods/ScriptBattles.lua @@ -3,6 +3,91 @@ local Ids = require("src.mods.UnifiedIds") local Version = require("src.core.GameVersion") local B = { ARITY = { start_battle = 4, static_battle = 4, check_battle_result = 5 } } local outcomes = { win = true, lose = true, run = true, caught = true } +local function integer(n, lo, hi) + return type(n) == "number" and n == n and n >= lo and n <= hi and n % 1 == 0 +end +function B.trainer(game, reference, member) + local gen = Version.generation() + local rows = game.data.trainers or {} + local key, found + if type(reference) == "number" then + if gen == 3 then + key = game.data.constants + and game.data.constants.trainerOrder + and game.data.constants.trainerOrder[reference] + else + for id, row in pairs(rows) do + if type(row) == "table" and row.index == reference then + local candidateKey = row.id and rows[row.id] and row.id or id + local candidate = rows[candidateKey] + if candidate.index ~= reference then + return nil, "inconsistent trainer alias" + end + if found and found ~= candidate then + return nil, "ambiguous trainer class" + end + key, found = candidateKey, candidate + end + end + end + else + key = reference + end + local row = key and rows[key] + local party = member or 1 + if gen == 3 and party ~= 1 then + return nil, "Gen3 trainer has one party" + end + local roster = row and row.parties and row.parties[party] + if type(roster) ~= "table" or #roster == 0 or #roster > 6 then + return nil, "trainer content unavailable: " .. tostring(reference) + end + for _, mon in ipairs(roster) do + if + type(mon) ~= "table" + or not integer(mon.level, 1, 100) + or not (game.data.pokemon and game.data.pokemon[mon.species]) + then + return nil, "invalid native trainer party" + end + end + local nativeIndex = row and row.index + if gen == 3 then + local order = game.data.constants and game.data.constants.trainerOrder or {} + nativeIndex = nil + for index, id in pairs(order) do + if id == key then + if nativeIndex and nativeIndex ~= index then + return nil, "ambiguous native trainer id" + end + nativeIndex = index + end + end + local base = game.data.constants and game.data.constants.gen3TrainerFlagBase + if + not integer(nativeIndex, 1, 65535) + or not integer(base, 0, 65535 - nativeIndex) + or game.save.flags ~= nil and type(game.save.flags) ~= "table" + then + return nil, "native trainer flags unavailable" + end + end + return { key = key, member = party, index = nativeIndex } +end +B.nativeTrainer = { + resolve = B.trainer, + construct = function(game, plan) + return require("src.battle.BattleState").newTrainer(game, plan.key, plan.member) + end, + cleanup = function(game, world, plan, outcome) + if Version.generation() == 3 and outcome == "win" then + local ctx = { game = game, save = game.save, overworld = world } + local commands = require("src.script.Gen3Commands") + commands.markTrainerBeaten(ctx, plan.index) + commands.registerTrainerInMatchCall(ctx, plan.index) + end + end, +} function B.syntax(verb, row, context) if (context.generation or Version.generation()) > 3 then return nil, "portable battles unavailable for this generation" @@ -22,8 +107,23 @@ function B.syntax(verb, row, context) end local species, level = row[2], row[3] if verb == "start_battle" then + if row[2] == "trainer" then + if + #row < 3 + or #row > 4 + or not (type(row[3]) == "string" and #row[3] > 0 and #row[3] <= 256 and not row[3]:find("%c") or integer( + row[3], + 0, + 65535 + )) + or row[4] ~= nil and not integer(row[4], 1, 255) + then + return nil, "invalid script trainer" + end + return { row[1], row[2], row[3], row[4] } + end if #row ~= 4 or row[2] ~= "wild" then - return nil, "portable start_battle currently requires wild" + return nil, "invalid script battle" end species, level = row[3], row[4] elseif #row < 3 or #row > 4 then @@ -46,10 +146,17 @@ function B.prepare(loader, game, world, rows, flag) talking = nil end if verb == "start_battle" or verb == "static_battle" then - local species = verb == "start_battle" and row[3] or row[2] - local group, why = ids:resolve("pokemon", species) - if not group or not (game.data.pokemon and game.data.pokemon[group.key]) then - return nil, "row " .. i .. ": " .. (why or "unknown species") + if verb == "start_battle" and row[2] == "trainer" then + local plan, why = B.trainer(game, row[3], row[4]) + if not plan then + return nil, "row " .. i .. ": " .. why + end + else + local species = verb == "start_battle" and row[3] or row[2] + local group, why = ids:resolve("pokemon", species) + if not group or not (game.data.pokemon and game.data.pokemon[group.key]) then + return nil, "row " .. i .. ": " .. (why or "unknown species") + end end if type(world.pushBattle) ~= "function" or type(world.afterBattle) ~= "function" then return nil, "battle runtime unavailable" diff --git a/tests/api3_contract/mods/script_trainers/main.lua b/tests/api3_contract/mods/script_trainers/main.lua new file mode 100644 index 000000000..be627b337 --- /dev/null +++ b/tests/api3_contract/mods/script_trainers/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.result = function() + return { done = done, success = success } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_trainers/manifest.json b/tests/api3_contract/mods/script_trainers/manifest.json new file mode 100644 index 000000000..66dd1a92c --- /dev/null +++ b/tests/api3_contract/mods/script_trainers/manifest.json @@ -0,0 +1 @@ +{"id":"script_trainers","name":"Portable trainer battles","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/script_battles.lua b/tests/api3_contract/script_battles.lua index d7198dd11..f29912824 100644 --- a/tests/api3_contract/script_battles.lua +++ b/tests/api3_contract/script_battles.lua @@ -1,7 +1,7 @@ local M = {} -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, modId) local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_battles%.lua$") - local f = dofile(codeRoot .. "/wild_battle.lua").fixture(root, backend, version, "script_battles") + local f = dofile(codeRoot .. "/wild_battle.lua").fixture(root, backend, version, modId or "script_battles") f.loader.game = f.game f.ow.runner.update = function() end if f.field then diff --git a/tests/api3_contract/script_trainers.lua b/tests/api3_contract/script_trainers.lua new file mode 100644 index 000000000..81d2a4d8b --- /dev/null +++ b/tests/api3_contract/script_trainers.lua @@ -0,0 +1,82 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_trainers%.lua$") + local f = dofile(codeRoot .. "/script_battles.lua").fixture(root, backend, version, "script_trainers") + f.game.data.trainers = + { PROBE = { id = "PROBE", index = 7, name = "PROBE", parties = { { { species = "MEW", level = 5 } } } } } + f.game.data.constants = f.game.data.constants or {} + f.game.data.constants.trainerOrder = { [7] = "PROBE" } + f.game.data.constants.gen3TrainerFlagBase = 0x500 + local battle = package.loaded["src.battle.BattleState"] + battle.newTrainer = function(game, class, member) + f.constructed = f.constructed + 1 + if f.constructError then + error("trainer constructor probe", 0) + end + return { kind = "trainer", class = class, member = member } + end + f.trainerRef = f.gen == 1 and "PROBE" or 7 + if backend == "gen1" and f.gen == 2 then + f.game.data.trainers = { + classes = { + PROBE = { + id = "PROBE", + index = 7, + name = "PROBE", + trainers = { { id = "PROBE_1", name = "PROBE", party = { { species = "MEW", level = 5 } } } }, + }, + }, + } + function f.ow:trainerParty(class, member) + return require("src.world.gen2.TrainerHouse").lookup(f.game.data.trainers, f.game.save, class, member) + end + function f.ow:startScriptedBattle(trainer, _, done) + f.constructed = f.constructed + 1 + return f:admit({ kind = "trainer", trainer = trainer }, done, true) + end + elseif backend == "gen1" and f.gen == 3 then + package.loaded["src.core.game3.scripting.trainers"] = { + get = function(id) + return id == 7 and { party = { { species = 151, level = 5 } } } or nil + end, + foeFromId = function(id) + return id == 7 and { trainerId = id, party = { { species = 151, level = 5 } } } or nil + end, + } + package.loaded["src.core.game3.battle_bridge"].start = function(_, game, foe, opts) + f.constructed = f.constructed + 1 + local value, why = f:admit({ kind = "trainer", foe = foe }, opts.done, true) + if value == false then + return value, why + end + return true + end + end + return f +end +function M.run(root, backend, version) + local report = {} + for _, outcome in ipairs({ "win", "lose", "run" }) do + local f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "start_battle", "trainer", f.trainerRef, 1 }, + { "check_battle_result", outcome }, + { "jump_if_false", "end" }, + { "set_flag", "mod:after" }, + })) + assert(f.constructed == 1 and f.pushed == 1 and f.exports.result().done == 0) + f:complete(outcome) + f:drain() + assert(f.after == 1 and f.exports.result().success and f.exports.get("mod:after")) + report[outcome] = { after = f.after, complete = true } + f.restore() + end + local f = M.fixture(root, backend, version) + local value, why = f.exports.queue({ { "set_flag", "mod:prefix" }, { "start_battle", "trainer", 999, 1 } }) + assert(value == nil and type(why) == "string" and not f.exports.get("mod:prefix") and f.constructed == 0) + report.preflight = true + f.restore() + 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 0a2522100..947f910c3 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -23,11 +23,14 @@ assert( or mode == "script_motion" or mode == "script_travel" or mode == "script_battles" + or mode == "script_trainers" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_battles" then +if mode == "script_trainers" then + dofile(codeRoot .. "/script_trainers.lua").run(root, backend, version) +elseif mode == "script_battles" then dofile(codeRoot .. "/script_battles.lua").run(root, backend, version) elseif mode == "script_travel" then dofile(codeRoot .. "/script_travel.lua").run(root, backend, version) diff --git a/tests/engine/portable_script_trainers.lua b/tests/engine/portable_script_trainers.lua new file mode 100644 index 000000000..022ecd236 --- /dev/null +++ b/tests/engine/portable_script_trainers.lua @@ -0,0 +1,198 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_trainers") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v) + return Contract.fixture("tests/api3_contract", "gen2", v or "red") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared trainer contract: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "set_flag", "mod:prefix" }, { "start_battle", "trainer", 999, 1 } }, + { { "start_battle", "trainer", {}, 1 } }, + { { "start_battle", "trainer", "PROBE", 0 } }, + { { "start_battle", "trainer", "PROBE", 256 } }, + { { "start_battle", "trainer", "PROBE", 2 } }, + { { "start_battle", "trainer", "PROBE", 1, {} } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid trainer preflight") + T.eq(f.constructed, 0, "invalid trainer does not construct") + T.eq(f.exports.get("mod:prefix"), false, "invalid suffix prevents prefix") + f.restore() +end +for _, change in ipairs({ "species", "level", "empty", "oversized", "ambiguous" }) do + local f = fixture("gold") + local row = f.game.data.trainers.PROBE + if change == "species" then + row.parties[1][1].species = "UNKNOWN" + elseif change == "level" then + row.parties[1][1].level = 101 + elseif change == "empty" then + row.parties[1] = {} + elseif change == "oversized" then + for i = 2, 7 do + row.parties[1][i] = row.parties[1][1] + end + else + f.game.data.trainers.OTHER = { id = "OTHER", index = 7, parties = row.parties } + end + T.eq( + f.exports.queue({ { "set_flag", "mod:prefix" }, { "start_battle", "trainer", 7, 1 } }), + nil, + "invalid native trainer data: " .. change + ) + T.eq(f.constructed, 0, "invalid data never constructs") + T.eq(f.exports.get("mod:prefix"), false, "invalid data never runs prefix") + f.restore() +end +do + local f = fixture("gold") + f.game.data.trainers.ALIAS = require("src.mods.Merge").deepCopy(f.game.data.trainers.PROBE) + T.check(f.exports.queue({ { "start_battle", "trainer", 7, 1 } }), "canonical native alias resolves once") + T.eq(f.battle.class, "PROBE", "native class identity selected deterministically") + f:complete("win") + f:drain() + f.restore() +end +for _, change in ipairs({ "missing_order", "ambiguous_order", "missing_base", "invalid_flags", "member" }) do + local f = fixture("firered") + local row = { "start_battle", "trainer", 7, 1 } + if change == "missing_order" then + f.game.data.constants.trainerOrder = {} + elseif change == "ambiguous_order" then + f.game.data.constants.trainerOrder[8] = "PROBE" + elseif change == "missing_base" then + f.game.data.constants.gen3TrainerFlagBase = nil + elseif change == "invalid_flags" then + f.game.save.flags = false + else + row[4] = 2 + end + T.eq(f.exports.queue({ row }), nil, "unrepresentable Gen3 trainer refused: " .. change) + T.eq(f.constructed, 0, "Gen3 refusal has no constructor") + f.restore() +end +for _, change in ipairs({ "rollback", "save", "data", "player", "cancel" }) do + local f = fixture("firered") + T.check( + f.exports.queue({ { "start_battle", "trainer", 7, 1 }, { "set_flag", "mod:after" } }), + "trainer admitted before retirement" + ) + local callback = f.finish + if change == "rollback" then + f.loader:_rollback("script_trainers") + elseif change == "player" then + f.ow.player = {} + elseif change == "cancel" then + require("src.mods.PortableScripts").cancelAll(f.loader, "script_trainers") + else + f.game[change] = {} + end + f:tick() + T.eq(f.game.stack:top(), f.battle, "trainer native screen survives script retirement") + f:complete("win") + callback("lose") + T.eq(f.exports.result().success, false, "retired trainer cannot continue") + T.eq( + f.after, + (change == "rollback" or change == "cancel") and 1 or 0, + "native cleanup only on same session" + ) + T.eq(f.exports.result().done, 1, "late trainer callback completes once") + if change == "rollback" or change == "cancel" then + T.eq( + f.game.save.flags.FLAG_G3_0507, + true, + "native trainer victory flag survives originating mod retirement" + ) + end + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ { "wait", 1 }, { "start_battle", "trainer", "PROBE", 1 } }), + "deferred trainer preflight" + ) + f.game.data.trainers.PROBE.parties[1][1].species = "UNKNOWN" + f:drain() + T.eq(f.exports.result().success, false, "trainer data revalidated at dispatch") + T.eq(f.constructed, 0, "changed live roster cannot construct") + f.restore() +end +for _, failure in ipairs({ "constructError", "pushFailure", "cleanupError", "constructUnload" }) do + local f = fixture() + if failure == "constructUnload" then + local native = package.loaded["src.battle.BattleState"].newTrainer + package.loaded["src.battle.BattleState"].newTrainer = function(...) + local result = native(...) + f.loader:_rollback("script_trainers") + return result + end + else + f[failure] = true + end + T.check( + f.exports.queue({ { "start_battle", "trainer", "PROBE", 1 }, { "set_flag", "mod:after" } }), + "trainer native failure admitted" + ) + if failure == "cleanupError" then + f:complete("win") + end + T.eq(f.exports.result().success, false, "trainer native failure terminates") + T.eq(f.exports.get("mod:after"), false, "trainer native failure suppresses suffix") + if failure == "constructUnload" then + T.eq(f.pushed, 0, "retired constructor cannot push trainer battle") + end + f.restore() +end +for _, v in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + for _, outcome in ipairs({ "win", "lose" }) do + local f = fixture(v) + f.restore() + local data = T.fixtures.fresh() + data.trainers.PROBE = { + id = "PROBE", + index = 7, + name = "PROBE", + baseMoney = 10, + parties = { { { species = "FIXMON_B", level = 5 } } }, + } + data.constants.gen = Version.generation(v) + data.constants.trainerOrder = { [7] = "PROBE" } + data.constants.gen3TrainerFlagBase = 0x500 + f.game.data = data + f.game.save = require("src.core.SaveData").newGame() + f.game.session = f.game.save + f.game.save.party = { require("src.pokemon.Pokemon").new(data, "FIXMON_A", 15) } + require("src.render.Font").load(data) + T.check( + f.exports.queue({ + { "start_battle", "trainer", f.trainerRef, 1 }, + { "check_battle_result", outcome }, + { "jump_if_false", "end" }, + { "set_flag", "mod:after" }, + }), + v .. " native trainer admitted" + ) + T.eq(f.battle.kind, "trainer", "actual native trainer kind") + T.eq(f.battle.enemy.mon.species, "FIXMON_B", "actual native trainer party built") + T.eq(f.battle.partyIndex, 1, "actual native member selected") + f:complete(outcome) + f:drain() + T.eq(f.after, 1, "actual trainer cleanup retained") + T.eq(f.exports.get("mod:after"), true, "native trainer outcome branch") + if f.gen == 3 then + T.eq(f.game.save.flags.FLAG_G3_0507 == true, outcome == "win", "native trainer flag tracks win only") + end + end +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("Portable script trainers") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 89d49cc64..250603752 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", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 1a09b6c7847a723805fd39e88ea0be17f5dc7795 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:18:55 +0200 Subject: [PATCH 27/42] Add portable native marts with owned transaction callbacks --- docs/api3-portability.md | 2 +- docs/portable-world-scripts.md | 26 ++ src/mods/PortableScripts.lua | 117 ++++++- src/mods/ScriptProgram.lua | 22 +- src/mods/ScriptShopUI.lua | 61 ++++ src/mods/ScriptShops.lua | 121 +++++++ src/mods/ScriptTravel.lua | 8 +- .../api3_contract/mods/script_shops/main.lua | 15 + .../mods/script_shops/manifest.json | 1 + tests/api3_contract/script_gifts.lua | 4 +- tests/api3_contract/script_shops.lua | 83 +++++ tests/api3_contract/worker.lua | 5 +- tests/engine/portable_script_shops.lua | 329 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 14 files changed, 784 insertions(+), 12 deletions(-) create mode 100644 src/mods/ScriptShopUI.lua create mode 100644 src/mods/ScriptShops.lua create mode 100644 tests/api3_contract/mods/script_shops/main.lua create mode 100644 tests/api3_contract/mods/script_shops/manifest.json create mode 100644 tests/api3_contract/script_shops.lua create mode 100644 tests/engine/portable_script_shops.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 5025399e6..55dbe5fee 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -135,7 +135,7 @@ 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 and eight NPC cases extend this to 96 comparisons -and 272 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +and 288 isolated loader runs (including world-action, flag and portable-script action cases). 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 diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index a00d1b543..e20c5d4d2 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -408,3 +408,29 @@ victory flag and Match Call registration helpers; loss does not mark a win. Cleanup remains valid after the originating mod retires, provided the same native session still exists. Native battle mechanics and game-specific story rewards remain the responsibility of this host's battle/field code. + +## Native marts + +`open_mart(textConstant)` opens the native mart selected by the active map's +`data:textEntry(map.label, textConstant).mart` entry. Entries hold 1–99 item +IDs; canonical names and known native item IDs resolve through this boot's +item registry. Every mart row, including a row after a warp, is validated +before any prefix runs. Missing data is refused rather than guessed from +unrelated ROM pointers. Live dispatch repeats content/store validation. + +The screen factory and native buy/sell/quantity/confirmation flows remain +in charge of transactions. The script waits until the root counter closes. +UI callbacks are bound to their exact active screen and phase: closed, +cancelled, superseded or retired quantity/confirmation callbacks cannot +replay a transaction. Foreign overlays block callbacks and continuation. +Nested owned screens are removed on cancellation while foreign UI remains. +A retired constructor cannot leave its late native root on the stack. + +Wallet/inventory shapes, counts and prices must be representable (whole +non-negative amounts; prices at most 999,999, inventory at most 4,096 +entries). Checked sale totals must fit the exact integer range. Replaced +inventories or changed item records/prices cancel an open shop before later +callbacks run. Committed native transactions remain committed after +cancellation. The same guard covers classic lists, FireRed's inline sale +quantity and Emerald's native sell bag. Closed UI records are released +between frames rather than accumulated for a long shopping session. diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index c6c43101d..a3b56aa46 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -5,6 +5,7 @@ local Runtime = require("src.mods.Runtime") local Flags = require("src.mods.Api3Flags") local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local Shops = require("src.mods.ScriptShops") local Battles = require("src.mods.ScriptBattles") local Travel = require("src.mods.ScriptTravel") local Movement = require("src.mods.ScriptMovement") @@ -145,6 +146,8 @@ local function observe(loader) 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") + elseif token.shop then + token.shop.refresh() elseif token.travel then token.travel.tick() end @@ -295,7 +298,7 @@ local function facade(loader, modId, native) if not battleOK then return nil, battleReason end - local travel, travelReason = Travel.prepare(game, ow, modId, program) + local travel, travelReason = Travel.prepare(game, ow, modId, program, loader) if not travel then return nil, travelReason end @@ -349,6 +352,7 @@ local function facade(loader, modId, native) or not token.travel and (ow.player.cellX ~= token.x or ow.player.cellY ~= token.y) and not (token.move and token.move.position()) or not token.travel and not token.mutating and ow.runner ~= runner or token.move and not token.move.live() + or token.shop and not token.shop.valid() or GameVersion.get() ~= token.version or Runtime.hooks ~= loader.hooks or Runtime.events ~= loader.events @@ -462,14 +466,25 @@ local function facade(loader, modId, native) return end local before = token.nativeStep == step and snapshot() + local previous, phase, priorBefore = token.executingUI, token.uiPhase, token.uiBefore + if token.shop and token.shop.step == step then + token.executingUI, token.uiPhase, token.uiBefore = self, self.asking, before + end local ok, err = engine(update, self, ...) if before then discover(before, step) end + if token.shop then + token.shop.refresh() + end + token.executingUI, token.uiPhase, token.uiBefore = previous, phase, priorBefore if not ok then token.finish(false, "native UI failed: " .. message(err)) end end + if token.shop then + token.shop.attach(state) + end return state end discover = function(before, step) @@ -479,7 +494,7 @@ local function facade(loader, modId, native) for _, state in ipairs(game.stack.states) do if not before[state] then owned(state, step) - if token.nativeStep == step then + if token.nativeStep == step and not token.shop then token.waitingUI = state end end @@ -602,6 +617,100 @@ local function facade(loader, modId, native) game.stack:push(menu) runner:yield() end + local function shop(ctx, row) + local stock, validate = Shops.plan(loader, game, ow, row) + if not stock then + error(validate, 0) + end + local step, complete, root = token.step, false, nil + local service = { step = step } + function service.valid() + if not complete and root and not contains(token.stack, root) and token.executingUI ~= root then + return false + end + return validate() + end + local invoke + service.attach = require("src.mods.ScriptShopUI").new({ + alive = function(state) + return not complete + and token.shop == service + and token.step == step + and check() + and ( + game.stack:top() == state + or token.executingUI == state + or token.executingUI + and token.ownedUI[token.executingUI] + and (contains(token.stack, state) or token.uiBefore and token.uiBefore[state]) + ) + end, + phase = function(state) + return token.executingUI == state and token.uiPhase or nil + end, + invoke = function(state, fn, ...) + return invoke(state, fn, ...) + end, + }) + function service.refresh() + if token.done then + return + end + if not token.executingUI then + for i = #token.ui, 1, -1 do + if not contains(token.stack, token.ui[i]) then + token.ownedUI[token.ui[i]] = nil + table.remove(token.ui, i) + end + end + end + for _, state in ipairs(token.ui) do + if contains(token.stack, state) then + service.attach(state) + end + end + end + invoke = function(state, fn, ...) + local previous, phase, priorBefore = token.executingUI, token.uiPhase, token.uiBefore + local before = snapshot() + token.executingUI, token.uiPhase, token.uiBefore = state, state.asking, before + local result = pack(engine(fn, ...)) + discover(before, step) + service.refresh() + token.executingUI, token.uiPhase, token.uiBefore = previous, phase, priorBefore + if not result[1] then + token.finish(false, "native shop callback failed: " .. message(result[2])) + return + end + return unpack(result, 2, result.n) + end + token.shop, token.nativeStep = service, step + local function done() + if not root or complete or token.step ~= step or not check() or contains(token.stack, root) then + return + end + complete = true + end + local called, state = engine(require("src.ui.Screens").push, game, "ShopMenu", stock, done) + if not called then + token.shop, token.nativeStep = nil, nil + error(state, 0) + end + root = state + if token.done then + remove(token.stack, root) + return + end + owned(root, step) + if not check() then + return + end + runner.waitingCheck = function() + return complete + end + runner:yield() + token.shop, token.nativeStep = nil, nil + end local function battle(ctx, row) local verb = row[1] if verb == "check_battle_result" then @@ -970,7 +1079,9 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if Battles.ARITY[verb] then + if Shops.ARITY[verb] then + shop(ctx, row) + elseif Battles.ARITY[verb] then battle(ctx, row) elseif Travel.ARITY[verb] then warp(ctx, row) diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index 7b859ce02..89b6c948b 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -2,6 +2,7 @@ local Program = {} local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local Shops = require("src.mods.ScriptShops") local Battles = require("src.mods.ScriptBattles") local Travel = require("src.mods.ScriptTravel") local Movement = require("src.mods.ScriptMovement") @@ -40,14 +41,22 @@ end for verb, arity in pairs(Battles.ARITY) do ARITY[verb] = arity end +for verb, arity in pairs(Shops.ARITY) do + ARITY[verb] = arity +end local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) if type(verb) ~= "string" then return false end if - (WorldEdits.ARITY[verb] or Movement.ARITY[verb] or Travel.ARITY[verb] or Battles.ARITY[verb]) - and require("src.core.GameVersion").generation() > 3 + ( + WorldEdits.ARITY[verb] + or Movement.ARITY[verb] + or Travel.ARITY[verb] + or Battles.ARITY[verb] + or Shops.ARITY[verb] + ) and require("src.core.GameVersion").generation() > 3 then return false end @@ -133,12 +142,19 @@ function Program.compile(raw, context) and not Movement.ARITY[verb] and not Travel.ARITY[verb] and not Battles.ARITY[verb] + and not Shops.ARITY[verb] and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if Battles.ARITY[verb] then + if Shops.ARITY[verb] then + local value, reason = Shops.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif Battles.ARITY[verb] then local value, reason = Battles.syntax(verb, source, context) if not value then return fail(i, reason) diff --git a/src/mods/ScriptShopUI.lua b/src/mods/ScriptShopUI.lua new file mode 100644 index 000000000..5ed099c32 --- /dev/null +++ b/src/mods/ScriptShopUI.lua @@ -0,0 +1,61 @@ +-- Callback leases for the native mart's menu, quantity and confirmation phases. +-- Only explicit UI fields are visited, never a state's Game/save/data graph. +local U = {} +local methods = { "select", "choose", "cancel", "close", "buy", "sell", "askQuantity", "askQuantityInput" } +local callbacks = + { onDone = true, onChoose = true, onCancel = true, onQuit = true, onSelect = true, choice = true } +function U.new(control) + local wrapped = setmetatable({}, { __mode = "k" }) + local function bind(state, holder, key, once, phase) + local original = holder[key] + if type(original) ~= "function" or wrapped[original] then + return + end + local fired = false + local guarded + guarded = function(...) + if fired or holder[key] ~= guarded or not control.alive(state) or phase and not phase() then + return + end + if once then + fired = true + end + return control.invoke(state, original, ...) + end + holder[key] = guarded + wrapped[guarded] = true + end + local function attach(state) + for key in pairs(callbacks) do + if rawget(state, key) ~= nil then + local once = key == "onDone" + or key == "choice" + or key == "onChoose" and getmetatable(state) == require("src.ui.ChoiceBox") + bind(state, state, key, once) + end + end + for _, key in ipairs(methods) do + bind(state, state, key, false) + end + for _, item in ipairs(state.items or {}) do + if type(item) == "table" then + bind(state, item, "onSelect", false, function() + for _, current in ipairs(state.items or {}) do + if current == item then + return true + end + end + return false + end) + end + end + local asking = state.asking + if type(asking) == "table" then + bind(state, asking, "onDone", true, function() + return state.asking == asking or control.phase(state) == asking + end) + end + end + return attach +end +return U diff --git a/src/mods/ScriptShops.lua b/src/mods/ScriptShops.lua new file mode 100644 index 000000000..f026c2cae --- /dev/null +++ b/src/mods/ScriptShops.lua @@ -0,0 +1,121 @@ +-- Native mart content and save validation; no live UI is created by preflight. +local Ids = require("src.mods.UnifiedIds") +local Version = require("src.core.GameVersion") +local S = { ARITY = { open_mart = 2 } } +local function integer(n, max) + return type(n) == "number" and n == n and n >= 0 and n <= max and n % 1 == 0 +end +function S.syntax(_, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable shops unavailable for this generation" + end + local ref = row[2] + if + #row ~= 2 + or not (type(ref) == "string" and #ref > 0 and #ref <= 256 and not ref:find("%c") or integer(ref, 65535)) + then + return nil, "invalid mart text constant" + end + return { row[1], ref } +end +local function stores(game) + local save = game.save + if + not integer(save.money, 9007199254740991) + or type(save.inventory) ~= "table" + or save.bagOrder ~= nil and type(save.bagOrder) ~= "table" + then + return nil, "invalid shop wallet/inventory" + end + local total, room = 0, 9007199254740991 - save.money + for id, count in pairs(save.inventory) do + total = total + 1 + if total > 4096 then + return nil, "shop inventory too large" + end + if not integer(count, 999999) then + return nil, "invalid inventory count" + end + local item = game.data.items and game.data.items[id] + if + item + and not item.keyItem + and not require("src.inventory.ItemEffects").alias(id, item):find("^HM_") + then + if not integer(item.price, 999999) then + return nil, "invalid inventory item price" + end + local value = math.floor(item.price / 2) * count + if value > room then + return nil, "shop money overflow" + end + room = room - value + end + end + return true +end +function S.plan(loader, game, world, row) + local valid, why = stores(game) + if not valid then + return nil, why + end + local def = world.map and world.map.def + if not (def and type(def.label) == "string" and type(game.data.textEntry) == "function") then + return nil, "native mart content unavailable" + end + local entry = game.data:textEntry(def.label, row[2]) + local list = entry and entry.mart + if type(list) ~= "table" or #list == 0 or #list > 99 then + return nil, "native mart content unavailable: " .. tostring(row[2]) + end + for k in pairs(list) do + if not integer(k, #list) or k == 0 then + return nil, "invalid mart stock" + end + end + local ids = Ids.new(loader) + local stock, records = {}, {} + for i = 1, #list do + local public = ids:read("items", list[i]) + local group, reason = ids:resolve("items", public) + local item = group and game.data.items and game.data.items[group.key] + if not item then + return nil, reason or "unknown mart item" + end + if not integer(item.price, 999999) or type(item.name) ~= "string" then + return nil, "invalid mart item price/name" + end + stock[i] = group.key + records[group.key] = { record = item, price = item.price } + end + local inventory = game.save.inventory + for id in pairs(inventory) do + local item = game.data.items[id] + if item then + records[id] = { record = item, price = item.price } + end + end + return stock, + function() + local ok, reason = stores(game) + if not ok then + return nil, reason + end + if game.save.inventory ~= inventory then + return nil, "shop inventory changed" + end + for id in pairs(inventory) do + local item = game.data.items[id] + if item and not records[id] then + records[id] = { record = item, price = item.price } + end + end + for id, expected in pairs(records) do + if game.data.items[id] ~= expected.record or expected.record.price ~= expected.price then + return nil, "shop item data changed" + end + end + return true + end +end +return S diff --git a/src/mods/ScriptTravel.lua b/src/mods/ScriptTravel.lua index 31f56630c..5a6ec345b 100644 --- a/src/mods/ScriptTravel.lua +++ b/src/mods/ScriptTravel.lua @@ -1,4 +1,5 @@ -- Destination preflight, including later world rows against destination data. +local Shops = require("src.mods.ScriptShops") local Warp = require("src.mods.Api3Warp") local Movement = require("src.mods.ScriptMovement") local Edits = require("src.mods.ScriptWorldEdits") @@ -52,7 +53,7 @@ local function future(game, world, plan, cache) } return proxy, view end -function T.prepare(game, world, owner, rows) +function T.prepare(game, world, owner, rows, loader) local gen = Version.generation() local model, modelGame = world, game local cache = {} @@ -69,6 +70,11 @@ function T.prepare(game, world, owner, rows) end modelGame, model = future(game, world, plan, cache) end + elseif Shops.ARITY[verb] then + local stock, why = Shops.plan(loader, modelGame, model, row) + if not stock then + return nil, "row " .. i .. ": " .. why + end elseif Edits.ARITY[verb] then local edit, why = Edits.prepare(modelGame, model, owner, { row }) if not edit then diff --git a/tests/api3_contract/mods/script_shops/main.lua b/tests/api3_contract/mods/script_shops/main.lua new file mode 100644 index 000000000..2d21c725b --- /dev/null +++ b/tests/api3_contract/mods/script_shops/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success, reason = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok, why) + done, success, reason = done + 1, ok, why + end, + }) +end +mod.exports.result = function() + return { done = done, success = success, reason = reason } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_shops/manifest.json b/tests/api3_contract/mods/script_shops/manifest.json new file mode 100644 index 000000000..d53ea01c5 --- /dev/null +++ b/tests/api3_contract/mods/script_shops/manifest.json @@ -0,0 +1 @@ +{"id":"script_shops","name":"Portable shops","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/script_gifts.lua b/tests/api3_contract/script_gifts.lua index 9aee71bb0..9545bb462 100644 --- a/tests/api3_contract/script_gifts.lua +++ b/tests/api3_contract/script_gifts.lua @@ -1,7 +1,7 @@ local M = {} -function M.fixture(root, backend, version) +function M.fixture(root, backend, version, modId) local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_gifts%.lua$") - local f = dofile(codeRoot .. "/script_state.lua").fixture(root, backend, version, "script_gifts") + local f = dofile(codeRoot .. "/script_state.lua").fixture(root, backend, version, modId or "script_gifts") love = love or require("tests.love_stub") local data, save = f.game.data, f.game.save data.text = data.text or {} diff --git a/tests/api3_contract/script_shops.lua b/tests/api3_contract/script_shops.lua new file mode 100644 index 000000000..adde51e2a --- /dev/null +++ b/tests/api3_contract/script_shops.lua @@ -0,0 +1,83 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_shops%.lua$") + local f = dofile(codeRoot .. "/script_gifts.lua").fixture(root, backend, version, "script_shops") + local data = f.game.data + f.ow.game, f.ow.text = f.game, data.text + f.ow.map.def.label = "SHOP_HOME" + data.text_pointers = { SHOP_HOME = { SHOP = { mart = { "POTION" } } } } + if data.items and data.items.POTION then + data.items.POTION.price = 100 + end + if backend == "gen1" and f.gen == 3 then + require("src.core.game3.scripting.space").ensureBundle = function() + local IR = require("src.core.game3.scripting.text_ir") + return { + text = { + Text_MayIHelpYou = IR.fromAscii("WELCOME"), + gText_HowMayIServeYou = IR.fromAscii("WELCOME"), + }, + } + end + end + if backend == "gen1" and f.gen == 2 then + f.ow.openMart = require("src.world.gen2.World").openMart + end + function f:tick(key) + self.game.input.wasPressed = function(_, button) + return button == key + end + self.loader.hooks:call(backend == "gen1" and "core.update" or "input.step", function() + if backend == "gen2" then + self.ow.runner:update() + end + end, self.game, 1 / 60) + self.game.stack:update(1 / 60) + self.game.input.wasPressed = function() + return false + end + end + function f:close() + if backend == "gen1" and f.gen == 3 then + require("src.ui.game3.shop_menu").close() + else + for _ = 1, 2000 do + if self.exports.result().done > 0 then + return + end + self:tick("b") + end + error("native shop did not close") + end + end + function f:drain() + for _ = 1, 100 do + if self.exports.result().done > 0 then + return + end + self:tick() + end + error("shop script did not finish") + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local ok, why = f.exports.queue({ { "open_mart", "SHOP" }, { "set_flag", "mod:after" } }) + assert(ok, why) + assert( + f.exports.result().done == 0 and not f.exports.get("mod:after"), + require("src.link.Json").encode(f.exports.result()) .. " " .. table.concat(f.loader.errors, "; ") + ) + f:close() + f:drain() + assert(f.exports.result().success and f.exports.get("mod:after")) + local report = { complete = true, quantity = f.quantity() } + f = M.fixture(root, backend, version) + local value, reason = f.exports.queue({ { "set_flag", "mod:prefix" }, { "open_mart", "UNKNOWN" } }) + assert(value == nil and type(reason) == "string" and not f.exports.get("mod:prefix")) + report.preflight = true + 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 947f910c3..ed7ebf365 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -24,11 +24,14 @@ assert( or mode == "script_travel" or mode == "script_battles" or mode == "script_trainers" + or mode == "script_shops" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_trainers" then +if mode == "script_shops" then + dofile(codeRoot .. "/script_shops.lua").run(root, backend, version) +elseif mode == "script_trainers" then dofile(codeRoot .. "/script_trainers.lua").run(root, backend, version) elseif mode == "script_battles" then dofile(codeRoot .. "/script_battles.lua").run(root, backend, version) diff --git a/tests/engine/portable_script_shops.lua b/tests/engine/portable_script_shops.lua new file mode 100644 index 000000000..b900ac25f --- /dev/null +++ b/tests/engine/portable_script_shops.lua @@ -0,0 +1,329 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_shops") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v, native) + local f = Contract.fixture("tests/api3_contract", "gen2", v or "red") + f.game.save.money = 1000 + if native then + f.game.data.constants = f.game.data.constants or {} + f.game.data.constants.gen3MartText = { buy = "BUY", sell = "SELL", quit = "QUIT" } + end + return f +end +local function queue(f) + return f.exports.queue({ { "open_mart", "SHOP" }, { "set_flag", "mod:after" } }) +end +local function finishChoice(f) + f:tick("a") + for _ = 1, 20 do + f:tick() + end +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared shop contract: " .. tostring(why)) +end +for _, change in ipairs({ + "unknown", + "invalid_price", + "unknown_item", + "empty", + "sparse", + "wallet", + "inventory", + "overflow", +}) do + local f = fixture() + local ref = "SHOP" + if change == "unknown" then + ref = "UNKNOWN" + elseif change == "invalid_price" then + f.game.data.items.POTION.price = -1 + elseif change == "unknown_item" then + f.game.data.text_pointers.SHOP_HOME.SHOP.mart = { "UNKNOWN" } + elseif change == "empty" then + f.game.data.text_pointers.SHOP_HOME.SHOP.mart = {} + elseif change == "sparse" then + f.game.data.text_pointers.SHOP_HOME.SHOP.mart[3] = "POTION" + elseif change == "wallet" then + f.game.save.money = false + elseif change == "inventory" then + f.game.save.inventory.POTION = -1 + else + f.game.save.money = 9007199254740991 + end + T.eq( + f.exports.queue({ { "set_flag", "mod:prefix" }, { "open_mart", ref } }), + nil, + "invalid shop preflight: " .. change + ) + T.eq(f.exports.get("mod:prefix"), false, "invalid suffix prevents prefix") + T.eq(f.game.stack:top(), f.ow, "preflight opens no native UI") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local f = fixture(v) + T.check(queue(f), v .. " native classic shop admitted") + local root = f.game.stack:top() + f:tick("a") + local list = f.game.stack:top() + T.eq(getmetatable(list), require("src.ui.ListMenu"), "native buy list opened") + f:tick("a") + local quantity = f.game.stack:top() + local oldQuantity = quantity.onDone + T.eq(getmetatable(quantity), require("src.ui.QuantityBox"), "native quantity picker opened") + f:tick("a") + local choice = f.game.stack:top() + local oldChoice = choice.onChoose + finishChoice(f) + T.eq(f.quantity(), 4, "native bag receives purchase") + T.eq(f.game.save.money, 900, "native wallet charged once") + oldQuantity(99) + oldChoice(true) + T.eq(f.quantity(), 4, "old quantity/confirm callbacks cannot replay purchase") + T.eq(f.game.save.money, 900, "old callbacks cannot charge again") + T.eq(f.game.stack:top(), list, "nested callbacks retain native list") + f:close() + f:drain() + T.eq(f.exports.result().success, true, "shop quit resumes script") + T.eq(f.exports.get("mod:after"), true, "script runs after native close") + root.onCancel() + oldQuantity(1) + oldChoice(true) + T.eq(f.game.stack:top(), f.ow, "finished UI cannot reopen a child") +end +for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + local f = fixture(v) + T.check(queue(f), "native sale admitted") + f.game.stack:top().index = 2 + f:tick("a") + f:tick("a") + local quantity = f.game.stack:top() + quantity.qty = 3 + f:tick("a") + finishChoice(f) + T.eq(f.quantity(), 0, "native sale removes full item stack") + T.eq(f.game.save.money, 1150, "native sale credits half price") + T.eq(#f.game.save.bagOrder, 0, "native sale removes stale bag order entry") + f:close() + f:drain() + T.eq(f.exports.result().success, true, "native sale closes normally") +end +for _, change in ipairs({ "rollback", "save", "data", "price", "inventory", "removed" }) do + local f = fixture() + T.check(queue(f), "shop before live change") + f:tick("a") + f:tick("a") + local quantity = f.game.stack:top() + local callback = quantity.onDone + local overlay = {} + f.game.stack:push(overlay) + if change == "rollback" then + f.loader:_rollback("script_shops") + elseif change == "price" then + f.game.data.items.POTION.price = 200 + elseif change == "inventory" then + f.game.save.inventory = { POTION = 3 } + elseif change == "removed" then + table.remove(f.game.stack.states, 2) + else + f.game[change] = {} + end + f.loader.hooks:call("input.step", function() end, f.game, 1 / 60) + T.eq(f.exports.result().success, false, "live context/item change cancels shop") + T.eq(f.game.stack:top(), overlay, "foreign overlay survives shop cancellation") + T.eq(#f.game.stack.states, 2, "only owned nested screens removed") + callback(1) + T.eq(#f.game.stack.states, 2, "stale quantity cannot open a confirmation") +end +for _, v in ipairs({ "firered", "emerald" }) do + local f = fixture(v, true) + T.check(queue(f), v .. " native Gen3 counter admitted") + local root = f.game.stack:top() + f:tick("a") + local counter = f.game.stack:top() + T.eq(getmetatable(counter), require("src.ui.Gen3ShopMenu"), "Gen3 buy counter selected") + f:tick("a") + local asking = counter.asking + local oldQuantity = asking and asking.onDone + T.check(asking, "native Gen3 inline quantity opened") + f:tick("a") + local box = f.game.stack:top() + local oldChoice = box.choice + for _ = 1, 2000 do + f:tick("a") + if f.quantity() == 4 then + break + end + end + T.eq(f.quantity(), 4, "native Gen3 confirmation purchases item") + T.eq(f.game.save.money, 900, "native Gen3 wallet charged once") + oldQuantity(5) + oldChoice(true) + T.eq(f.quantity(), 4, "old Gen3 inline callbacks cannot replay") + T.eq(f.game.save.money, 900, "old Gen3 callbacks cannot charge again") + f:close() + f:drain() + T.eq(f.exports.result().success, true, "native Gen3 counter closes") +end +-- A retained confirmation is inert under a foreign overlay, then the native +-- answer still works after that overlay closes. +do + local f = fixture() + T.check(queue(f), "shop before foreign confirmation overlay") + f:tick("a") + f:tick("a") + f:tick("a") + local choice = f.game.stack:top() + local callback = choice.onChoose + local overlay = {} + f.game.stack:push(overlay) + callback(true) + T.eq(f.quantity(), 3, "foreign overlay blocks direct confirmation") + T.eq(f.game.save.money, 1000, "foreign overlay blocks debit") + f.game.stack:pop() + finishChoice(f) + T.eq(f.quantity(), 4, "legitimate answer still works after overlay") + f:close() + f:drain() +end +for _, v in ipairs({ "firered", "emerald" }) do + local f = fixture(v, true) + T.check(queue(f), "Gen3 quantity before cancellation") + f:tick("a") + f:tick("a") + local counter = f.game.stack:top() + local old = counter.asking.onDone + f:tick("b") + old(1) + T.eq(f.game.stack:top(), counter, "cancelled inline quantity cannot create confirmation") + T.eq(f.quantity(), 3, "cancelled inline quantity cannot give item") + f:tick("a") + local current = counter.asking + old(1) + T.eq(counter.asking, current, "old inline callback cannot replace a new request") + f:close() + f:drain() +end +for _, v in ipairs({ "firered", "emerald" }) do + local f = fixture(v, true) + T.check(queue(f), "Gen3 native sale admitted") + f.game.stack:top().index = 2 + f:tick("a") + f:tick("a") + local quantity = f.game.stack:top() + if v == "firered" then + T.eq(getmetatable(quantity), require("src.ui.Gen3ShopMenu"), "FireRed native inline sale quantity") + T.check(quantity.asking, "FireRed sale quantity is active") + quantity.asking.qty = 2 + else + T.eq(getmetatable(quantity), require("src.ui.QuantityBox"), "Emerald native bag sale quantity") + quantity.qty = 2 + end + f:tick("a") + for _ = 1, 2000 do + f:tick("a") + if f.quantity() == 1 then + break + end + end + T.eq(f.quantity(), 1, "Gen3 native sale removes selected quantity") + T.eq(f.game.save.money, 1100, "Gen3 native sale credits wallet") + f:close() + f:drain() + T.eq(f.exports.result().success, true, "Gen3 native sale returns to script") +end +for _, phase in ipairs({ "classic_quantity", "classic_choice", "gen3_quantity", "gen3_choice" }) do + local native = phase:find("gen3", 1, true) ~= nil + local f = fixture(native and "emerald" or "red", native) + T.check(queue(f), "native shop before phase unload") + f:tick("a") + f:tick("a") + local callbacks = {} + if native then + callbacks[#callbacks + 1] = f.game.stack:top().asking.onDone + else + callbacks[#callbacks + 1] = f.game.stack:top().onDone + end + if phase:find("choice", 1, true) then + f:tick("a") + local state = f.game.stack:top() + callbacks[#callbacks + 1] = native and state.choice or state.onChoose + end + local overlay = {} + f.game.stack:push(overlay) + f.loader:_rollback("script_shops") + for _, fn in ipairs(callbacks) do + fn(1) + fn(true) + end + T.eq(f.game.stack:top(), overlay, "unload keeps foreign overlay") + T.eq(#f.game.stack.states, 2, "unload removes exact native shop screens") + T.eq(f.quantity(), 3, "retired purchase callback cannot grant item") + T.eq(f.game.save.money, 1000, "retired purchase callback cannot debit wallet") +end +for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + local f = fixture(v) + f.game.data.maps[f.dest].label = "SHOP_DEST" + f.game.data.text_pointers.SHOP_DEST = { DEST_SHOP = { mart = { "POTION" } } } + T.check( + f.exports.queue({ { "warp", f.dest, 1, 1 }, { "open_mart", "DEST_SHOP" }, { "set_flag", "mod:after" } }), + "destination shop preflight" + ) + f:arrive() + f:complete() + f:tick() + T.check(f.game.stack:top() ~= f.ow, "destination native shop opens after arrival") + f:close() + f:drain() + T.eq(f.exports.result().success, true, "destination native shop resumes program") + f = fixture(v) + f.game.data.maps[f.dest].label = "SHOP_DEST" + f.game.data.text_pointers.SHOP_DEST = { DEST_SHOP = { mart = { "UNKNOWN" } } } + T.eq( + f.exports.queue({ { "warp", f.dest, 1, 1 }, { "open_mart", "DEST_SHOP" } }), + nil, + "invalid destination stock refuses whole script" + ) + T.eq(#f.trace, 0, "invalid future stock prevents warp") +end +for _, change in ipairs({ "wallet", "inventory", "stock" }) do + local f = fixture() + T.check(f.exports.queue({ { "wait", 1 }, { "open_mart", "SHOP" } }), "deferred shop admitted") + if change == "wallet" then + f.game.save.money = false + elseif change == "inventory" then + f.game.save.inventory = false + else + f.game.data.items.POTION.price = -1 + end + f:drain() + T.eq(f.exports.result().success, false, "live shop stores revalidated") + T.eq(f.game.stack:top(), f.ow, "bad live data opens no native shop") +end +do + local f = fixture() + local overlay = {} + local Screens = require("src.ui.Screens") + f.game.data.screens = { + ShopMenu = { + new = function(game, stock, onQuit) + f.loader:_rollback("script_shops") + game.stack:push(overlay) + return require("src.ui.ShopMenu").new(game, stock, onQuit) + end, + }, + } + Screens.invalidate() + T.check(queue(f), "shop admitted before constructor hook retires owner") + T.eq(f.exports.result().success, false, "constructor retirement cancels program") + T.eq(f.game.stack:top(), overlay, "constructor retirement preserves foreign overlay") + T.eq(#f.game.stack.states, 2, "late native root removed after constructor returns") + Screens.invalidate() +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("Portable script shops") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 250603752..bf9cbdf38 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", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 1fa9d59d90fbb6391a4a6cf994de56c016f40436 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:38 +0200 Subject: [PATCH 28/42] Add validated portable sound and music script commands --- docs/api3-portability.md | 2 +- docs/portable-world-scripts.md | 27 ++ src/core/Sound.lua | 32 +- src/mods/PortableScripts.lua | 32 +- src/mods/ScriptAudio.lua | 152 +++++++++ src/mods/ScriptProgram.lua | 14 +- .../api3_contract/mods/script_audio/main.lua | 15 + .../mods/script_audio/manifest.json | 1 + tests/api3_contract/script_audio.lua | 154 ++++++++++ tests/api3_contract/worker.lua | 5 +- tests/engine/portable_script_audio.lua | 288 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 12 files changed, 713 insertions(+), 11 deletions(-) create mode 100644 src/mods/ScriptAudio.lua create mode 100644 tests/api3_contract/mods/script_audio/main.lua create mode 100644 tests/api3_contract/mods/script_audio/manifest.json create mode 100644 tests/api3_contract/script_audio.lua create mode 100644 tests/engine/portable_script_audio.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 55dbe5fee..4e2861f8b 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -135,7 +135,7 @@ 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 and eight NPC cases extend this to 96 comparisons -and 288 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +and 304 isolated loader runs (including world-action, flag and portable-script action cases). 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 diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index e20c5d4d2..6a26982f7 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -434,3 +434,30 @@ callbacks run. Committed native transactions remain committed after cancellation. The same guard covers classic lists, FireRed's inline sale quantity and Emerald's native sell bag. Closed UI records are released between frames rather than accumulated for a long shopping session. + +## Sound and music + +Gen1–3 accept `play_sound(id)`, `wait_sound()`, +`play_music(id, {keep=boolean, reason=string}?)`, `stop_music()`, +`fade_music(control?)`, `play_once(id)` and `play_default_music()`. +The active game's native audio definitions must contain the cue. All rows +are checked before a prefix runs and checked again at dispatch. Gen3 +numeric and extracted `musName`/`sfxName` aliases resolve to an unambiguous +native sequence; unknown aliases are refused. Gen3 `play_music(0)` retains +the native stop convention. `play_once("Get_Item1")` can resolve the native +Gen3 obtain-item fanfare role. + +Playback uses the existing Sound/Music controllers. `wait_sound` waits for +native SFX activity and its last returned source, with the reference's +600-foreground-frame release ceiling. A one-shot waits on native restore +state, releases if another song takes over, and fails after 3,600 foreground +frames if it stalls. Foreign overlays pause continuation. Music `keep` is +armed only while the script still owns the context. Fade control is a whole +number 0–255 (default 10); timing follows this host's native fade controller. + +Names/numbers remain game-specific unless the mod supplies a shared cue. +The reference Gen3 resolver can return an unknown string unchanged; this +host requires a real definition before admission. Missing audio data is +therefore a preflight failure; a muted/unavailable device keeps the native +headless playback behavior. Already started audio remains engine-owned on +script cancellation. Dialogue-bound cries/fanfares are a separate slice. diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 7093a32f5..a6e84df7e 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -264,14 +264,34 @@ end -- PlaySoundWaitForCurrent -> WaitForSoundToFinish can poll it -- Resolve a cartridge effect by its extracted native name, including effects -- that have no shared engine role in audio.sfx. -function Sound.playNamedEffect(data,name) - for id,song in pairs(data and data.audio and data.audio.songs or {}) do - if type(song)=="table" and song.sfxName==name and tonumber(song.index) then - local src=playPath(data,id,{song=id,m4a=tonumber(song.index)}) - if src then played("sfx",name) end - return src +-- Native numeric/symbolic sequence aliases must resolve unambiguously. +function Sound.sequenceKey(data, name) + if type(name) ~= "string" and type(name) ~= "number" then return nil end + local songs = data and data.audio and data.audio.songs or {} + if (type(name) == "string" or type(name) == "number") and (type(songs[name]) == "string" or type(songs[name]) == "table") then + return name, songs[name] + end + local key, found + for id, song in pairs(songs) do + if type(song) == "table" and (song.sfxName == name or song.musName == name + or type(name) == "number" and song.index == name) then + if found and found ~= song then return nil, "ambiguous native audio id" end + key, found = id, song end end + return key, found +end + +function Sound.playNamedEffect(data, name) + local id, song = Sound.sequenceKey(data, name) + if not id then return nil end + local def = song + if type(song) == "table" and tonumber(song.index) and not song.file then + def = { song = id, m4a = tonumber(song.index) } + end + local src = playPath(data, id, def) + if src then played("sfx", name) end + return src end function Sound.play(data, name) diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index a3b56aa46..9fca0ef2d 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -5,6 +5,7 @@ local Runtime = require("src.mods.Runtime") local Flags = require("src.mods.Api3Flags") local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local Audio = require("src.mods.ScriptAudio") local Shops = require("src.mods.ScriptShops") local Battles = require("src.mods.ScriptBattles") local Travel = require("src.mods.ScriptTravel") @@ -286,6 +287,10 @@ local function facade(loader, modId, native) end end end + local audio, audioReason = Audio.prepare(game, program) + if not audio then + return nil, audioReason + end local state, reason = State.prepare(loader, game, ow, program) if not state then return nil, reason @@ -1056,6 +1061,29 @@ local function facade(loader, modId, native) end end end + local function waitAudio(poll, ceiling, continueOnTimeout) + if poll() then + return + end + local remaining = ceiling + runner.waitingCheck = function() + if not check() then + return false + end + if poll() then + return true + end + remaining = remaining - 1 + if remaining <= 0 then + if continueOnTimeout then + return true + end + token.finish(false, "native audio timed out") + end + return false + end + runner:yield() + end function runner:exec(compiled, ctx) local pc, slice = 1, 0 while pc <= #compiled do @@ -1079,7 +1107,9 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if Shops.ARITY[verb] then + if Audio.ARITY[verb] then + audio(ctx, row, waitAudio, check) + elseif Shops.ARITY[verb] then shop(ctx, row) elseif Battles.ARITY[verb] then battle(ctx, row) diff --git a/src/mods/ScriptAudio.lua b/src/mods/ScriptAudio.lua new file mode 100644 index 000000000..b02767bfd --- /dev/null +++ b/src/mods/ScriptAudio.lua @@ -0,0 +1,152 @@ +-- Portable native audio cues, with pure data lookup and bounded waits. +local Version = require("src.core.GameVersion") +local A = { + ARITY = { + play_sound = 2, + wait_sound = 1, + play_music = 3, + stop_music = 1, + fade_music = 2, + play_once = 2, + play_default_music = 1, + }, +} +local function integer(v, max) + return type(v) == "number" and v == v and v >= 0 and v <= max and v % 1 == 0 +end +local function id(v) + return type(v) == "string" and #v > 0 and #v <= 256 and not v:find("%c") or integer(v, 65535) +end +function A.syntax(verb, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable audio unavailable for this generation" + end + local minimum = (verb == "play_music" and 2 or verb == "fade_music" and 1) or A.ARITY[verb] + if #row < minimum or #row > A.ARITY[verb] then + return nil, "invalid command arity" + end + local out = { row[1], row[2] } + if verb == "fade_music" then + if row[2] ~= nil and not integer(row[2], 255) then + return nil, "invalid music fade" + end + elseif verb == "play_sound" or verb == "play_music" or verb == "play_once" then + if not id(row[2]) then + return nil, "invalid audio id" + end + end + if verb == "play_music" and row[3] ~= nil then + if type(row[3]) ~= "table" or getmetatable(row[3]) ~= nil then + return nil, "invalid music options" + end + out[3] = {} + for k, v in pairs(row[3]) do + if + not ( + k == "keep" and type(v) == "boolean" + or k == "reason" and type(v) == "string" and #v <= 256 and not v:find("%c") + ) + then + return nil, "invalid music option" + end + out[3][k] = v + end + end + return out +end +function A.resolve(game, row) + local verb, ref = row[1], row[2] + if Version.generation() == 3 and verb == "play_music" and ref == 0 then + return { stop = true } + end + local audio = game.data.audio or {} + local sound = verb == "play_sound" + local rows = sound and audio.sfx or audio.songs + if rows and (type(rows[ref]) == "table" or type(rows[ref]) == "string") then + return { key = ref, sound = sound } + end + if Version.generation() == 3 and (sound or verb == "play_music" or verb == "play_once") then + if verb == "play_once" and ref == "Get_Item1" then + local def = audio.sfx and audio.sfx.Get_Item1 + if type(def) == "table" then + ref = def.song or def.m4a or ref + end + end + local key, def = require("src.core.Sound").sequenceKey(game.data, ref) + if key then + return { key = key, sound = sound, sequence = sound } + end + if def then + return nil, def + end + end + return nil, "native audio unavailable: " .. tostring(row[2]) +end +function A.prepare(game, rows) + for i, row in ipairs(rows) do + if row[1] == "play_sound" or row[1] == "play_music" or row[1] == "play_once" then + local plan, why = A.resolve(game, row) + if not plan then + return nil, "row " .. i .. ": " .. why + end + end + end + return function(ctx, row, wait, alive) + local verb = row[1] + local Sound, Music = require("src.core.Sound"), require("src.core.Music") + if verb == "stop_music" then + Music.stop() + elseif verb == "fade_music" then + Music.fadeOut(row[2] or 10) + elseif verb == "play_default_music" then + local ow = ctx.overworld + Music.playMap(game.data, ow.map.id, game.save.onBike, ow.player.surfing) + elseif verb == "wait_sound" then + local source = ctx.lastSfxSrc + ctx.lastSfxSrc = nil + local function busy() + if Sound.anyPlaying("sfx") then + return true + end + if not source then + return false + end + local ok, value = pcall(function() + return source:isPlaying() + end) + return ok and value == true + end + if busy() then + wait(function() + return not busy() + end, 600, true) + end + else + local plan, why = A.resolve(game, row) + if not plan then + error(why, 0) + end + if verb == "play_sound" then + if plan.sequence then + ctx.lastSfxSrc = Sound.playNamedEffect(game.data, plan.key) + else + ctx.lastSfxSrc = Sound.play(game.data, plan.key) + end + elseif verb == "play_music" then + if plan.stop then + Music.stop() + return + end + Music.play(game.data, plan.key, nil, row[3]) + if alive() and row[3] and row[3].keep then + ctx.overworld.keepMusicOnce = true + end + elseif Music.playOnce(game.data, plan.key) then + wait(function() + return Music.current() ~= plan.key or not Music.oneShotPlaying() + end, 3600, false) + end + end + end +end +return A diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index 89b6c948b..1e6b6238c 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -2,6 +2,7 @@ local Program = {} local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") +local Audio = require("src.mods.ScriptAudio") local Shops = require("src.mods.ScriptShops") local Battles = require("src.mods.ScriptBattles") local Travel = require("src.mods.ScriptTravel") @@ -44,6 +45,9 @@ end for verb, arity in pairs(Shops.ARITY) do ARITY[verb] = arity end +for verb, arity in pairs(Audio.ARITY) do + ARITY[verb] = arity +end local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) if type(verb) ~= "string" then @@ -56,6 +60,7 @@ function Program.supports(verb) or Travel.ARITY[verb] or Battles.ARITY[verb] or Shops.ARITY[verb] + or Audio.ARITY[verb] ) and require("src.core.GameVersion").generation() > 3 then return false @@ -143,12 +148,19 @@ function Program.compile(raw, context) and not Travel.ARITY[verb] and not Battles.ARITY[verb] and not Shops.ARITY[verb] + and not Audio.ARITY[verb] and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if Shops.ARITY[verb] then + if Audio.ARITY[verb] then + local value, reason = Audio.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif Shops.ARITY[verb] then local value, reason = Shops.syntax(verb, source, context) if not value then return fail(i, reason) diff --git a/tests/api3_contract/mods/script_audio/main.lua b/tests/api3_contract/mods/script_audio/main.lua new file mode 100644 index 000000000..2d21c725b --- /dev/null +++ b/tests/api3_contract/mods/script_audio/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success, reason = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok, why) + done, success, reason = done + 1, ok, why + end, + }) +end +mod.exports.result = function() + return { done = done, success = success, reason = reason } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_audio/manifest.json b/tests/api3_contract/mods/script_audio/manifest.json new file mode 100644 index 000000000..c4f8be8ba --- /dev/null +++ b/tests/api3_contract/mods/script_audio/manifest.json @@ -0,0 +1 @@ +{"id":"script_audio","name":"Portable audio","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/script_audio.lua b/tests/api3_contract/script_audio.lua new file mode 100644 index 000000000..1f957bd0f --- /dev/null +++ b/tests/api3_contract/script_audio.lua @@ -0,0 +1,154 @@ +-- Real loaders/adapters; an explicit source/clock seam avoids audio-device timing. +local M = {} +function M.fixture(root, backend, version, modId) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_audio%.lua$") + local f = dofile(codeRoot .. "/script_flags.lua").fixture(root, backend, version, modId or "script_audio") + f.game.data.audio = { + songs = { TUNE = { file = "tune.wav" }, JINGLE = { file = "jingle.wav" } }, + sfx = { CHIME = { file = "chime.wav" } }, + mapSongs = { [f.home] = "TUNE" }, + } + f.audioTrace = {} + f.source = { playing = false } + function f.source:isPlaying() + return self.playing + end + local Sound, Music = require("src.core.Sound"), require("src.core.Music") + local restored = {} + local function patch(module, key, fn) + local old = module[key] + restored[#restored + 1] = function() + module[key] = old + end + module[key] = fn + end + local function sound(_, key) + f.audioTrace[#f.audioTrace + 1] = { kind = "sound", id = key } + f.source.playing = true + return f.source + end + local function music(_, key, _, opts) + f.audioTrace[#f.audioTrace + 1] = { kind = "music", id = key, keep = opts and opts.keep or false } + f.current = key + end + local function stop() + f.audioTrace[#f.audioTrace + 1] = { kind = "stop" } + f.current = nil + f.musicBusy = false + end + local function once(_, key) + f.audioTrace[#f.audioTrace + 1] = { kind = "once", id = key } + f.current = key + f.musicBusy = true + return true + end + local function fade(control) + f.audioTrace[#f.audioTrace + 1] = { kind = "fade", control = control } + end + local function default(_, map) + f.audioTrace[#f.audioTrace + 1] = { kind = "default", map = map } + end + patch(Sound, "play", sound) + patch(Sound, "playNamedEffect", sound) + patch(Sound, "sfxBusy", function() + return f.otherBusy == true + end) + patch(Sound, "anyPlaying", function() + return f.otherBusy == true + end) + patch(Music, "play", music) + patch(Music, "stop", stop) + patch(Music, "playOnce", once) + patch(Music, "oneShotPlaying", function() + return f.musicBusy + end) + patch(Music, "current", function() + return f.current + end) + patch(Music, "fadeOut", fade) + patch(Music, "playMap", default) + function f.ow:playMapMusic() + default(f.game.data, self.map.id) + end + if backend == "gen1" and f.gen == 3 then + local Audio = require("src.core.game3.audio") + patch(Audio, "songInfo", function(key) + return f.game.data.audio.songs[key] or f.game.data.audio.sfx[key] + end) + patch(Audio, "resolveSong", function(key) + return key + end) + patch(Audio, "playSe", function(key) + return sound(f.game.data, key) + end) + patch(Audio, "isSePlaying", function() + return f.source.playing or f.otherBusy == true + end) + patch(Audio, "playSong", function(key, opts) + if key == 0 then + return stop() + end + return music(f.game.data, key, nil, opts) + end) + patch(Audio, "playFanfare", function(key) + return once(f.game.data, key) + end) + patch(Audio, "isFanfareFinished", function() + return not f.musicBusy + end) + patch(Audio, "fadeOutBgm", fade) + patch(Audio, "restoreMapSong", function() + return default(f.game.data, f.ow.map.id) + end) + end + f.restore = function() + for i = #restored, 1, -1 do + restored[i]() + end + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "play_sound", "CHIME" }, + { "wait_sound" }, + { "play_music", "TUNE", { keep = true } }, + { "fade_music", 2 }, + { "stop_music" }, + { "play_once", "JINGLE" }, + { "play_default_music" }, + { "set_flag", "mod:after" }, + })) + for _ = 1, 3 do + f:tick() + end + assert(f.exports.result().done == 0 and #f.audioTrace == 1) + f.source.playing = false + f.otherBusy = true + for _ = 1, 3 do + f:tick() + end + assert(#f.audioTrace == 1 and f.exports.result().done == 0) + f.otherBusy = false + for _ = 1, 20 do + f:tick() + if f.musicBusy then + break + end + end + assert(f.musicBusy and f.exports.result().done == 0) + f.musicBusy = false + f:drain() + assert(f.exports.result().success and f.exports.get("mod:after")) + local report = { trace = f.audioTrace, complete = true } + f.restore() + f = M.fixture(root, backend, version) + local value, why = f.exports.queue({ { "play_sound", "CHIME" }, { "play_music", "" } }) + assert(value == nil and type(why) == "string" and #f.audioTrace == 0) + report.preflight = true + f.restore() + 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 ed7ebf365..f4e4578e1 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -25,11 +25,14 @@ assert( or mode == "script_battles" or mode == "script_trainers" or mode == "script_shops" + or mode == "script_audio" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_shops" then +if mode == "script_audio" then + dofile(codeRoot .. "/script_audio.lua").run(root, backend, version) +elseif mode == "script_shops" then dofile(codeRoot .. "/script_shops.lua").run(root, backend, version) elseif mode == "script_trainers" then dofile(codeRoot .. "/script_trainers.lua").run(root, backend, version) diff --git a/tests/engine/portable_script_audio.lua b/tests/engine/portable_script_audio.lua new file mode 100644 index 000000000..bdc15eb4d --- /dev/null +++ b/tests/engine/portable_script_audio.lua @@ -0,0 +1,288 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_audio") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v) + return Contract.fixture("tests/api3_contract", "gen2", v or "red") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared audio contract: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "set_flag", "mod:prefix" }, { "play_sound", "UNKNOWN" } }, + { { "play_music", "UNKNOWN" } }, + { { "play_once", "UNKNOWN" } }, + { { "play_sound", "" } }, + { { "play_music", "TUNE", { keep = "yes" } } }, + { { "play_music", "TUNE", { volume = 1 } } }, + { { "fade_music", -1 } }, + { { "fade_music", 256 } }, + { { "wait_sound", 1 } }, + { { "stop_music", false } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid audio preflight") + T.eq(f.exports.get("mod:prefix"), false, "invalid audio suffix prevents prefix") + T.eq(#f.audioTrace, 0, "preflight plays no cue") + f.restore() +end +for _, phase in ipairs({ "sound", "once" }) do + for _, change in ipairs({ "rollback", "save", "data", "map" }) do + local f = fixture() + local original = f.ow.runner + local first = phase == "sound" and { "play_sound", "CHIME" } or { "play_once", "JINGLE" } + local rows = phase == "sound" and { first, { "wait_sound" }, { "set_flag", "mod:after" } } + or { first, { "set_flag", "mod:after" } } + T.check(f.exports.queue(rows), "audio admitted before context change") + if change == "rollback" then + f.loader:_rollback("script_audio") + elseif change == "map" then + f.ow.map = { id = f.home } + else + f.game[change] = {} + end + f:tick() + f.source.playing = false + f.musicBusy = false + f:tick() + T.eq(f.exports.result().success, false, "retired audio wait fails script") + T.eq(f.exports.result().done, 1, "retired audio finishes once") + T.eq(f.ow.runner, original, "retired audio restores idle runner") + T.eq(#f.audioTrace, 1, "retirement adds no audio or later effects") + f.restore() + end +end +do + local f = fixture() + T.check( + f.exports.queue({ { "play_sound", "CHIME" }, { "wait_sound" }, { "set_flag", "mod:after" } }), + "sound timeout admitted" + ) + for _ = 1, 600 do + f:tick() + end + T.eq(f.exports.result().success, true, "600-frame sound ceiling releases native wait") + T.eq(f.exports.get("mod:after"), true, "sound timeout keeps native continuation behavior") + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ { "play_once", "JINGLE" }, { "set_flag", "mod:after" } }), + "stalled fanfare admitted" + ) + for _ = 1, 3600 do + f:tick() + end + T.eq(f.exports.result().success, false, "stalled one-shot fails at bounded deadline") + T.eq(f.exports.get("mod:after"), false, "stalled one-shot cannot burn later flag") + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ { "play_once", "JINGLE" }, { "set_flag", "mod:after" } }), + "one-shot before foreign music" + ) + f.current = "FOREIGN" + f:drain() + T.eq(f.exports.result().success, true, "one-shot does not wait on a replacement song") + T.eq(f.current, "FOREIGN", "replacement song retained") + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ { "play_sound", "CHIME" }, { "wait_sound" }, { "set_flag", "mod:after" } }), + "sound before overlay" + ) + f.source.playing = false + f.game.stack.states[2] = {} + for _ = 1, 5 do + f:tick() + end + T.eq(f.exports.get("mod:after"), false, "audio continuation pauses beneath overlay") + f.game.stack.states[2] = nil + f:drain() + T.eq(f.exports.get("mod:after"), true, "audio resumes in foreground") + f.restore() +end +for _, v in ipairs({ "firered", "emerald" }) do + local f = fixture(v) + f.game.data.audio.songs.SONG_007 = { index = 7, musName = "MUS_TEST", sfxName = "SE_TEST", tracks = {} } + f.game.data.audio.sfx.Get_Item1 = { m4a = 7, song = "SONG_007" } + T.check( + f.exports.queue({ + { "play_sound", "SE_TEST" }, + { "wait_sound" }, + { "play_music", 7 }, + { + "play_once", + "Get_Item1", + }, + }), + "native Gen3 names/number/fanfare role admitted" + ) + T.eq(f.audioTrace[1].id, "SONG_007", "native sequence resolved for effect") + f.source.playing = false + for _ = 1, 5 do + f:tick() + end + T.eq(f.audioTrace[2].id, "SONG_007", "numeric music id resolved") + T.eq(f.audioTrace[3].id, "SONG_007", "native obtain-item fanfare role resolved") + f.musicBusy = false + f:drain() + T.eq(f.exports.result().success, true, "native Gen3 resolved sequence completes") + f.restore() + f = fixture(v) + f.game.data.audio.songs.A = { index = 7, musName = "MUS_DUP" } + f.game.data.audio.songs.B = { index = 8, musName = "MUS_DUP" } + T.eq( + f.exports.queue({ { "set_flag", "mod:prefix" }, { "play_music", "MUS_DUP" } }), + nil, + "ambiguous sequence name preflight" + ) + T.eq(f.exports.get("mod:prefix"), false, "ambiguous cue prevents prefix") + T.check(f.exports.queue({ { "play_music", 0 } }), "Gen3 native zero music id admitted") + T.eq(f.audioTrace[1].kind, "stop", "Gen3 native zero means stop") + f.restore() +end +do + local f = fixture() + local opts = { keep = true, reason = "script" } + T.check( + f.exports.queue({ { "wait", 1 }, { "play_music", "TUNE", opts } }), + "music options snapshot admitted" + ) + opts.keep = false + f:drain() + T.eq(f.ow.keepMusicOnce, true, "caller cannot mutate queued music options") + f.restore() +end +do + local f = fixture() + T.check(f.exports.queue({ { "wait", 1 }, { "play_music", "TUNE" } }), "deferred cue admitted") + f.game.data.audio.songs.TUNE = nil + f:drain() + T.eq(f.exports.result().success, false, "live missing cue fails dispatch") + T.eq(#f.audioTrace, 0, "missing live cue does not play") + f.restore() +end +do + local f = fixture() + local Music = require("src.core.Music") + local native = Music.play + Music.play = function(...) + native(...) + f.loader:_rollback("script_audio") + end + T.check( + f.exports.queue({ { "play_music", "TUNE", { keep = true } }, { "set_flag", "mod:after" } }), + "retirement during native music admitted" + ) + T.eq(f.exports.result().success, false, "music callback retirement cancels script") + T.eq(f.ow.keepMusicOnce, nil, "retired cue cannot arm a later warp's music flag") + T.eq(#f.audioTrace, 1, "already committed native cue is retained") + f.restore() +end +for _, v in ipairs({ "firered", "emerald" }) do + local f = fixture(v) + T.eq( + f.exports.queue({ { "set_flag", "mod:prefix" }, { "play_music", "MUS_UNKNOWN" } }), + nil, + "unknown Gen3 cue refused before prefix" + ) + T.eq(f.exports.get("mod:prefix"), false, "Gen3 cue refusal has no prefix") + f.game.data.audio.songs.TUNE = false + T.eq(f.exports.queue({ { "play_music", "TUNE" } }), nil, "disabled cue not accepted as a definition") + f.restore() +end +-- Native Sound file/effect dispatch, with only the actual device replaced. +do + love = require("tests.love_stub") + local Sound = require("src.core.Sound") + local previousAudio = love.audio + local calls = 0 + local src = { + playing = false, + setVolume = function() end, + stop = function(self) + self.playing = false + end, + play = function(self) + self.playing = true + end, + isPlaying = function(self) + return self.playing + end, + } + love.audio = { + newSource = function(file, kind) + T.eq(file, "native-cue.wav", "native file cue passed to device") + calls = calls + 1 + return src + end, + } + local data = { audio = { songs = { PORTABLE_NATIVE_FILE = { file = "native-cue.wav" } } } } + T.eq(Sound.playNamedEffect(data, "PORTABLE_NATIVE_FILE"), src, "native helper returns started source") + T.eq(calls, 1, "native helper builds source once") + T.eq(Sound.playNamedEffect(data, "PORTABLE_NATIVE_FILE"), src, "native helper reuses native cache") + T.eq(calls, 1, "native helper preserves cache") + Sound.invalidate("PORTABLE_NATIVE_FILE") + love.audio = previousAudio +end +-- Actual native music one-shot and restore state; only the device is a seam. +do + local f = fixture() + f.restore() + love = require("tests.love_stub") + local previousAudio = love.audio + local src = { + playing = false, + setVolume = function() end, + setLooping = function() end, + setPitch = function() end, + stop = function(self) + self.playing = false + end, + play = function(self) + self.playing = true + end, + isPlaying = function(self) + return self.playing + end, + } + love.audio = { + newSource = function(file) + T.eq(file, "native-jingle.wav", "native music resolves file definition") + return src + end, + } + local Music = require("src.core.Music") + Music.stop() + f.game.data.audio.songs.JINGLE = { file = "native-jingle.wav" } + T.check( + f.exports.queue({ { "play_once", "JINGLE" }, { "set_flag", "mod:after" } }), + "native music one-shot admitted" + ) + T.eq(Music.current(), "JINGLE", "native music controller owns current jingle") + T.eq(Music.oneShotPlaying(), true, "native music pending restore retained") + for _ = 1, 3 do + f:tick() + end + T.eq(f.exports.get("mod:after"), false, "script waits on actual native restore state") + src.playing = false + Music.update(f.game.data) + f:drain() + T.eq(Music.oneShotPlaying(), false, "actual native controller drains restore") + T.eq(f.exports.get("mod:after"), true, "actual native restore resumes script") + Music.stop() + love.audio = previousAudio +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("Portable script audio") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index bf9cbdf38..8e6383eeb 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", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops", "script_audio"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 8a6eca8ebcc5501bc390f39de6aeba75b2878d48 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:01:06 +0200 Subject: [PATCH 29/42] Add owned deferred dialogue audio for portable scripts --- docs/api3-portability.md | 4 +- docs/portable-world-scripts.md | 28 ++- src/mods/PortableScripts.lua | 21 +- src/mods/ScriptDialogueAudio.lua | 165 ++++++++++++++ src/mods/ScriptProgram.lua | 14 +- .../mods/script_dialogue_audio/main.lua | 15 ++ .../mods/script_dialogue_audio/manifest.json | 1 + tests/api3_contract/script_audio.lua | 5 +- tests/api3_contract/script_dialogue_audio.lua | 111 +++++++++ tests/api3_contract/worker.lua | 5 +- tests/engine/portable_dialogue_audio.lua | 211 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 12 files changed, 571 insertions(+), 11 deletions(-) create mode 100644 src/mods/ScriptDialogueAudio.lua create mode 100644 tests/api3_contract/mods/script_dialogue_audio/main.lua create mode 100644 tests/api3_contract/mods/script_dialogue_audio/manifest.json create mode 100644 tests/api3_contract/script_dialogue_audio.lua create mode 100644 tests/engine/portable_dialogue_audio.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 4e2861f8b..443237fc8 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -134,8 +134,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 and eight NPC cases extend this to 96 comparisons -and 304 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 160 comparisons +and 320 isolated loader runs (including world-action, flag and portable-script action cases). 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 diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index 6a26982f7..a8dd1a159 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -460,4 +460,30 @@ The reference Gen3 resolver can return an unknown string unchanged; this host requires a real definition before admission. Missing audio data is therefore a preflight failure; a muted/unavailable device keeps the native headless playback behavior. Already started audio remains engine-owned on -script cancellation. Dialogue-bound cries/fanfares are a separate slice. +script cancellation. Dialogue-bound cues are described below. + + +### Deferred dialogue audio + +`play_cry(canonicalSpecies, waitForButton?)`, `text_sound(id)` and +`engage_music(id)` arm the next `show_text` or `ask`, rather than playing +while its text types. Species and native cue assets are checked before any +row runs, at arm time, at box creation and again at playback. Native numeric +cry identities are resolved through canonical species names. Unknown assets +fail admission; muted devices retain native no-source behavior. + +The native TextBox waits for returned sources, with its 600-frame ceiling. +An ordinary cry auto-closes after playback; `waitForButton=true` retains the +button wait. Text sounds and engage-only music retain the button wait. +Engage music starts once, after sound completion or the native ceiling. +Questions always wait for audio before opening their native choice box. +Gen3 sequence text cues use the native fanfare music duck controller. + +Retained callbacks cannot replay cues after the box closes, beneath a foreign +overlay or after the script retires. Reentrant unload during playback aborts +the native update before it can pop an unrelated UI state. Already started +sources remain engine-owned. A combined cry and text sound plays both on this +host; the reference Gen1 opts merge drops the text sound in that combination. +Shared cases deliberately verify individual cues, not that reference defect. +They drive real native dialogue controls with synthetic font/text data and +a fake audio-device clock; they do not claim imported-ROM or PCM equivalence. diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index 9fca0ef2d..d8bb35883 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -6,6 +6,7 @@ local Flags = require("src.mods.Api3Flags") local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") local Audio = require("src.mods.ScriptAudio") +local DialogueAudio = require("src.mods.ScriptDialogueAudio") local Shops = require("src.mods.ScriptShops") local Battles = require("src.mods.ScriptBattles") local Travel = require("src.mods.ScriptTravel") @@ -287,6 +288,10 @@ local function facade(loader, modId, native) end end end + local dialogueAudio, dialogueReason = DialogueAudio.prepare(loader, game, program) + if not dialogueAudio then + return nil, dialogueReason + end local audio, audioReason = Audio.prepare(game, program) if not audio then return nil, audioReason @@ -550,11 +555,19 @@ local function facade(loader, modId, native) ctx.lastCheck = value runner:resume() end + local opts = dialogueAudio.options(ctx, asking, function() + return not answered + and token.step == step + and check() + and box ~= nil + and contains(token.stack, box) + and token.stack:top() == box + end) box = TextBox.new(game, TextBox.fromProse(text), function() if not asking and not contains(token.stack, box) then answer(nil) end - end) + end, opts) if asking then local update, pushed, selected = box.update, false, nil box.choice = function(yes) @@ -565,7 +578,7 @@ local function facade(loader, modId, native) answer(yes) end function box:update(dt) - if self.done and (self.holdFrames or 0) <= 0 and not pushed then + if self.done and (self.holdFrames or 0) <= 0 and not pushed and not self.auto then pushed, self.choicePushed = true, true local Choice = require("src.ui.ChoiceBox") local geometry = self.choiceBox or {} @@ -1107,7 +1120,9 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if Audio.ARITY[verb] then + if DialogueAudio.ARITY[verb] then + dialogueAudio.arm(ctx, row) + elseif Audio.ARITY[verb] then audio(ctx, row, waitAudio, check) elseif Shops.ARITY[verb] then shop(ctx, row) diff --git a/src/mods/ScriptDialogueAudio.lua b/src/mods/ScriptDialogueAudio.lua new file mode 100644 index 000000000..ce8fde2a1 --- /dev/null +++ b/src/mods/ScriptDialogueAudio.lua @@ -0,0 +1,165 @@ +-- Deferred, owned dialogue cues. Native TextBox supplies its 600-frame gate. +local Audio = require("src.mods.ScriptAudio") +local Version = require("src.core.GameVersion") +local D = { ARITY = { play_cry = 3, text_sound = 2, engage_music = 2 } } +function D.syntax(verb, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable dialogue audio unavailable for this generation" + end + if #row < 2 or #row > D.ARITY[verb] then + return nil, "invalid command arity" + end + if verb == "play_cry" then + if + type(row[2]) ~= "string" + or not row[2]:match("^[A-Z][A-Z0-9_]*$") + or #row[2] > 128 + or row[3] ~= nil and type(row[3]) ~= "boolean" + then + return nil, "invalid cry" + end + return { verb, row[2], row[3] } + end + local out, why = Audio.syntax( + verb == "text_sound" and "play_sound" or "play_music", + { verb == "text_sound" and "play_sound" or "play_music", row[2] }, + context + ) + if not out then + return nil, why + end + return { verb, row[2] } +end +function D.prepare(loader, game, rows) + local ids = require("src.mods.UnifiedIds").new(loader) + local function resolve(row) + if row[1] ~= "play_cry" then + return Audio.resolve(game, { row[1] == "text_sound" and "play_sound" or "play_music", row[2] }) + end + local group, why = ids:resolve("pokemon", row[2]) + local mon = group and game.data.pokemon and game.data.pokemon[group.key] + if not mon then + return nil, why or "cry species unavailable" + end + local audio = game.data.audio or {} + local def = audio.cries + and ( + audio.cries[group.key] + or type(group.key) == "number" and audio.cries[mon.translationSourceName or mon.name] + ) + local pika = Version.get() == "yellow" + and group.key == "PIKACHU" + and type(audio.pikaCries) == "table" + and audio.pikaCries[1] + if type(def) ~= "table" and type(def) ~= "string" and not pika then + return nil, "native cry unavailable: " .. row[2] + end + return { key = group.key, cry = true } + end + for i, row in ipairs(rows) do + if D.ARITY[row[1]] then + local plan, why = resolve(row) + if not plan then + return nil, "row " .. i .. ": " .. why + end + end + end + local function checked(row) + local plan, why = resolve(row) + if not plan then + error(why, 0) + end + return plan + end + local function arm(ctx, row) + checked(row) + ctx.dialogueAudio = ctx.dialogueAudio or {} + ctx.dialogueAudio[row[1]] = row + end + local function options(ctx, asking, alive) + local cues = ctx.dialogueAudio + ctx.dialogueAudio = nil + if not cues then + return nil + end + for _, row in pairs(cues) do + checked(row) + end + local Sound, Music = require("src.core.Sound"), require("src.core.Music") + local sources, started, engaged, waited = {}, false, false, 0 + -- Throw only after a callback entered alive: abort native update before it + -- can pop a foreign overlay installed by a reentrant audio hook. + local function stillAlive() + if not alive() then + error("dialogue audio owner retired", 0) + end + end + local function busy() + if not alive() then + return false + end + local playing = false + for _, src in ipairs(sources) do + local ok, value = pcall(function() + return src:isPlaying() + end) + stillAlive() + playing = playing or ok and value == true + end + return playing + end + local function sound() + if started or not alive() then + return nil + end + started = true + for _, verb in ipairs({ "play_cry", "text_sound" }) do + if cues[verb] then + local p = checked(cues[verb]) + local src + if p.cry then + src = Sound.playCry(game.data, p.key) + elseif p.sequence then + src = Sound.playNamedEffect(game.data, p.key) + else + src = Sound.play(game.data, p.key) + end + stillAlive() + if src and p.sequence then + Music.duckForFanfare(src) + stillAlive() + end + if src then + sources[#sources + 1] = src + end + end + end + return { isPlaying = busy } + end + local function tick() + if engaged or not alive() then + return + end + waited = waited + 1 + if busy() and waited < 600 then + return + end + engaged = true + if cues.engage_music then + local p = checked(cues.engage_music) + if p.stop then + Music.stop() + else + Music.play(game.data, p.key) + end + stillAlive() + end + end + local cry = cues.play_cry + return { + auto = { sound = sound, tick = tick, delay = 0, wait = asking or not cry or cry[3] == true }, + } + end + return { arm = arm, options = options } +end +return D diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index 1e6b6238c..9f986e3b0 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -3,6 +3,7 @@ local Program = {} local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") local Audio = require("src.mods.ScriptAudio") +local DialogueAudio = require("src.mods.ScriptDialogueAudio") local Shops = require("src.mods.ScriptShops") local Battles = require("src.mods.ScriptBattles") local Travel = require("src.mods.ScriptTravel") @@ -48,6 +49,9 @@ end for verb, arity in pairs(Audio.ARITY) do ARITY[verb] = arity end +for verb, arity in pairs(DialogueAudio.ARITY) do + ARITY[verb] = arity +end local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) if type(verb) ~= "string" then @@ -61,6 +65,7 @@ function Program.supports(verb) or Battles.ARITY[verb] or Shops.ARITY[verb] or Audio.ARITY[verb] + or DialogueAudio.ARITY[verb] ) and require("src.core.GameVersion").generation() > 3 then return false @@ -149,12 +154,19 @@ function Program.compile(raw, context) and not Battles.ARITY[verb] and not Shops.ARITY[verb] and not Audio.ARITY[verb] + and not DialogueAudio.ARITY[verb] and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if Audio.ARITY[verb] then + if DialogueAudio.ARITY[verb] then + local value, reason = DialogueAudio.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif Audio.ARITY[verb] then local value, reason = Audio.syntax(verb, source, context) if not value then return fail(i, reason) diff --git a/tests/api3_contract/mods/script_dialogue_audio/main.lua b/tests/api3_contract/mods/script_dialogue_audio/main.lua new file mode 100644 index 000000000..2d21c725b --- /dev/null +++ b/tests/api3_contract/mods/script_dialogue_audio/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success, reason = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok, why) + done, success, reason = done + 1, ok, why + end, + }) +end +mod.exports.result = function() + return { done = done, success = success, reason = reason } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_dialogue_audio/manifest.json b/tests/api3_contract/mods/script_dialogue_audio/manifest.json new file mode 100644 index 000000000..d81be00df --- /dev/null +++ b/tests/api3_contract/mods/script_dialogue_audio/manifest.json @@ -0,0 +1 @@ +{"id":"script_dialogue_audio","name":"Portable dialogue audio","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/script_audio.lua b/tests/api3_contract/script_audio.lua index 1f957bd0f..0548e0813 100644 --- a/tests/api3_contract/script_audio.lua +++ b/tests/api3_contract/script_audio.lua @@ -1,8 +1,9 @@ -- Real loaders/adapters; an explicit source/clock seam avoids audio-device timing. local M = {} -function M.fixture(root, backend, version, modId) +function M.fixture(root, backend, version, modId, base) local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_audio%.lua$") - local f = dofile(codeRoot .. "/script_flags.lua").fixture(root, backend, version, modId or "script_audio") + local f = base + or dofile(codeRoot .. "/script_flags.lua").fixture(root, backend, version, modId or "script_audio") f.game.data.audio = { songs = { TUNE = { file = "tune.wav" }, JINGLE = { file = "jingle.wav" } }, sfx = { CHIME = { file = "chime.wav" } }, diff --git a/tests/api3_contract/script_dialogue_audio.lua b/tests/api3_contract/script_dialogue_audio.lua new file mode 100644 index 000000000..e0de68ec7 --- /dev/null +++ b/tests/api3_contract/script_dialogue_audio.lua @@ -0,0 +1,111 @@ +-- Actual native dialogue UI, with an explicit fake audio-device clock. +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_dialogue_audio%.lua$") + local base = + dofile(codeRoot .. "/script_gifts.lua").fixture(root, backend, version, "script_dialogue_audio") + local f = dofile(codeRoot .. "/script_audio.lua").fixture(root, backend, version, nil, base) + f.game.data.audio.cries = { MEW = { file = "mew.wav" }, [151] = { file = "mew.wav" } } + f.game.data.text.WELCOME = "WELCOME" + f.game.data.text_pointers = {} + f.ow.map.def.label = "DIALOGUE_HOME" + f.ow.game, f.ow.text = f.game, f.game.data.text + local Sound = require("src.core.Sound") + local oldCry, restore = Sound.playCry, f.restore + local function cry(_, key) + f.audioTrace[#f.audioTrace + 1] = { kind = "cry", id = "MEW" } + f.source.playing = true + return f.source + end + Sound.playCry = cry + if backend == "gen1" and f.gen == 2 then + local World = require("src.world.gen2.World") + f.ow.showText, f.ow.askYesNo = World.showText, World.askYesNo + end + local sourceRestore = function() end + if backend == "gen1" and f.gen == 3 then + local A = require("src.core.game3.audio") + local oldPlay, oldBusy = A.playCry, A.isCryFinished + A.playCry = function(key) + return cry(f.game.data, key) + end + A.isCryFinished = function() + return not f.source.playing + end + sourceRestore = function() + A.playCry, A.isCryFinished = oldPlay, oldBusy + end + end + f.restore = function() + Sound.playCry = oldCry + sourceRestore() + restore() + end + function f:tick(key) + self.game.input.wasPressed = function(_, button) + return button == key + end + self.loader.hooks:call(backend == "gen1" and "core.update" or "input.step", function() + if backend == "gen2" then + self.ow.runner:update() + end + end, self.game, 1 / 60) + if backend == "gen1" and self.gen == 3 then + local Message = require("src.ui.game3.message") + Message.tick() + if key then + if Message.isWaiting() then + Message.advance() + else + Message.pressAB() + end + end + else + self.game.stack:update(1 / 60) + end + self.game.input.wasPressed = function() + return false + end + end + return f +end +function M.run(root, backend, version) + local report = {} + for _, verb in ipairs({ "play_cry", "text_sound", "engage_music" }) do + local f = M.fixture(root, backend, version) + local cue = verb == "play_cry" and "MEW" or verb == "text_sound" and "CHIME" or "TUNE" + assert(f.exports.queue({ { verb, cue }, { "show_text", "WELCOME" }, { "set_flag", "mod:after" } })) + assert(#f.audioTrace == 0, "cue must be deferred") + for _ = 1, 300 do + f:tick() + if #f.audioTrace > 0 then + break + end + end + assert(#f.audioTrace == 1, "dialogue must start cue") + if verb ~= "engage_music" then + for _ = 1, 3 do + f:tick("a") + end + assert(f.exports.result().done == 0, "sounding cue blocks completion") + end + f.source.playing, f.musicBusy = false, false + for _ = 1, 30 do + f:tick() + end + if verb == "play_cry" then + assert(f.exports.result().success == true, "ordinary cry auto closes") + else + assert(f.exports.result().done == 0, "text cue keeps button wait") + for _ = 1, 30 do + f:tick("a") + end + end + assert(f.exports.result().success == true and f.exports.get("mod:after")) + report[verb] = { deferred = true, complete = true } + f.restore() + end + 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 f4e4578e1..2dfe31b80 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -26,11 +26,14 @@ assert( or mode == "script_trainers" or mode == "script_shops" or mode == "script_audio" + or mode == "script_dialogue_audio" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_audio" then +if mode == "script_dialogue_audio" then + dofile(codeRoot .. "/script_dialogue_audio.lua").run(root, backend, version) +elseif mode == "script_audio" then dofile(codeRoot .. "/script_audio.lua").run(root, backend, version) elseif mode == "script_shops" then dofile(codeRoot .. "/script_shops.lua").run(root, backend, version) diff --git a/tests/engine/portable_dialogue_audio.lua b/tests/engine/portable_dialogue_audio.lua new file mode 100644 index 000000000..a5882405f --- /dev/null +++ b/tests/engine/portable_dialogue_audio.lua @@ -0,0 +1,211 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_dialogue_audio") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v) + return Contract.fixture("tests/api3_contract", "gen2", v or "red") +end +local function sounding(f) + for _ = 1, 300 do + f:tick() + if #f.audioTrace > 0 then + return f.game.stack:top() + end + end + error("no deferred cue") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " native dialogue audio contract: " .. tostring(why)) +end +for _, row in ipairs({ + { "play_cry", "UNKNOWN" }, + { "play_cry", 151 }, + { "play_cry", "MEW", 1 }, + { "play_cry", "MEW", true, 1 }, + { "text_sound", "UNKNOWN" }, + { "engage_music", "UNKNOWN" }, + { "text_sound", "CHIME", false }, + { "engage_music", false }, +}) do + local f = fixture() + T.eq(f.exports.queue({ { "set_flag", "mod:prefix" }, row }), nil, "bad deferred cue refused") + T.eq(f.exports.get("mod:prefix"), false, "whole script preflight") + T.eq(#f.audioTrace, 0, "preflight is silent") + f.restore() +end +for _, change in ipairs({ "rollback", "save", "data", "map" }) do + for _, phase in ipairs({ "typing", "sounding", "button" }) do + local f = fixture() + T.check( + f.exports.queue({ + { "play_cry", "MEW", true }, + { "engage_music", "TUNE" }, + { "show_text", "WELCOME" }, + { "set_flag", "mod:after" }, + }), + "retirement fixture queued" + ) + local box = f.game.stack:top() + local auto = box.auto + if phase ~= "typing" then + sounding(f) + end + if phase == "button" then + f.source.playing = false + f:tick() + end + if change == "rollback" then + f.loader:_rollback("script_dialogue_audio") + elseif change == "map" then + f.ow.map = { id = f.home } + else + f.game[change] = {} + end + local foreign = { update = function() end } + f.game.stack:push(foreign) + -- Retained callbacks after retirement cannot replay cues or remove UI. + auto.sound() + auto.tick() + f:tick() + T.eq(f.exports.get("mod:after"), false, "retired cue has no continuation") + T.eq(f.game.stack:top(), foreign, "foreign overlay retained") + T.eq(f.exports.result().done, 1, "retired cue completes once") + f.restore() + end +end +for _, callback in ipairs({ "sound", "poll", "engage" }) do + local f = fixture() + local Sound, Music = require("src.core.Sound"), require("src.core.Music") + local foreign = { update = function() end } + local function retire() + f.loader:_rollback("script_dialogue_audio") + f.game.stack:push(foreign) + end + local oldSound, oldMusic = Sound.playCry, Music.play + if callback == "sound" then + Sound.playCry = function(...) + local src = oldSound(...) + retire() + return src + end + elseif callback == "poll" then + f.source.isPlaying = function() + retire() + return false + end + else + Music.play = function(...) + oldMusic(...) + retire() + end + end + T.check( + f.exports.queue({ + { "play_cry", "MEW" }, + { "engage_music", "TUNE" }, + { "show_text", "WELCOME" }, + { "set_flag", "mod:after" }, + }), + "reentrant cue admitted" + ) + for _ = 1, 300 do + f:tick() + if callback == "engage" then + f.source.playing = false + end + if f.exports.result().done > 0 then + break + end + end + T.eq(f.game.stack:top(), foreign, "reentrant callback cannot pop foreign state") + T.eq(f.exports.result().done, 1, "reentrant completion once") + T.eq(f.exports.get("mod:after"), false, "reentrant continuation suppressed") + Sound.playCry, Music.play = oldSound, oldMusic + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ + { "play_cry", "MEW" }, + { "text_sound", "CHIME" }, + { "engage_music", "TUNE" }, + { "ask", "WELCOME" }, + { "set_flag", "mod:after" }, + }), + "combined cues with question" + ) + local box = sounding(f) + T.eq(#f.audioTrace, 2, "both cry and text effect play") + for _ = 1, 3 do + f:tick("a") + end + T.eq(f.game.stack:top(), box, "question waits for sound before choice") + local retained = box.auto + retained.sound() + T.eq(#f.audioTrace, 2, "sound callback is once only") + f.source.playing = false + for _ = 1, 3 do + f:tick() + end + T.check(f.game.stack:top() ~= box, "native choice after audio gate") + T.eq(f.audioTrace[3].kind, "music", "engage follows completed cue") + retained.tick() + retained.sound() + T.eq(#f.audioTrace, 3, "underlay callbacks add no playback") + for _ = 1, 30 do + f:tick("a") + end + T.eq(f.exports.result().success, true, "choice continues script once") + T.eq(f.exports.result().done, 1, "choice single completion") + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ + { "play_cry", "MEW" }, + { "engage_music", "TUNE" }, + { "show_text", "WELCOME" }, + { "set_flag", "mod:after" }, + }), + "bounded cry fixture" + ) + sounding(f) + for _ = 1, 600 do + f:tick() + end + T.eq(f.exports.result().success, true, "600-frame native audio ceiling releases dialogue") + T.eq(#f.audioTrace, 2, "music starts once at sound ceiling") + f.restore() +end +for _, stage in ipairs({ "dispatch", "typed" }) do + local f = fixture() + T.check( + f.exports.queue({ { "wait", 2 }, { "play_cry", "MEW" }, { "show_text", "WELCOME" }, { + "set_flag", + "mod:after", + } }), + "asset-change fixture" + ) + if stage == "typed" then + for _ = 1, 4 do + f:tick() + end + end + f.game.data.audio.cries.MEW = nil + for _ = 1, 300 do + f:tick() + end + T.eq(f.exports.result().success, false, "missing cry revalidated at " .. stage) + T.eq(f.exports.get("mod:after"), false, "changed asset prevents continuation") + T.eq(#f.audioTrace, 0, "missing asset never played") + f.restore() +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod = saved[2], saved[3], saved[4], saved[5] +love = saved[6] +T.finish() diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 8e6383eeb..424fb4e14 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", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops", "script_audio"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops", "script_audio", "script_dialogue_audio"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 5528ea28a9e8f9451aa27bbc6f2d411fa8ef2e62 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:05:35 +0200 Subject: [PATCH 30/42] Add bounded portable save-field and option commands --- docs/api3-portability.md | 4 +- docs/portable-world-scripts.md | 25 +++ src/mods/PortableScripts.lua | 9 +- src/mods/ScriptFields.lua | 127 +++++++++++++++ src/mods/ScriptProgram.lua | 14 +- .../api3_contract/mods/script_fields/main.lua | 15 ++ .../mods/script_fields/manifest.json | 1 + tests/api3_contract/script_fields.lua | 41 +++++ tests/api3_contract/worker.lua | 5 +- tests/engine/portable_script_fields.lua | 146 ++++++++++++++++++ tools/api3-contract-check/check.py | 2 +- 11 files changed, 383 insertions(+), 6 deletions(-) create mode 100644 src/mods/ScriptFields.lua create mode 100644 tests/api3_contract/mods/script_fields/main.lua create mode 100644 tests/api3_contract/mods/script_fields/manifest.json create mode 100644 tests/api3_contract/script_fields.lua create mode 100644 tests/engine/portable_script_fields.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 443237fc8..92b941d4c 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -134,8 +134,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 and eight NPC cases extend this to 160 comparisons -and 320 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 168 comparisons +and 336 isolated loader runs (including world-action, flag and portable-script action cases). 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 diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md index a8dd1a159..ad8e876c9 100644 --- a/docs/portable-world-scripts.md +++ b/docs/portable-world-scripts.md @@ -487,3 +487,28 @@ host; the reference Gen1 opts merge drops the text sound in that combination. Shared cases deliberately verify individual cues, not that reference defect. They drive real native dialogue controls with synthetic font/text data and a fake audio-device clock; they do not claim imported-ROM or PCM equivalence. + +### Save fields and options + +`set_field(key, value?)` writes a native save-root field; `mod:key` writes +only the current mod's `save.modData[modId][key]`. `set_option(key, value?)` +writes the native `save.options[key]` dictionary. As in the reference, +`set_option("mod:key", value)` retains that literal option key; it does not +redirect to a private bucket. Omitting the value deletes the selected entry. +Native root field and option names/meanings remain game-specific. + +Values must be finite numbers, strings, booleans or acyclic plain data tables +with string/positive-integer keys. Functions, metatables and foreign mod +names (`mod.other.key`, `other:key`) are refused. Replacing the root `modData` +store is refused so `set_field` cannot bypass ownership. This is intentionally +stricter than the reference's unrestricted root write. A value is limited to +16 nested levels, 1,024 nodes and 32 KiB of strings; the complete program is +limited to 8,192 nodes and 128 KiB of field strings. + +The compiler snapshots values; each execution copies again so neither the +caller nor an earlier loop iteration can mutate compiled data. All target +store shapes are checked before admission and again at dispatch. A linear +preflight accounts for an earlier root `options` replacement before a later +`set_option`. Conditional/loop behavior can still change native store validity +at runtime; a failed runtime check stops further rows. Root writes do not +translate game-specific semantics or persist the save file automatically. diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua index d8bb35883..72d855cec 100644 --- a/src/mods/PortableScripts.lua +++ b/src/mods/PortableScripts.lua @@ -6,6 +6,7 @@ local Flags = require("src.mods.Api3Flags") local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") local Audio = require("src.mods.ScriptAudio") +local Fields = require("src.mods.ScriptFields") local DialogueAudio = require("src.mods.ScriptDialogueAudio") local Shops = require("src.mods.ScriptShops") local Battles = require("src.mods.ScriptBattles") @@ -288,6 +289,10 @@ local function facade(loader, modId, native) end end end + local fields, fieldsReason = Fields.prepare(game, modId, program) + if not fields then + return nil, fieldsReason + end local dialogueAudio, dialogueReason = DialogueAudio.prepare(loader, game, program) if not dialogueAudio then return nil, dialogueReason @@ -1120,7 +1125,9 @@ local function facade(loader, modId, native) token.step = {} self.lastRow = verb .. " (portable row " .. pc .. ")" ctx.script, ctx.pc = compiled, pc - if DialogueAudio.ARITY[verb] then + if Fields.ARITY[verb] then + fields(ctx, row) + elseif DialogueAudio.ARITY[verb] then dialogueAudio.arm(ctx, row) elseif Audio.ARITY[verb] then audio(ctx, row, waitAudio, check) diff --git a/src/mods/ScriptFields.lua b/src/mods/ScriptFields.lua new file mode 100644 index 000000000..ade27470a --- /dev/null +++ b/src/mods/ScriptFields.lua @@ -0,0 +1,127 @@ +-- Data-only field writes; no executable values or namespace escape. +local F = { ARITY = { set_field = 3, set_option = 3 } } +local function copy(value, state, depth) + state.nodes = state.nodes + 1 + if state.nodes > 1024 or depth > 16 then + error("script field data limit", 0) + end + local kind = type(value) + if kind == "nil" or kind == "boolean" then + return value + end + if kind == "number" then + if value ~= value or value == math.huge or value == -math.huge then + error("invalid field number", 0) + end + return value + end + if kind == "string" then + state.bytes = state.bytes + #value + if state.bytes > 32768 then + error("script field string limit", 0) + end + return value + end + if kind ~= "table" or getmetatable(value) ~= nil or state.seen[value] then + error("script fields require acyclic plain data", 0) + end + state.seen[value] = true + local out = {} + for k, v in pairs(value) do + if type(k) ~= "string" and not (type(k) == "number" and k >= 1 and k % 1 == 0 and k < math.huge) then + error("invalid field data key", 0) + end + local key = copy(k, state, depth + 1) + out[key] = copy(v, state, depth + 1) + end + state.seen[value] = nil + return out +end +local function snapshot(value) + local state = { nodes = 0, bytes = 0, seen = {} } + return copy(value, state, 0), state +end +function F.syntax(verb, row, context) + if (context.generation or require("src.core.GameVersion").generation()) > 3 then + return nil, "portable fields unavailable for this generation" + end + local key = row[2] + if + #row < 2 + or #row > 3 + or type(key) ~= "string" + or #key == 0 + or #key > 256 + or key:find("%c") + or key:match("^mod%.") + or key == "mod:" + or key:find(":", 1, true) and not key:match("^mod:.+") + or verb == "set_field" and key == "modData" + then + return nil, "invalid or foreign script field" + end + local ok, value = pcall(snapshot, row[3]) + if not ok then + return nil, value + end + return { verb, key, value } +end +local function plain(value) + return value == nil or type(value) == "table" and getmetatable(value) == nil +end +function F.prepare(game, owner, rows) + local bytes, nodes = 0, 0 + local options = game.save.options + local function stores(row, plannedOptions) + if row[1] == "set_option" then + if not plain(plannedOptions) then + return nil, "invalid options store" + end + elseif row[2]:match("^mod:") then + local data = game.save.modData + if not plain(data) or data and not plain(data[owner]) then + return nil, "invalid mod save store" + end + end + return true + end + for i, row in ipairs(rows) do + if F.ARITY[row[1]] then + local _, size = snapshot(row[3]) + bytes, nodes = bytes + size.bytes, nodes + size.nodes + if bytes > 131072 or nodes > 8192 then + return nil, "aggregate script field data limit" + end + local ok, why = stores(row, options) + if not ok then + return nil, "row " .. i .. ": " .. why + end + if row[1] == "set_field" and row[2] == "options" then + options = row[3] + end + end + end + return function(_, row) + local ok, why = stores(row, game.save.options) + if not ok then + error(why, 0) + end + -- Copy again on each dispatch: loops and post-run save mutations never + -- expose or mutate the compiled program's snapshot. + local value = snapshot(row[3]) + if row[1] == "set_option" then + game.save.options = game.save.options or {} + game.save.options[row[2]] = value + else + local private = row[2]:match("^mod:(.+)$") + if private then + game.save.modData = game.save.modData or {} + game.save.modData[owner] = game.save.modData[owner] or {} + game.save.modData[owner][private] = value + else + game.save[row[2]] = value + end + end + end +end +return F diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua index 9f986e3b0..0bcc7fdaf 100644 --- a/src/mods/ScriptProgram.lua +++ b/src/mods/ScriptProgram.lua @@ -3,6 +3,7 @@ local Program = {} local State = require("src.mods.ScriptState") local Gifts = require("src.mods.ScriptGifts") local Audio = require("src.mods.ScriptAudio") +local Fields = require("src.mods.ScriptFields") local DialogueAudio = require("src.mods.ScriptDialogueAudio") local Shops = require("src.mods.ScriptShops") local Battles = require("src.mods.ScriptBattles") @@ -52,6 +53,9 @@ end for verb, arity in pairs(DialogueAudio.ARITY) do ARITY[verb] = arity end +for verb, arity in pairs(Fields.ARITY) do + ARITY[verb] = arity +end local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } function Program.supports(verb) if type(verb) ~= "string" then @@ -66,6 +70,7 @@ function Program.supports(verb) or Shops.ARITY[verb] or Audio.ARITY[verb] or DialogueAudio.ARITY[verb] + or Fields.ARITY[verb] ) and require("src.core.GameVersion").generation() > 3 then return false @@ -155,12 +160,19 @@ function Program.compile(raw, context) and not Shops.ARITY[verb] and not Audio.ARITY[verb] and not DialogueAudio.ARITY[verb] + and not Fields.ARITY[verb] and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) then return fail(i, "invalid command arity") end local row = { verb, source[2] } - if DialogueAudio.ARITY[verb] then + if Fields.ARITY[verb] then + local value, reason = Fields.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif DialogueAudio.ARITY[verb] then local value, reason = DialogueAudio.syntax(verb, source, context) if not value then return fail(i, reason) diff --git a/tests/api3_contract/mods/script_fields/main.lua b/tests/api3_contract/mods/script_fields/main.lua new file mode 100644 index 000000000..2d21c725b --- /dev/null +++ b/tests/api3_contract/mods/script_fields/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success, reason = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok, why) + done, success, reason = done + 1, ok, why + end, + }) +end +mod.exports.result = function() + return { done = done, success = success, reason = reason } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_fields/manifest.json b/tests/api3_contract/mods/script_fields/manifest.json new file mode 100644 index 000000000..c50380150 --- /dev/null +++ b/tests/api3_contract/mods/script_fields/manifest.json @@ -0,0 +1 @@ +{"id":"script_fields","name":"Portable script fields","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/script_fields.lua b/tests/api3_contract/script_fields.lua new file mode 100644 index 000000000..8b4f1a93f --- /dev/null +++ b/tests/api3_contract/script_fields.lua @@ -0,0 +1,41 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_fields%.lua$") + return dofile(codeRoot .. "/script_flags.lua").fixture(root, backend, version, "script_fields") +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + f.game.save.modData.foreign = { marker = 17 } + local value = { phase = 2, enabled = false, items = { "A", 3 } } + assert( + f.exports.queue({ + { "wait", 1 }, + { "set_field", "portableRoot", true }, + { "set_field", "mod:story", value }, + { "set_option", "portableChoice", "FAST" }, + { "set_option", "mod:theme", false }, + { "set_flag", "mod:after" }, + }) + ) + value.phase = 9 + value.items[1] = "CHANGED" + f:drain() + assert(f.exports.result().success and f.exports.get("mod:after")) + local save = f.game.save + assert(save.portableRoot == true and save.modData.script_fields.story.phase == 2) + assert(save.modData.script_fields.story.items[1] == "A" and save.modData.foreign.marker == 17) + local report = { + root = save.portableRoot, + private = save.modData.script_fields.story, + options = save.options, + foreign = 17, + } + f = M.fixture(root, backend, version) + local result, why = + f.exports.queue({ { "set_field", "portableRoot", true }, { "set_field", "mod.foreign.field", 1 } }) + assert(result == nil and type(why) == "string" and f.game.save.portableRoot == nil) + report.preflight = true + 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 2dfe31b80..84ff30889 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -27,11 +27,14 @@ assert( or mode == "script_shops" or mode == "script_audio" or mode == "script_dialogue_audio" + or mode == "script_fields" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_dialogue_audio" then +if mode == "script_fields" then + dofile(codeRoot .. "/script_fields.lua").run(root, backend, version) +elseif mode == "script_dialogue_audio" then dofile(codeRoot .. "/script_dialogue_audio.lua").run(root, backend, version) elseif mode == "script_audio" then dofile(codeRoot .. "/script_audio.lua").run(root, backend, version) diff --git a/tests/engine/portable_script_fields.lua b/tests/engine/portable_script_fields.lua new file mode 100644 index 000000000..81f85b718 --- /dev/null +++ b/tests/engine/portable_script_fields.lua @@ -0,0 +1,146 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_fields") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v) + return Contract.fixture("tests/api3_contract", "gen2", v or "red") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared field contract: " .. tostring(why)) +end +local cyclic = {} +cyclic.self = cyclic +local deep = {} +local node = deep +for _ = 1, 18 do + node.child = {} + node = node.child +end +local wide = {} +for i = 1, 600 do + wide[i] = i +end +for _, row in ipairs({ + { "set_field", "modData", {} }, + { "set_field", "mod:" }, + { "set_field", "mod.other.x", 1 }, + { "set_field", "foreign:x", 1 }, + { "set_option", "x\n", 1 }, + { "set_field", "", 1 }, + { "set_field", false, 1 }, + { "set_field", "x", function() end }, + { "set_field", "x", 0 / 0 }, + { "set_field", "x", math.huge }, + { "set_field", "x", cyclic }, + { "set_field", "x", deep }, + { "set_field", "x", wide }, + { "set_field", "x", string.rep("a", 32769) }, + { "set_field", "x", { [false] = 1 } }, + { "set_field", "x", setmetatable({}, {}) }, + { "set_option", "x", { nested = function() end } }, + { "set_option", "x", 1, 2 }, +}) do + local f = fixture() + T.eq(f.exports.queue({ { "set_flag", "mod:prefix" }, row }), nil, "invalid field rejected") + T.eq(f.exports.get("mod:prefix"), false, "invalid field prevents prefix") +end +for _, store in ipairs({ "options", "modData", "bucket" }) do + for _, value in ipairs({ false, setmetatable({}, {}) }) do + local f = fixture() + local verb, key = "set_option", "choice" + if store == "bucket" then + f.game.save.modData.script_fields = value + verb, key = "set_field", "mod:x" + else + f.game.save[store] = value + if store == "modData" then + verb, key = "set_field", "mod:x" + end + end + T.eq( + f.exports.queue({ { "set_field", "portableRoot", true }, { verb, key, 1 } }), + nil, + "invalid store refused" + ) + T.eq(f.game.save.portableRoot, nil, "store failure before prefix") + end +end +do + local f = fixture() + T.check( + f.exports.queue({ { "wait", 1 }, { "set_field", "mod:x", 1 }, { "set_flag", "mod:after" } }), + "changed bucket fixture" + ) + f.game.save.modData.script_fields = false + f:drain() + T.eq(f.exports.result().success, false, "store revalidated at dispatch") + T.eq(f.game.save.modData.script_fields, false, "changed bucket retained") + T.eq(f.exports.get("mod:after"), nil, "changed bucket has no continuation") +end +do + local f = fixture() + T.eq( + f.exports.queue({ { "set_field", "options", false }, { "set_option", "x", 1 } }), + nil, + "future options shape preflight" + ) + T.eq(f.game.save.options, nil, "future invalid store leaves original untouched") +end +do + local f = fixture() + local value = { a = { 3, false, "x" } } + T.check( + f.exports.queue({ + { "set_field", "mod:a", value }, + { "set_field", "portableRoot", 9 }, + { "set_option", "portableChoice", true }, + }), + "values written" + ) + f:drain() + T.eq(f.game.save.modData.script_fields.a.a[2], false, "false preserved") + value.a[1] = 7 + T.eq(f.game.save.modData.script_fields.a.a[1], 3, "caller table not aliased") + T.check( + f.exports.queue({ { "set_field", "mod:a" }, { "set_field", "portableRoot" }, { + "set_option", + "portableChoice", + } }), + "omitted value deletes" + ) + f:drain() + T.eq(f.game.save.modData.script_fields.a, nil, "private field cleared") + T.eq(f.game.save.portableRoot, nil, "root field cleared") + T.eq(f.game.save.options.portableChoice, nil, "option cleared") +end +do + local F = require("src.mods.ScriptFields") + local game = { save = {} } + local rows = { { "set_field", "mod:x", { n = 1 } } } + local run = assert(F.prepare(game, "owner", rows)) + run({}, rows[1]) + game.save.modData.owner.x.n = 4 + run({}, rows[1]) + T.eq(game.save.modData.owner.x.n, 1, "repeated dispatch copies compiled value") + local rows = {} + for i = 1, 5 do + rows[i] = { "set_field", "mod:x", string.rep("a", 32768) } + end + T.eq(F.prepare(game, "owner", rows), nil, "aggregate data string limit") + local rows = {} + for i = 1, 20 do + local v = {} + for j = 1, 250 do + v[j] = j + end + rows[i] = { "set_field", "mod:x", v } + end + T.eq(F.prepare(game, "owner", rows), nil, "aggregate data node limit") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod = saved[2], saved[3], saved[4], saved[5] +love = saved[6] +T.finish() diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 424fb4e14..66b2755d9 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", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops", "script_audio", "script_dialogue_audio"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops", "script_audio", "script_dialogue_audio", "script_fields"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 1d9b8ef0ae11e8faa791da895d9b3d305da1be4e Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:19:29 +0200 Subject: [PATCH 31/42] Map portable move effects to native Gen1-3 handlers --- docs/api3-portability.md | 4 +- docs/unified-mod-content.md | 35 +- src/battle/MoveEffects.lua | 2 +- src/mods/MoveEffectContract.lua | 1213 +++++++++++++++++ src/mods/UnifiedContent.lua | 29 +- src/mods/UnifiedEffects.lua | 135 ++ src/mods/UnifiedSchemas.lua | 2 +- tests/api3_contract/effects.lua | 13 + tests/api3_contract/mods/effects/main.lua | 33 + .../api3_contract/mods/effects/manifest.json | 1 + tests/api3_contract/worker.lua | 5 +- tests/engine/unified_move_effects.lua | 208 +++ tools/api3-contract-check/check.py | 2 +- .../move-effect-contract/export_reference.lua | 44 + tools/move-effect-contract/generate.py | 75 + 15 files changed, 1786 insertions(+), 15 deletions(-) create mode 100644 src/mods/MoveEffectContract.lua create mode 100644 src/mods/UnifiedEffects.lua create mode 100644 tests/api3_contract/effects.lua create mode 100644 tests/api3_contract/mods/effects/main.lua create mode 100644 tests/api3_contract/mods/effects/manifest.json create mode 100644 tests/engine/unified_move_effects.lua create mode 100644 tools/move-effect-contract/export_reference.lua create mode 100644 tools/move-effect-contract/generate.py diff --git a/docs/api3-portability.md b/docs/api3-portability.md index 92b941d4c..d59ecfc36 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -134,8 +134,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 and eight NPC cases extend this to 168 comparisons -and 336 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 176 comparisons +and 352 isolated loader runs (including world-action, flag and portable-script action cases). 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 diff --git a/docs/unified-mod-content.md b/docs/unified-mod-content.md index 64d83da74..6108608a9 100644 --- a/docs/unified-mod-content.md +++ b/docs/unified-mod-content.md @@ -44,7 +44,7 @@ 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 | +| `moves` | `id`, `name`, `type`, `power`, `accuracy`, `pp`, `effect`, `secondaryChance`, `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 | @@ -114,11 +114,34 @@ 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. +Gen1–3 move effects use canonical IDs, resolved to the host's actual native +battle handlers before an operation is journaled. For example +`SPECIAL_UP1_EFFECT` selects `SP_ATK_UP1_EFFECT` on Gen2/3; the Gen1 Special +stage does not leak into split Special Attack/Defense. `HIT` and `SLEEP` are +accepted aliases. Registered custom native effects retain their own IDs. + +`secondaryChance` is an optional whole percentage (0–100) on Gen2/3. Explicit +odds convert to the nearest native fraction out of 256; detached unmodified +records retain their exact original byte. Explicit zero is distinguished from +a legacy native zero byte whose controller uses a fallback. Changing a +canonical effect applies its reference default chance unless the caller +supplies odds. Authored aliases survive later odds edits. + +Unknown native selectors are opaque `native:generation:selector` tokens on +Gen2/3. They roundtrip only with their original record; they cannot assign an +unknown handler to another move. Gen4/5 retain the previous diagnostic-only +numeric selector behavior and refuse effect/chance changes. Item behavior +and changing a DS move type remain unavailable without their native adapters. + +The data-only binding catalog is reproduced from pinned Gen1Recomp contract +maps plus the host importer's native names by +`tools/move-effect-contract/generate.py --gen1 CLEAN_REFERENCE --lua LUAJIT +--check`. It does not execute imported content. A binding still requires a +real registered host handler: FOLLOW_ME, HELPING_HAND, MAGIC_COAT, SNATCH, +IMPRISON, ASSIST, NATURE_POWER and SECRET_POWER are refused on the current +host. Identity-dependent native Rest/SonicBoom variants are not assigned to +an incompatible move. This adapter selects native behavior; it does not add +missing battle mechanics or promise cross-engine RNG/pixel equivalence. ### Encounters diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index ed4249843..fa959fcef 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -144,7 +144,7 @@ end -- SHIELD DUST still refuses a doubled roll. local function sideChance(move, fallback, user, target) local own = move and tonumber(move.effectChance) - local base = (own and own > 0) and own or fallback + local base = (own and (own > 0 or move._api3EffectChance)) and own or fallback if user or target then base = require("src.battle.Abilities").secondaryOdds(user, target, base) end diff --git a/src/mods/MoveEffectContract.lua b/src/mods/MoveEffectContract.lua new file mode 100644 index 000000000..1d1ec1c27 --- /dev/null +++ b/src/mods/MoveEffectContract.lua @@ -0,0 +1,1213 @@ +-- Generated by tools/move-effect-contract/generate.py; do not hand-edit. +return { + ["bindings"] = { + ["ACCURACY_DOWN1_EFFECT"] = {["1"]={["native"]="ACCURACY_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ACCURACY_DOWN",["semantic"]=23},["3"]={["chance"]=0,["native"]=23,["semantic"]=23}}, + ["ASSIST"] = {["3"]={["native"]=180,["semantic"]=180}}, + ["ATTACK_DOWN1_EFFECT"] = {["1"]={["native"]="ATTACK_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_DOWN",["semantic"]=18},["3"]={["chance"]=0,["native"]=18,["semantic"]=18}}, + ["ATTACK_DOWN2_EFFECT"] = {["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_DOWN_2",["semantic"]=58},["3"]={["chance"]=0,["native"]=58,["semantic"]=58}}, + ["ATTACK_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="ATTACK_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_ATTACK_DOWN_HIT",["semantic"]=68},["3"]={["chance"]=33,["native"]=68,["semantic"]=68}}, + ["ATTACK_TWICE_EFFECT"] = {["1"]={["native"]="ATTACK_TWICE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DOUBLE_HIT",["semantic"]=44},["3"]={["chance"]=0,["native"]=44,["semantic"]=44}}, + ["ATTACK_UP1_EFFECT"] = {["1"]={["native"]="ATTACK_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_UP",["semantic"]=10},["3"]={["chance"]=0,["native"]=10,["semantic"]=10}}, + ["ATTACK_UP2_EFFECT"] = {["1"]={["native"]="ATTACK_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_UP_2",["semantic"]=50},["3"]={["chance"]=0,["native"]=50,["semantic"]=50}}, + ["BIDE_EFFECT"] = {["1"]={["native"]="BIDE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_BIDE",["semantic"]=26},["3"]={["chance"]=0,["native"]=26,["semantic"]=26}}, + ["BLAZE_KICK"] = {["3"]={["native"]=200,["semantic"]=200}}, + ["BRICK_BREAK"] = {["3"]={["native"]=186,["semantic"]=186}}, + ["BULK_UP"] = {["3"]={["native"]=208,["semantic"]=208}}, + ["BURN_SIDE_EFFECT1"] = {["1"]={["native"]="BURN_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_BURN_HIT",["semantic"]=4},["3"]={["chance"]=10,["native"]=4,["semantic"]=4}}, + ["BURN_SIDE_EFFECT2"] = {["1"]={["native"]="BURN_SIDE_EFFECT2"},["2"]={["chance"]=30,["native"]="EFFECT_BURN_HIT",["semantic"]=4},["3"]={["chance"]=30,["native"]=4,["semantic"]=4}}, + ["CALM_MIND"] = {["3"]={["native"]=211,["semantic"]=211}}, + ["CAMOUFLAGE"] = {["3"]={["native"]=213,["semantic"]=213}}, + ["CHARGE"] = {["3"]={["native"]=174,["semantic"]=174}}, + ["CHARGE_EFFECT"] = {["1"]={["native"]="CHARGE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RAZOR_WIND",["semantic"]=39},["3"]={["chance"]=0,["native"]=39,["semantic"]=39}}, + ["CONFUSION_EFFECT"] = {["1"]={["native"]="CONFUSION_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_CONFUSE",["semantic"]=49},["3"]={["chance"]=0,["native"]=49,["semantic"]=49}}, + ["CONFUSION_SIDE_EFFECT"] = {["1"]={["native"]="CONFUSION_SIDE_EFFECT"},["2"]={["chance"]=10,["native"]="EFFECT_CONFUSE_HIT",["semantic"]=76},["3"]={["chance"]=10,["native"]=76,["semantic"]=76}}, + ["CONVERSION_2"] = {["3"]={["native"]=93,["semantic"]=93}}, + ["CONVERSION_EFFECT"] = {["1"]={["native"]="CONVERSION_EFFECT"},["3"]={["chance"]=0,["native"]=30,["semantic"]=30}}, + ["COSMIC_POWER"] = {["3"]={["native"]=206,["semantic"]=206}}, + ["DEFENSE_DOWN1_EFFECT"] = {["1"]={["native"]="DEFENSE_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_DOWN",["semantic"]=19},["3"]={["chance"]=0,["native"]=19,["semantic"]=19}}, + ["DEFENSE_DOWN2_EFFECT"] = {["1"]={["native"]="DEFENSE_DOWN2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_DOWN_2",["semantic"]=59},["3"]={["chance"]=0,["native"]=59,["semantic"]=59}}, + ["DEFENSE_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="DEFENSE_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_DEFENSE_DOWN_HIT",["semantic"]=69},["3"]={["chance"]=33,["native"]=69,["semantic"]=69}}, + ["DEFENSE_UP1_EFFECT"] = {["1"]={["native"]="DEFENSE_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_UP",["semantic"]=11},["3"]={["chance"]=0,["native"]=11,["semantic"]=11}}, + ["DEFENSE_UP2_EFFECT"] = {["1"]={["native"]="DEFENSE_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_UP_2",["semantic"]=51},["3"]={["chance"]=0,["native"]=51,["semantic"]=51}}, + ["DESTINY_BOND"] = {["3"]={["native"]=98,["semantic"]=98}}, + ["DISABLE_EFFECT"] = {["1"]={["native"]="DISABLE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DISABLE",["semantic"]=86},["3"]={["chance"]=0,["native"]=86,["semantic"]=86}}, + ["DOUBLE_EDGE"] = {["3"]={["native"]=198,["semantic"]=198}}, + ["DRAGON_DANCE"] = {["3"]={["native"]=212,["semantic"]=212}}, + ["DRAIN_HP_EFFECT"] = {["1"]={["native"]="DRAIN_HP_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LEECH_HIT",["semantic"]=3},["3"]={["chance"]=0,["native"]=3,["semantic"]=3}}, + ["DREAM_EATER_EFFECT"] = {["1"]={["native"]="DREAM_EATER_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DREAM_EATER",["semantic"]=8},["3"]={["chance"]=0,["native"]=8,["semantic"]=8}}, + ["EARTHQUAKE"] = {["3"]={["native"]=147,["semantic"]=147}}, + ["EFFECT_ACCURACY_DOWN_HIT"] = {["2"]={["native"]="EFFECT_ACCURACY_DOWN_HIT",["semantic"]=73},["3"]={["native"]=73,["semantic"]=73}}, + ["EFFECT_ALL_UP_HIT"] = {["2"]={["native"]="EFFECT_ALL_UP_HIT",["semantic"]=140},["3"]={["native"]=140,["semantic"]=140}}, + ["EFFECT_ATTACK_UP_HIT"] = {["2"]={["native"]="EFFECT_ATTACK_UP_HIT",["semantic"]=139},["3"]={["native"]=139,["semantic"]=139}}, + ["EFFECT_ATTRACT"] = {["2"]={["native"]="EFFECT_ATTRACT",["semantic"]=120},["3"]={["native"]=120,["semantic"]=120}}, + ["EFFECT_BATON_PASS"] = {["2"]={["native"]="EFFECT_BATON_PASS",["semantic"]=127},["3"]={["native"]=127,["semantic"]=127}}, + ["EFFECT_BEAT_UP"] = {["2"]={["native"]="EFFECT_BEAT_UP",["semantic"]=154},["3"]={["native"]=154,["semantic"]=154}}, + ["EFFECT_BELLY_DRUM"] = {["2"]={["native"]="EFFECT_BELLY_DRUM",["semantic"]=142},["3"]={["native"]=142,["semantic"]=142}}, + ["EFFECT_CONVERSION2"] = {["3"]={["native"]=93,["semantic"]=93}}, + ["EFFECT_CURSE"] = {["2"]={["native"]="EFFECT_CURSE",["semantic"]=109},["3"]={["native"]=109,["semantic"]=109}}, + ["EFFECT_DEFENSE_CURL"] = {["2"]={["native"]="EFFECT_DEFENSE_CURL",["semantic"]=156},["3"]={["native"]=156,["semantic"]=156}}, + ["EFFECT_DEFENSE_UP_HIT"] = {["2"]={["native"]="EFFECT_DEFENSE_UP_HIT",["semantic"]=138},["3"]={["native"]=138,["semantic"]=138}}, + ["EFFECT_DESTINY_BOND"] = {["3"]={["native"]=98,["semantic"]=98}}, + ["EFFECT_EARTHQUAKE"] = {["3"]={["native"]=147,["semantic"]=147}}, + ["EFFECT_ENCORE"] = {["2"]={["native"]="EFFECT_ENCORE",["semantic"]=90},["3"]={["native"]=90,["semantic"]=90}}, + ["EFFECT_ENDURE"] = {["2"]={["native"]="EFFECT_ENDURE",["semantic"]=116},["3"]={["native"]=116,["semantic"]=116}}, + ["EFFECT_FAKE_OUT"] = {["3"]={["native"]=158,["semantic"]=158}}, + ["EFFECT_FALSE_SWIPE"] = {["2"]={["native"]="EFFECT_FALSE_SWIPE",["semantic"]=101},["3"]={["native"]=101,["semantic"]=101}}, + ["EFFECT_FLAME_WHEEL"] = {["3"]={["native"]=125,["semantic"]=125}}, + ["EFFECT_FORESIGHT"] = {["2"]={["native"]="EFFECT_FORESIGHT",["semantic"]=113},["3"]={["native"]=113,["semantic"]=113}}, + ["EFFECT_FRUSTRATION"] = {["2"]={["native"]="EFFECT_FRUSTRATION",["semantic"]=123},["3"]={["native"]=123,["semantic"]=123}}, + ["EFFECT_FURY_CUTTER"] = {["2"]={["native"]="EFFECT_FURY_CUTTER",["semantic"]=119},["3"]={["native"]=119,["semantic"]=119}}, + ["EFFECT_FUTURE_SIGHT"] = {["2"]={["native"]="EFFECT_FUTURE_SIGHT",["semantic"]=148},["3"]={["native"]=148,["semantic"]=148}}, + ["EFFECT_GUST"] = {["3"]={["native"]=149,["semantic"]=149}}, + ["EFFECT_HEAL_BELL"] = {["3"]={["native"]=102,["semantic"]=102}}, + ["EFFECT_LOCK_ON"] = {["2"]={["native"]="EFFECT_LOCK_ON",["semantic"]=94},["3"]={["native"]=94,["semantic"]=94}}, + ["EFFECT_MAGNITUDE"] = {["2"]={["native"]="EFFECT_MAGNITUDE",["semantic"]=126},["3"]={["native"]=126,["semantic"]=126}}, + ["EFFECT_MEAN_LOOK"] = {["2"]={["native"]="EFFECT_MEAN_LOOK",["semantic"]=106},["3"]={["native"]=106,["semantic"]=106}}, + ["EFFECT_MIRROR_COAT"] = {["2"]={["native"]="EFFECT_MIRROR_COAT",["semantic"]=144},["3"]={["native"]=144,["semantic"]=144}}, + ["EFFECT_MOONLIGHT"] = {["2"]={["native"]="EFFECT_MOONLIGHT",["semantic"]=134},["3"]={["native"]=134,["semantic"]=134}}, + ["EFFECT_MORNING_SUN"] = {["2"]={["native"]="EFFECT_MORNING_SUN",["semantic"]=132},["3"]={["native"]=132,["semantic"]=132}}, + ["EFFECT_NIGHTMARE"] = {["2"]={["native"]="EFFECT_NIGHTMARE",["semantic"]=107},["3"]={["native"]=107,["semantic"]=107}}, + ["EFFECT_PAIN_SPLIT"] = {["3"]={["native"]=91,["semantic"]=91}}, + ["EFFECT_PERISH_SONG"] = {["2"]={["native"]="EFFECT_PERISH_SONG",["semantic"]=114},["3"]={["native"]=114,["semantic"]=114}}, + ["EFFECT_PRESENT"] = {["3"]={["native"]=122,["semantic"]=122}}, + ["EFFECT_PRIORITY_HIT"] = {["2"]={["native"]="EFFECT_PRIORITY_HIT",["semantic"]=103}}, + ["EFFECT_PROTECT"] = {["2"]={["native"]="EFFECT_PROTECT",["semantic"]=111},["3"]={["native"]=111,["semantic"]=111}}, + ["EFFECT_PSYCH_UP"] = {["2"]={["native"]="EFFECT_PSYCH_UP",["semantic"]=143},["3"]={["native"]=143,["semantic"]=143}}, + ["EFFECT_PURSUIT"] = {["3"]={["native"]=128,["semantic"]=128}}, + ["EFFECT_RAIN_DANCE"] = {["2"]={["native"]="EFFECT_RAIN_DANCE",["semantic"]=136},["3"]={["native"]=136,["semantic"]=136}}, + ["EFFECT_RAPID_SPIN"] = {["3"]={["native"]=129,["semantic"]=129}}, + ["EFFECT_RETURN"] = {["2"]={["native"]="EFFECT_RETURN",["semantic"]=121},["3"]={["native"]=121,["semantic"]=121}}, + ["EFFECT_REVERSAL"] = {["3"]={["native"]=99,["semantic"]=99}}, + ["EFFECT_ROLLOUT"] = {["2"]={["native"]="EFFECT_ROLLOUT",["semantic"]=117},["3"]={["native"]=117,["semantic"]=117}}, + ["EFFECT_SACRED_FIRE"] = {["2"]={["native"]="EFFECT_SACRED_FIRE",["semantic"]=125},["3"]={["native"]=125,["semantic"]=125}}, + ["EFFECT_SAFEGUARD"] = {["2"]={["native"]="EFFECT_SAFEGUARD",["semantic"]=124},["3"]={["native"]=124,["semantic"]=124}}, + ["EFFECT_SANDSTORM"] = {["2"]={["native"]="EFFECT_SANDSTORM",["semantic"]=115},["3"]={["native"]=115,["semantic"]=115}}, + ["EFFECT_SKETCH"] = {["2"]={["native"]="EFFECT_SKETCH",["semantic"]=95},["3"]={["native"]=95,["semantic"]=95}}, + ["EFFECT_SLEEP_TALK"] = {["2"]={["native"]="EFFECT_SLEEP_TALK",["semantic"]=97},["3"]={["native"]=97,["semantic"]=97}}, + ["EFFECT_SNORE"] = {["2"]={["native"]="EFFECT_SNORE",["semantic"]=92},["3"]={["native"]=92,["semantic"]=92}}, + ["EFFECT_SPIKES"] = {["2"]={["native"]="EFFECT_SPIKES",["semantic"]=112},["3"]={["native"]=112,["semantic"]=112}}, + ["EFFECT_SPITE"] = {["2"]={["native"]="EFFECT_SPITE",["semantic"]=100},["3"]={["native"]=100,["semantic"]=100}}, + ["EFFECT_SP_ATK_DOWN_HIT"] = {["3"]={["native"]=71,["semantic"]=71}}, + ["EFFECT_SP_ATK_UP_2"] = {["3"]={["native"]=53,["semantic"]=53}}, + ["EFFECT_STOMP"] = {["3"]={["native"]=150,["semantic"]=150}}, + ["EFFECT_SUNNY_DAY"] = {["2"]={["native"]="EFFECT_SUNNY_DAY",["semantic"]=137},["3"]={["native"]=137,["semantic"]=137}}, + ["EFFECT_SWAGGER"] = {["3"]={["native"]=118,["semantic"]=118}}, + ["EFFECT_SYNTHESIS"] = {["2"]={["native"]="EFFECT_SYNTHESIS",["semantic"]=133},["3"]={["native"]=133,["semantic"]=133}}, + ["EFFECT_THIEF"] = {["2"]={["native"]="EFFECT_THIEF",["semantic"]=105},["3"]={["native"]=105,["semantic"]=105}}, + ["EFFECT_THUNDER"] = {["3"]={["native"]=152,["semantic"]=152}}, + ["EFFECT_TRIPLE_KICK"] = {["2"]={["native"]="EFFECT_TRIPLE_KICK",["semantic"]=104},["3"]={["native"]=104,["semantic"]=104}}, + ["EFFECT_TRI_ATTACK"] = {["3"]={["native"]=36,["semantic"]=36}}, + ["EFFECT_TWISTER"] = {["3"]={["native"]=146,["semantic"]=146}}, + ["ENDEAVOR"] = {["3"]={["native"]=189,["semantic"]=189}}, + ["ERUPTION"] = {["3"]={["native"]=190,["semantic"]=190}}, + ["EVASION_DOWN1_EFFECT"] = {["2"]={["chance"]=0,["native"]="EFFECT_EVASION_DOWN",["semantic"]=24},["3"]={["chance"]=0,["native"]=24,["semantic"]=24}}, + ["EVASION_UP1_EFFECT"] = {["1"]={["native"]="EVASION_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_EVASION_UP",["semantic"]=16},["3"]={["chance"]=0,["native"]=16,["semantic"]=16}}, + ["EXPLODE_EFFECT"] = {["1"]={["native"]="EXPLODE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SELFDESTRUCT",["semantic"]=7},["3"]={["chance"]=0,["native"]=7,["semantic"]=7}}, + ["FACADE"] = {["3"]={["native"]=169,["semantic"]=169}}, + ["FAKE_OUT"] = {["3"]={["native"]=158,["semantic"]=158}}, + ["FLAIL"] = {["3"]={["native"]=99,["semantic"]=99}}, + ["FLATTER"] = {["3"]={["native"]=166,["semantic"]=166}}, + ["FLINCH_MINIMIZE_HIT"] = {["3"]={["native"]=150,["semantic"]=150}}, + ["FLINCH_SIDE_EFFECT1"] = {["1"]={["native"]="FLINCH_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_FLINCH_HIT",["semantic"]=31},["3"]={["chance"]=10,["native"]=31,["semantic"]=31}}, + ["FLINCH_SIDE_EFFECT2"] = {["1"]={["native"]="FLINCH_SIDE_EFFECT2"},["2"]={["chance"]=30,["native"]="EFFECT_FLINCH_HIT",["semantic"]=31},["3"]={["chance"]=30,["native"]=31,["semantic"]=31}}, + ["FLY_EFFECT"] = {["1"]={["native"]="FLY_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_FLY",["semantic"]=155},["3"]={["chance"]=0,["native"]=155,["semantic"]=155}}, + ["FOCUS_ENERGY_EFFECT"] = {["1"]={["native"]="FOCUS_ENERGY_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_FOCUS_ENERGY",["semantic"]=47},["3"]={["chance"]=0,["native"]=47,["semantic"]=47}}, + ["FOCUS_PUNCH"] = {["3"]={["native"]=170,["semantic"]=170}}, + ["FOLLOW_ME"] = {["3"]={["native"]=172,["semantic"]=172}}, + ["FREEZE_SIDE_EFFECT1"] = {["1"]={["native"]="FREEZE_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_FREEZE_HIT",["semantic"]=5},["3"]={["chance"]=10,["native"]=5,["semantic"]=5}}, + ["FREEZE_SIDE_EFFECT2"] = {["2"]={["chance"]=30,["native"]="EFFECT_FREEZE_HIT",["semantic"]=5},["3"]={["chance"]=30,["native"]=5,["semantic"]=5}}, + ["GRUDGE"] = {["3"]={["native"]=194,["semantic"]=194}}, + ["GUST"] = {["3"]={["native"]=149,["semantic"]=149}}, + ["HAIL"] = {["3"]={["native"]=164,["semantic"]=164}}, + ["HAZE_EFFECT"] = {["1"]={["native"]="HAZE_EFFECT"},["3"]={["chance"]=0,["native"]=25,["semantic"]=25}}, + ["HEAL_BELL"] = {["3"]={["native"]=102,["semantic"]=102}}, + ["HEAL_EFFECT"] = {["1"]={["native"]="HEAL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_HEAL",["semantic"]=32},["3"]={["chance"]=0,["native"]=32,["semantic"]=32}}, + ["HELPING_HAND"] = {["3"]={["native"]=176,["semantic"]=176}}, + ["HIT"] = {["1"]={["native"]="NO_ADDITIONAL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_NORMAL_HIT",["semantic"]=0},["3"]={["chance"]=0,["native"]=0,["semantic"]=0}}, + ["HYPER_BEAM_EFFECT"] = {["1"]={["native"]="HYPER_BEAM_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_HYPER_BEAM",["semantic"]=80},["3"]={["chance"]=0,["native"]=80,["semantic"]=80}}, + ["IMPRISON"] = {["3"]={["native"]=192,["semantic"]=192}}, + ["INGRAIN"] = {["3"]={["native"]=181,["semantic"]=181}}, + ["JUMP_KICK_EFFECT"] = {["1"]={["native"]="JUMP_KICK_EFFECT"},["3"]={["chance"]=0,["native"]=45,["semantic"]=45}}, + ["KNOCK_OFF"] = {["3"]={["native"]=188,["semantic"]=188}}, + ["LEECH_SEED_EFFECT"] = {["1"]={["native"]="LEECH_SEED_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LEECH_SEED",["semantic"]=84},["3"]={["chance"]=0,["native"]=84,["semantic"]=84}}, + ["LIGHT_SCREEN_EFFECT"] = {["1"]={["native"]="LIGHT_SCREEN_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LIGHT_SCREEN",["semantic"]=35},["3"]={["chance"]=0,["native"]=35,["semantic"]=35}}, + ["LOW_KICK"] = {["3"]={["native"]=196,["semantic"]=196}}, + ["MAGIC_COAT"] = {["3"]={["native"]=183,["semantic"]=183}}, + ["MEMENTO"] = {["3"]={["native"]=168,["semantic"]=168}}, + ["METRONOME_EFFECT"] = {["1"]={["native"]="METRONOME_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_METRONOME",["semantic"]=83},["3"]={["chance"]=0,["native"]=83,["semantic"]=83}}, + ["MIMIC_EFFECT"] = {["1"]={["native"]="MIMIC_EFFECT"},["3"]={["chance"]=0,["native"]=82,["semantic"]=82}}, + ["MINIMIZE"] = {["3"]={["native"]=108,["semantic"]=108}}, + ["MIRROR_MOVE_EFFECT"] = {["1"]={["native"]="MIRROR_MOVE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_MIRROR_MOVE",["semantic"]=9},["3"]={["chance"]=0,["native"]=9,["semantic"]=9}}, + ["MIST_EFFECT"] = {["1"]={["native"]="MIST_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_MIST",["semantic"]=46},["3"]={["chance"]=0,["native"]=46,["semantic"]=46}}, + ["MUD_SPORT"] = {["3"]={["native"]=201,["semantic"]=201}}, + ["NATURE_POWER"] = {["3"]={["native"]=173,["semantic"]=173}}, + ["NO_ADDITIONAL_EFFECT"] = {["1"]={["native"]="NO_ADDITIONAL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_NORMAL_HIT",["semantic"]=0},["3"]={["chance"]=0,["native"]=0,["semantic"]=0}}, + ["OHKO_EFFECT"] = {["1"]={["native"]="OHKO_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_OHKO",["semantic"]=38},["3"]={["chance"]=0,["native"]=38,["semantic"]=38}}, + ["OVERHEAT"] = {["3"]={["native"]=204,["semantic"]=204}}, + ["PAIN_SPLIT"] = {["3"]={["native"]=91,["semantic"]=91}}, + ["PARALYZE_EFFECT"] = {["1"]={["native"]="PARALYZE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_PARALYZE",["semantic"]=67},["3"]={["chance"]=0,["native"]=67,["semantic"]=67}}, + ["PARALYZE_SIDE_EFFECT1"] = {["1"]={["native"]="PARALYZE_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_PARALYZE_HIT",["semantic"]=6},["3"]={["chance"]=10,["native"]=6,["semantic"]=6}}, + ["PARALYZE_SIDE_EFFECT2"] = {["1"]={["native"]="PARALYZE_SIDE_EFFECT2"},["2"]={["chance"]=30,["native"]="EFFECT_PARALYZE_HIT",["semantic"]=6},["3"]={["chance"]=30,["native"]=6,["semantic"]=6}}, + ["PAY_DAY_EFFECT"] = {["1"]={["native"]="PAY_DAY_EFFECT"},["2"]={["chance"]=100,["native"]="EFFECT_PAY_DAY",["semantic"]=34},["3"]={["chance"]=100,["native"]=34,["semantic"]=34}}, + ["POISON_EFFECT"] = {["1"]={["native"]="POISON_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_POISON",["semantic"]=66},["3"]={["chance"]=0,["native"]=66,["semantic"]=66}}, + ["POISON_FANG"] = {["3"]={["native"]=202,["semantic"]=202}}, + ["POISON_SIDE_EFFECT1"] = {["1"]={["native"]="POISON_SIDE_EFFECT1"},["2"]={["chance"]=20,["native"]="EFFECT_POISON_HIT",["semantic"]=2},["3"]={["chance"]=20,["native"]=2,["semantic"]=2}}, + ["POISON_SIDE_EFFECT2"] = {["1"]={["native"]="POISON_SIDE_EFFECT2"},["2"]={["chance"]=40,["native"]="EFFECT_POISON_HIT",["semantic"]=2},["3"]={["chance"]=40,["native"]=2,["semantic"]=2}}, + ["POISON_TAIL"] = {["3"]={["native"]=209,["semantic"]=209}}, + ["PRESENT"] = {["3"]={["native"]=122,["semantic"]=122}}, + ["PURSUIT"] = {["3"]={["native"]=128,["semantic"]=128}}, + ["RAGE_EFFECT"] = {["1"]={["native"]="RAGE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RAGE",["semantic"]=81},["3"]={["chance"]=0,["native"]=81,["semantic"]=81}}, + ["RAPID_SPIN"] = {["3"]={["native"]=129,["semantic"]=129}}, + ["RECOIL_EFFECT"] = {["1"]={["native"]="RECOIL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RECOIL_HIT",["semantic"]=48},["3"]={["chance"]=0,["native"]=48,["semantic"]=48}}, + ["RECYCLE"] = {["3"]={["native"]=184,["semantic"]=184}}, + ["REFLECT_EFFECT"] = {["1"]={["native"]="REFLECT_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_REFLECT",["semantic"]=65},["3"]={["chance"]=0,["native"]=65,["semantic"]=65}}, + ["REFRESH"] = {["3"]={["native"]=193,["semantic"]=193}}, + ["REVENGE"] = {["3"]={["native"]=185,["semantic"]=185}}, + ["ROLE_PLAY"] = {["3"]={["native"]=178,["semantic"]=178}}, + ["SECRET_POWER"] = {["3"]={["native"]=197,["semantic"]=197}}, + ["SKILL_SWAP"] = {["3"]={["native"]=191,["semantic"]=191}}, + ["SKY_UPPERCUT"] = {["3"]={["native"]=207,["semantic"]=207}}, + ["SLEEP"] = {["1"]={["native"]="SLEEP_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SLEEP",["semantic"]=1},["3"]={["chance"]=0,["native"]=1,["semantic"]=1}}, + ["SLEEP_EFFECT"] = {["1"]={["native"]="SLEEP_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SLEEP",["semantic"]=1},["3"]={["chance"]=0,["native"]=1,["semantic"]=1}}, + ["SMELLINGSALT"] = {["3"]={["native"]=171,["semantic"]=171}}, + ["SNATCH"] = {["3"]={["native"]=195,["semantic"]=195}}, + ["SOFTBOILED"] = {["3"]={["native"]=157,["semantic"]=157}}, + ["SPECIAL_ATTACK_DOWN_HIT"] = {["3"]={["native"]=71,["semantic"]=71}}, + ["SPECIAL_ATTACK_UP_2"] = {["3"]={["native"]=53,["semantic"]=53}}, + ["SPECIAL_DAMAGE_EFFECT"] = {["1"]={["native"]="SPECIAL_DAMAGE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LEVEL_DAMAGE",["semantic"]=87},["3"]={["chance"]=0,["native"]=87,["semantic"]=87}}, + ["SPECIAL_DOWN2_EFFECT"] = {["3"]={["chance"]=0,["native"]=62,["semantic"]=62}}, + ["SPECIAL_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="SPECIAL_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_SP_DEF_DOWN_HIT",["semantic"]=72},["3"]={["chance"]=33,["native"]=72,["semantic"]=72}}, + ["SPECIAL_UP1_EFFECT"] = {["1"]={["native"]="SPECIAL_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SP_ATK_UP",["semantic"]=13},["3"]={["chance"]=0,["native"]=13,["semantic"]=13}}, + ["SPECIAL_UP2_EFFECT"] = {["1"]={["native"]="SPECIAL_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SP_DEF_UP_2",["semantic"]=54},["3"]={["chance"]=0,["native"]=54,["semantic"]=54}}, + ["SPEED_DOWN1_EFFECT"] = {["1"]={["native"]="SPEED_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SPEED_DOWN",["semantic"]=20},["3"]={["chance"]=0,["native"]=20,["semantic"]=20}}, + ["SPEED_DOWN2_EFFECT"] = {["2"]={["chance"]=0,["native"]="EFFECT_SPEED_DOWN_2",["semantic"]=60},["3"]={["chance"]=0,["native"]=60,["semantic"]=60}}, + ["SPEED_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="SPEED_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_SPEED_DOWN_HIT",["semantic"]=70},["3"]={["chance"]=33,["native"]=70,["semantic"]=70}}, + ["SPEED_UP2_EFFECT"] = {["1"]={["native"]="SPEED_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SPEED_UP_2",["semantic"]=52},["3"]={["chance"]=0,["native"]=52,["semantic"]=52}}, + ["SPIT_UP"] = {["3"]={["native"]=161,["semantic"]=161}}, + ["SPLASH_EFFECT"] = {["1"]={["native"]="SPLASH_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SPLASH",["semantic"]=85},["3"]={["chance"]=0,["native"]=85,["semantic"]=85}}, + ["STOCKPILE"] = {["3"]={["native"]=160,["semantic"]=160}}, + ["SUBSTITUTE_EFFECT"] = {["1"]={["native"]="SUBSTITUTE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SUBSTITUTE",["semantic"]=79},["3"]={["chance"]=0,["native"]=79,["semantic"]=79}}, + ["SUPERPOWER"] = {["3"]={["native"]=182,["semantic"]=182}}, + ["SUPER_FANG_EFFECT"] = {["1"]={["native"]="SUPER_FANG_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SUPER_FANG",["semantic"]=40},["3"]={["chance"]=0,["native"]=40,["semantic"]=40}}, + ["SWAGGER"] = {["3"]={["native"]=118,["semantic"]=118}}, + ["SWALLOW"] = {["3"]={["native"]=162,["semantic"]=162}}, + ["SWIFT_EFFECT"] = {["1"]={["native"]="SWIFT_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ALWAYS_HIT",["semantic"]=17},["3"]={["chance"]=0,["native"]=17,["semantic"]=17}}, + ["SWITCH_AND_TELEPORT_EFFECT"] = {["1"]={["native"]="SWITCH_AND_TELEPORT_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_FORCE_SWITCH",["semantic"]=28},["3"]={["chance"]=0,["native"]=28,["semantic"]=28}}, + ["TAUNT"] = {["3"]={["native"]=175,["semantic"]=175}}, + ["TEETER_DANCE"] = {["3"]={["native"]=199,["semantic"]=199}}, + ["THRASH_PETAL_DANCE_EFFECT"] = {["1"]={["native"]="THRASH_PETAL_DANCE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RAMPAGE",["semantic"]=27},["3"]={["chance"]=0,["native"]=27,["semantic"]=27}}, + ["THUNDER"] = {["3"]={["native"]=152,["semantic"]=152}}, + ["TICKLE"] = {["3"]={["native"]=205,["semantic"]=205}}, + ["TORMENT"] = {["3"]={["native"]=165,["semantic"]=165}}, + ["TRANSFORM_EFFECT"] = {["1"]={["native"]="TRANSFORM_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_TRANSFORM",["semantic"]=57},["3"]={["chance"]=0,["native"]=57,["semantic"]=57}}, + ["TRAPPING_EFFECT"] = {["1"]={["native"]="TRAPPING_EFFECT"},["2"]={["chance"]=100,["native"]="EFFECT_TRAP_TARGET",["semantic"]=42},["3"]={["chance"]=100,["native"]=42,["semantic"]=42}}, + ["TRICK"] = {["3"]={["native"]=177,["semantic"]=177}}, + ["TRI_ATTACK"] = {["3"]={["native"]=36,["semantic"]=36}}, + ["TWINEEDLE_EFFECT"] = {["1"]={["native"]="TWINEEDLE_EFFECT"},["3"]={["chance"]=20,["native"]=77,["semantic"]=77}}, + ["TWISTER"] = {["3"]={["native"]=146,["semantic"]=146}}, + ["TWO_TO_FIVE_ATTACKS_EFFECT"] = {["1"]={["native"]="TWO_TO_FIVE_ATTACKS_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_MULTI_HIT",["semantic"]=29},["3"]={["chance"]=0,["native"]=29,["semantic"]=29}}, + ["UPROAR"] = {["3"]={["native"]=159,["semantic"]=159}}, + ["VITAL_THROW"] = {["3"]={["native"]=78,["semantic"]=78}}, + ["WATER_SPORT"] = {["3"]={["native"]=210,["semantic"]=210}}, + ["WEATHER_BALL"] = {["3"]={["native"]=203,["semantic"]=203}}, + ["WISH"] = {["3"]={["native"]=179,["semantic"]=179}}, + ["YAWN"] = {["3"]={["native"]=187,["semantic"]=187}}, + }, + ["hostMapSha256"] = "640d77b2a86740d18458262bdf424070ad9c52164365dbdb4879f3af2ba038ee", + ["native3"] = { + ["0"] = {"NO_ADDITIONAL_EFFECT"}, + ["1"] = {"SLEEP_EFFECT"}, + ["10"] = {"ATTACK_UP1_EFFECT"}, + ["100"] = {"SPITE_EFFECT"}, + ["101"] = {"FALSE_SWIPE_EFFECT"}, + ["102"] = {"HEAL_BELL_EFFECT"}, + ["103"] = {"NO_ADDITIONAL_EFFECT"}, + ["104"] = {"TRIPLE_KICK_EFFECT"}, + ["105"] = {"THIEF_EFFECT"}, + ["106"] = {"MEAN_LOOK_EFFECT"}, + ["107"] = {"NIGHTMARE_EFFECT"}, + ["108"] = {"EVASION_UP1_EFFECT"}, + ["109"] = {"CURSE_EFFECT"}, + ["11"] = {"DEFENSE_UP1_EFFECT"}, + ["111"] = {"PROTECT_EFFECT"}, + ["112"] = {"SPIKES_EFFECT"}, + ["113"] = {"FORESIGHT_EFFECT"}, + ["114"] = {"PERISH_SONG_EFFECT"}, + ["115"] = {"SANDSTORM_EFFECT"}, + ["116"] = {"ENDURE_EFFECT"}, + ["117"] = {"ROLLOUT_EFFECT"}, + ["118"] = {"SWAGGER_EFFECT"}, + ["119"] = {"FURY_CUTTER_EFFECT"}, + ["120"] = {"ATTRACT_EFFECT"}, + ["121"] = {"RETURN_EFFECT"}, + ["122"] = {"PRESENT_EFFECT"}, + ["123"] = {"FRUSTRATION_EFFECT"}, + ["124"] = {"SAFEGUARD_EFFECT"}, + ["125"] = {"BURN_SIDE_EFFECT1","BURN_SIDE_EFFECT2"}, + ["126"] = {"MAGNITUDE_EFFECT"}, + ["127"] = {"BATON_PASS_EFFECT"}, + ["128"] = {"PURSUIT_EFFECT"}, + ["129"] = {"RAPID_SPIN_EFFECT"}, + ["13"] = {"SP_ATK_UP1_EFFECT"}, + ["130"] = {"SPECIAL_DAMAGE_EFFECT"}, + ["132"] = {"MORNING_SUN_EFFECT"}, + ["133"] = {"SYNTHESIS_EFFECT"}, + ["134"] = {"MOONLIGHT_EFFECT"}, + ["135"] = {"HIDDEN_POWER_EFFECT"}, + ["136"] = {"RAIN_DANCE_EFFECT"}, + ["137"] = {"SUNNY_DAY_EFFECT"}, + ["138"] = {"DEFENSE_UP_HIT_EFFECT"}, + ["139"] = {"ATTACK_UP_HIT_EFFECT"}, + ["140"] = {"ALL_UP_HIT_EFFECT"}, + ["142"] = {"BELLY_DRUM_EFFECT"}, + ["143"] = {"PSYCH_UP_EFFECT"}, + ["144"] = {"MIRROR_COAT_EFFECT"}, + ["145"] = {"CHARGE_EFFECT"}, + ["146"] = {"FLINCH_SIDE_EFFECT1"}, + ["147"] = {"EARTHQUAKE_EFFECT"}, + ["148"] = {"FUTURE_SIGHT_EFFECT"}, + ["149"] = {"GUST_EFFECT"}, + ["150"] = {"FLINCH_SIDE_EFFECT2"}, + ["151"] = {"SOLARBEAM_EFFECT"}, + ["152"] = {"THUNDER_EFFECT"}, + ["153"] = {"SWITCH_AND_TELEPORT_EFFECT"}, + ["154"] = {"BEAT_UP_EFFECT"}, + ["155"] = {"FLY_EFFECT"}, + ["156"] = {"DEFENSE_UP1_EFFECT"}, + ["157"] = {"HEAL_EFFECT"}, + ["158"] = {"FAKE_OUT_EFFECT"}, + ["159"] = {"UPROAR_EFFECT"}, + ["16"] = {"EVASION_UP1_EFFECT"}, + ["160"] = {"STOCKPILE_EFFECT"}, + ["161"] = {"SPIT_UP_EFFECT"}, + ["162"] = {"SWALLOW_EFFECT"}, + ["164"] = {"HAIL_EFFECT"}, + ["165"] = {"TORMENT_EFFECT"}, + ["166"] = {"FLATTER_EFFECT"}, + ["167"] = {"WILL_O_WISP_EFFECT"}, + ["168"] = {"MEMENTO_EFFECT"}, + ["169"] = {"FACADE_EFFECT"}, + ["17"] = {"SWIFT_EFFECT"}, + ["170"] = {"FOCUS_PUNCH_EFFECT"}, + ["171"] = {"SMELLINGSALT_EFFECT"}, + ["172"] = {"FOLLOW_ME_EFFECT"}, + ["173"] = {"NATURE_POWER_EFFECT"}, + ["174"] = {"CHARGE_UP_EFFECT"}, + ["175"] = {"TAUNT_EFFECT"}, + ["176"] = {"HELPING_HAND_EFFECT"}, + ["177"] = {"TRICK_EFFECT"}, + ["178"] = {"ROLE_PLAY_EFFECT"}, + ["179"] = {"WISH_EFFECT"}, + ["18"] = {"ATTACK_DOWN1_EFFECT"}, + ["180"] = {"ASSIST_EFFECT"}, + ["181"] = {"INGRAIN_EFFECT"}, + ["182"] = {"SUPERPOWER_EFFECT"}, + ["183"] = {"MAGIC_COAT_EFFECT"}, + ["184"] = {"RECYCLE_EFFECT"}, + ["185"] = {"REVENGE_EFFECT"}, + ["186"] = {"BRICK_BREAK_EFFECT"}, + ["187"] = {"YAWN_EFFECT"}, + ["188"] = {"KNOCK_OFF_EFFECT"}, + ["189"] = {"ENDEAVOR_EFFECT"}, + ["19"] = {"DEFENSE_DOWN1_EFFECT"}, + ["190"] = {"ERUPTION_EFFECT"}, + ["191"] = {"SKILL_SWAP_EFFECT"}, + ["192"] = {"IMPRISON_EFFECT"}, + ["193"] = {"REFRESH_EFFECT"}, + ["194"] = {"GRUDGE_EFFECT"}, + ["195"] = {"SNATCH_EFFECT"}, + ["196"] = {"LOW_KICK_EFFECT"}, + ["197"] = {"SECRET_POWER_EFFECT"}, + ["198"] = {"RECOIL_EFFECT"}, + ["199"] = {"TEETER_DANCE_EFFECT"}, + ["2"] = {"POISON_SIDE_EFFECT2"}, + ["20"] = {"SPEED_DOWN1_EFFECT"}, + ["200"] = {"BLAZE_KICK_EFFECT"}, + ["201"] = {"MUD_SPORT_EFFECT"}, + ["202"] = {"POISON_FANG_EFFECT"}, + ["203"] = {"WEATHER_BALL_EFFECT"}, + ["204"] = {"OVERHEAT_EFFECT"}, + ["205"] = {"TICKLE_EFFECT"}, + ["206"] = {"COSMIC_POWER_EFFECT"}, + ["207"] = {"SKY_UPPERCUT_EFFECT"}, + ["208"] = {"BULK_UP_EFFECT"}, + ["209"] = {"POISON_TAIL_EFFECT"}, + ["210"] = {"WATER_SPORT_EFFECT"}, + ["211"] = {"CALM_MIND_EFFECT"}, + ["212"] = {"DRAGON_DANCE_EFFECT"}, + ["213"] = {"CAMOUFLAGE_EFFECT"}, + ["23"] = {"ACCURACY_DOWN1_EFFECT"}, + ["24"] = {"EVASION_DOWN1_EFFECT"}, + ["25"] = {"HAZE_EFFECT"}, + ["26"] = {"BIDE_EFFECT"}, + ["27"] = {"THRASH_PETAL_DANCE_EFFECT"}, + ["28"] = {"SWITCH_AND_TELEPORT_EFFECT"}, + ["29"] = {"TWO_TO_FIVE_ATTACKS_EFFECT"}, + ["3"] = {"DRAIN_HP_EFFECT"}, + ["30"] = {"CONVERSION_EFFECT"}, + ["31"] = {"FLINCH_SIDE_EFFECT1","FLINCH_SIDE_EFFECT2"}, + ["32"] = {"HEAL_EFFECT"}, + ["33"] = {"POISON_EFFECT"}, + ["34"] = {"PAY_DAY_EFFECT"}, + ["35"] = {"LIGHT_SCREEN_EFFECT"}, + ["36"] = {"TRI_ATTACK_EFFECT"}, + ["37"] = {"HEAL_EFFECT"}, + ["38"] = {"OHKO_EFFECT"}, + ["39"] = {"CHARGE_EFFECT"}, + ["4"] = {"BURN_SIDE_EFFECT1"}, + ["40"] = {"SUPER_FANG_EFFECT"}, + ["41"] = {"SPECIAL_DAMAGE_EFFECT"}, + ["42"] = {"TRAPPING_EFFECT"}, + ["43"] = {"HIGH_CRITICAL_EFFECT"}, + ["44"] = {"ATTACK_TWICE_EFFECT"}, + ["45"] = {"JUMP_KICK_EFFECT"}, + ["46"] = {"MIST_EFFECT"}, + ["47"] = {"FOCUS_ENERGY_EFFECT"}, + ["48"] = {"RECOIL_EFFECT"}, + ["49"] = {"CONFUSION_EFFECT"}, + ["5"] = {"FREEZE_SIDE_EFFECT1"}, + ["50"] = {"ATTACK_UP2_EFFECT"}, + ["51"] = {"DEFENSE_UP2_EFFECT"}, + ["52"] = {"SPEED_UP2_EFFECT"}, + ["53"] = {"SP_ATK_UP2_EFFECT"}, + ["54"] = {"SP_DEF_UP2_EFFECT"}, + ["57"] = {"TRANSFORM_EFFECT"}, + ["58"] = {"ATTACK_DOWN2_EFFECT"}, + ["59"] = {"DEFENSE_DOWN2_EFFECT"}, + ["6"] = {"PARALYZE_SIDE_EFFECT1","PARALYZE_SIDE_EFFECT2"}, + ["60"] = {"SPEED_DOWN2_EFFECT"}, + ["62"] = {"SP_DEF_DOWN2_EFFECT"}, + ["65"] = {"REFLECT_EFFECT"}, + ["66"] = {"POISON_EFFECT"}, + ["67"] = {"PARALYZE_EFFECT"}, + ["68"] = {"ATTACK_DOWN_SIDE_EFFECT"}, + ["69"] = {"DEFENSE_DOWN_SIDE_EFFECT"}, + ["7"] = {"EXPLODE_EFFECT"}, + ["70"] = {"SPEED_DOWN_SIDE_EFFECT"}, + ["71"] = {"SP_ATK_DOWN_HIT_EFFECT"}, + ["72"] = {"SP_DEF_DOWN_SIDE_EFFECT"}, + ["73"] = {"ACCURACY_DOWN_SIDE_EFFECT"}, + ["75"] = {"CHARGE_EFFECT"}, + ["76"] = {"CONFUSION_SIDE_EFFECT"}, + ["77"] = {"TWINEEDLE_EFFECT"}, + ["78"] = {"SWIFT_EFFECT"}, + ["79"] = {"SUBSTITUTE_EFFECT"}, + ["8"] = {"DREAM_EATER_EFFECT"}, + ["80"] = {"HYPER_BEAM_EFFECT"}, + ["81"] = {"RAGE_EFFECT"}, + ["82"] = {"MIMIC_EFFECT"}, + ["83"] = {"METRONOME_EFFECT"}, + ["84"] = {"LEECH_SEED_EFFECT"}, + ["85"] = {"SPLASH_EFFECT"}, + ["86"] = {"DISABLE_EFFECT"}, + ["87"] = {"SPECIAL_DAMAGE_EFFECT"}, + ["88"] = {"SPECIAL_DAMAGE_EFFECT"}, + ["89"] = {"COUNTER_EFFECT"}, + ["9"] = {"MIRROR_MOVE_EFFECT"}, + ["90"] = {"ENCORE_EFFECT"}, + ["91"] = {"PAIN_SPLIT_EFFECT"}, + ["92"] = {"SNORE_EFFECT"}, + ["93"] = {"CONVERSION2_EFFECT"}, + ["94"] = {"LOCK_ON_EFFECT"}, + ["95"] = {"SKETCH_EFFECT"}, + ["97"] = {"SLEEP_TALK_EFFECT"}, + ["98"] = {"DESTINY_BOND_EFFECT"}, + ["99"] = {"REVERSAL_EFFECT"}, + }, + ["read3"] = { + ["0"] = "NO_ADDITIONAL_EFFECT", + ["1"] = "SLEEP_EFFECT", + ["10"] = "ATTACK_UP1_EFFECT", + ["100"] = "EFFECT_SPITE", + ["101"] = "EFFECT_FALSE_SWIPE", + ["102"] = "HEAL_BELL", + ["103"] = "EFFECT_PRIORITY_HIT", + ["104"] = "EFFECT_TRIPLE_KICK", + ["105"] = "EFFECT_THIEF", + ["106"] = "EFFECT_MEAN_LOOK", + ["107"] = "EFFECT_NIGHTMARE", + ["108"] = "MINIMIZE", + ["109"] = "EFFECT_CURSE", + ["11"] = "DEFENSE_UP1_EFFECT", + ["110"] = "native:3:110", + ["111"] = "EFFECT_PROTECT", + ["112"] = "EFFECT_SPIKES", + ["113"] = "EFFECT_FORESIGHT", + ["114"] = "EFFECT_PERISH_SONG", + ["115"] = "EFFECT_SANDSTORM", + ["116"] = "EFFECT_ENDURE", + ["117"] = "EFFECT_ROLLOUT", + ["118"] = "SWAGGER", + ["119"] = "EFFECT_FURY_CUTTER", + ["12"] = "native:3:SPEED_UP", + ["120"] = "EFFECT_ATTRACT", + ["121"] = "EFFECT_RETURN", + ["122"] = "PRESENT", + ["123"] = "EFFECT_FRUSTRATION", + ["124"] = "EFFECT_SAFEGUARD", + ["125"] = "EFFECT_SACRED_FIRE", + ["126"] = "EFFECT_MAGNITUDE", + ["127"] = "EFFECT_BATON_PASS", + ["128"] = "PURSUIT", + ["129"] = "RAPID_SPIN", + ["13"] = "SPECIAL_UP1_EFFECT", + ["130"] = "native:3:SONICBOOM", + ["131"] = "native:3:131", + ["132"] = "EFFECT_MORNING_SUN", + ["133"] = "EFFECT_SYNTHESIS", + ["134"] = "EFFECT_MOONLIGHT", + ["135"] = "native:3:HIDDEN_POWER", + ["136"] = "EFFECT_RAIN_DANCE", + ["137"] = "EFFECT_SUNNY_DAY", + ["138"] = "EFFECT_DEFENSE_UP_HIT", + ["139"] = "EFFECT_ATTACK_UP_HIT", + ["14"] = "native:3:SPECIAL_DEFENSE_UP", + ["140"] = "EFFECT_ALL_UP_HIT", + ["141"] = "native:3:141", + ["142"] = "EFFECT_BELLY_DRUM", + ["143"] = "EFFECT_PSYCH_UP", + ["144"] = "EFFECT_MIRROR_COAT", + ["145"] = "native:3:SKULL_BASH", + ["146"] = "TWISTER", + ["147"] = "EARTHQUAKE", + ["148"] = "EFFECT_FUTURE_SIGHT", + ["149"] = "GUST", + ["15"] = "native:3:ACCURACY_UP", + ["150"] = "FLINCH_MINIMIZE_HIT", + ["151"] = "native:3:SOLAR_BEAM", + ["152"] = "THUNDER", + ["153"] = "native:3:TELEPORT", + ["154"] = "EFFECT_BEAT_UP", + ["155"] = "FLY_EFFECT", + ["156"] = "EFFECT_DEFENSE_CURL", + ["157"] = "SOFTBOILED", + ["158"] = "FAKE_OUT", + ["159"] = "UPROAR", + ["16"] = "EVASION_UP1_EFFECT", + ["160"] = "STOCKPILE", + ["161"] = "SPIT_UP", + ["162"] = "SWALLOW", + ["163"] = "native:3:163", + ["164"] = "HAIL", + ["165"] = "TORMENT", + ["166"] = "FLATTER", + ["167"] = "EFFECT_BURN", + ["168"] = "MEMENTO", + ["169"] = "FACADE", + ["17"] = "SWIFT_EFFECT", + ["170"] = "FOCUS_PUNCH", + ["171"] = "SMELLINGSALT", + ["172"] = "FOLLOW_ME", + ["173"] = "NATURE_POWER", + ["174"] = "CHARGE", + ["175"] = "TAUNT", + ["176"] = "HELPING_HAND", + ["177"] = "TRICK", + ["178"] = "ROLE_PLAY", + ["179"] = "WISH", + ["18"] = "ATTACK_DOWN1_EFFECT", + ["180"] = "ASSIST", + ["181"] = "INGRAIN", + ["182"] = "SUPERPOWER", + ["183"] = "MAGIC_COAT", + ["184"] = "RECYCLE", + ["185"] = "REVENGE", + ["186"] = "BRICK_BREAK", + ["187"] = "YAWN", + ["188"] = "KNOCK_OFF", + ["189"] = "ENDEAVOR", + ["19"] = "DEFENSE_DOWN1_EFFECT", + ["190"] = "ERUPTION", + ["191"] = "SKILL_SWAP", + ["192"] = "IMPRISON", + ["193"] = "REFRESH", + ["194"] = "GRUDGE", + ["195"] = "SNATCH", + ["196"] = "LOW_KICK", + ["197"] = "SECRET_POWER", + ["198"] = "DOUBLE_EDGE", + ["199"] = "TEETER_DANCE", + ["2"] = "POISON_SIDE_EFFECT1", + ["20"] = "SPEED_DOWN1_EFFECT", + ["200"] = "BLAZE_KICK", + ["201"] = "MUD_SPORT", + ["202"] = "POISON_FANG", + ["203"] = "WEATHER_BALL", + ["204"] = "OVERHEAT", + ["205"] = "TICKLE", + ["206"] = "COSMIC_POWER", + ["207"] = "SKY_UPPERCUT", + ["208"] = "BULK_UP", + ["209"] = "POISON_TAIL", + ["21"] = "native:3:SPECIAL_ATTACK_DOWN", + ["210"] = "WATER_SPORT", + ["211"] = "CALM_MIND", + ["212"] = "DRAGON_DANCE", + ["213"] = "CAMOUFLAGE", + ["214"] = "native:3:214", + ["215"] = "native:3:215", + ["216"] = "native:3:216", + ["217"] = "native:3:217", + ["218"] = "native:3:218", + ["219"] = "native:3:219", + ["22"] = "native:3:SPECIAL_DEFENSE_DOWN", + ["220"] = "native:3:220", + ["221"] = "native:3:221", + ["222"] = "native:3:222", + ["223"] = "native:3:223", + ["224"] = "native:3:224", + ["225"] = "native:3:225", + ["226"] = "native:3:226", + ["227"] = "native:3:227", + ["228"] = "native:3:228", + ["229"] = "native:3:229", + ["23"] = "ACCURACY_DOWN1_EFFECT", + ["230"] = "native:3:230", + ["231"] = "native:3:231", + ["232"] = "native:3:232", + ["233"] = "native:3:233", + ["234"] = "native:3:234", + ["235"] = "native:3:235", + ["236"] = "native:3:236", + ["237"] = "native:3:237", + ["238"] = "native:3:238", + ["239"] = "native:3:239", + ["24"] = "EVASION_DOWN1_EFFECT", + ["240"] = "native:3:240", + ["241"] = "native:3:241", + ["242"] = "native:3:242", + ["243"] = "native:3:243", + ["244"] = "native:3:244", + ["245"] = "native:3:245", + ["246"] = "native:3:246", + ["247"] = "native:3:247", + ["248"] = "native:3:248", + ["249"] = "native:3:249", + ["25"] = "HAZE_EFFECT", + ["250"] = "native:3:250", + ["251"] = "native:3:251", + ["252"] = "native:3:252", + ["253"] = "native:3:253", + ["254"] = "native:3:254", + ["255"] = "native:3:255", + ["26"] = "BIDE_EFFECT", + ["27"] = "THRASH_PETAL_DANCE_EFFECT", + ["28"] = "SWITCH_AND_TELEPORT_EFFECT", + ["29"] = "TWO_TO_FIVE_ATTACKS_EFFECT", + ["3"] = "DRAIN_HP_EFFECT", + ["30"] = "CONVERSION_EFFECT", + ["31"] = "FLINCH_SIDE_EFFECT1", + ["32"] = "HEAL_EFFECT", + ["33"] = "native:3:TOXIC", + ["34"] = "PAY_DAY_EFFECT", + ["35"] = "LIGHT_SCREEN_EFFECT", + ["36"] = "TRI_ATTACK", + ["37"] = "native:3:REST", + ["38"] = "OHKO_EFFECT", + ["39"] = "CHARGE_EFFECT", + ["4"] = "BURN_SIDE_EFFECT1", + ["40"] = "SUPER_FANG_EFFECT", + ["41"] = "native:3:DRAGON_RAGE", + ["42"] = "TRAPPING_EFFECT", + ["43"] = "native:3:HIGH_CRITICAL", + ["44"] = "ATTACK_TWICE_EFFECT", + ["45"] = "JUMP_KICK_EFFECT", + ["46"] = "MIST_EFFECT", + ["47"] = "FOCUS_ENERGY_EFFECT", + ["48"] = "RECOIL_EFFECT", + ["49"] = "CONFUSION_EFFECT", + ["5"] = "FREEZE_SIDE_EFFECT1", + ["50"] = "ATTACK_UP2_EFFECT", + ["51"] = "DEFENSE_UP2_EFFECT", + ["52"] = "SPEED_UP2_EFFECT", + ["53"] = "SPECIAL_ATTACK_UP_2", + ["54"] = "SPECIAL_UP2_EFFECT", + ["55"] = "native:3:ACCURACY_UP_2", + ["56"] = "native:3:EVASION_UP_2", + ["57"] = "TRANSFORM_EFFECT", + ["58"] = "ATTACK_DOWN2_EFFECT", + ["59"] = "DEFENSE_DOWN2_EFFECT", + ["6"] = "PARALYZE_SIDE_EFFECT1", + ["60"] = "SPEED_DOWN2_EFFECT", + ["61"] = "native:3:SPECIAL_ATTACK_DOWN_2", + ["62"] = "SPECIAL_DOWN2_EFFECT", + ["63"] = "native:3:ACCURACY_DOWN_2", + ["64"] = "native:3:EVASION_DOWN_2", + ["65"] = "REFLECT_EFFECT", + ["66"] = "POISON_EFFECT", + ["67"] = "PARALYZE_EFFECT", + ["68"] = "ATTACK_DOWN_SIDE_EFFECT", + ["69"] = "DEFENSE_DOWN_SIDE_EFFECT", + ["7"] = "EXPLODE_EFFECT", + ["70"] = "SPEED_DOWN_SIDE_EFFECT", + ["71"] = "SPECIAL_ATTACK_DOWN_HIT", + ["72"] = "SPECIAL_DOWN_SIDE_EFFECT", + ["73"] = "EFFECT_ACCURACY_DOWN_HIT", + ["74"] = "native:3:EVASION_DOWN_HIT", + ["75"] = "native:3:SKY_ATTACK", + ["76"] = "CONFUSION_SIDE_EFFECT", + ["77"] = "TWINEEDLE_EFFECT", + ["78"] = "VITAL_THROW", + ["79"] = "SUBSTITUTE_EFFECT", + ["8"] = "DREAM_EATER_EFFECT", + ["80"] = "HYPER_BEAM_EFFECT", + ["81"] = "RAGE_EFFECT", + ["82"] = "MIMIC_EFFECT", + ["83"] = "METRONOME_EFFECT", + ["84"] = "LEECH_SEED_EFFECT", + ["85"] = "SPLASH_EFFECT", + ["86"] = "DISABLE_EFFECT", + ["87"] = "SPECIAL_DAMAGE_EFFECT", + ["88"] = "native:3:PSYWAVE", + ["89"] = "native:3:COUNTER", + ["9"] = "MIRROR_MOVE_EFFECT", + ["90"] = "EFFECT_ENCORE", + ["91"] = "PAIN_SPLIT", + ["92"] = "EFFECT_SNORE", + ["93"] = "CONVERSION_2", + ["94"] = "EFFECT_LOCK_ON", + ["95"] = "EFFECT_SKETCH", + ["96"] = "native:3:96", + ["97"] = "EFFECT_SLEEP_TALK", + ["98"] = "DESTINY_BOND", + ["99"] = "FLAIL", + }, + ["reference"] = { + ["commit"] = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36", + ["sha256"] = {["src/battle/g3u/EffectMap1.lua"]="15a66e3c011128a94f5d84689581c78e1f7f737da7c17d60d46b8cdf3ad22cfd",["src/battle/g3u/EffectMap2.lua"]="009ed83f5a76638050110b4304e66bc514b61ded28bbf4e4b75e5d1f448cb708",["src/core/game3/battle/effect_ids.lua"]="00ce6a4196c2b05d56c8b575ec3eb20d8e32d16a99646716f18f08624795f496",["src/mods/UnifiedEffects.lua"]="bde10a80bd8c64a71a279b8a143757a8f26f30848c095ce49b6a085690181676"}, + }, + ["reserved"] = { + ["ABSORB"] = true, + ["ACCURACY_DOWN"] = true, + ["ACCURACY_DOWN1_EFFECT"] = true, + ["ACCURACY_DOWN2_EFFECT"] = true, + ["ACCURACY_DOWN_2"] = true, + ["ACCURACY_DOWN_HIT"] = true, + ["ACCURACY_UP"] = true, + ["ACCURACY_UP1_EFFECT"] = true, + ["ACCURACY_UP2_EFFECT"] = true, + ["ACCURACY_UP_2"] = true, + ["AFTER_HIT"] = true, + ["ALL_STATS_UP_HIT"] = true, + ["ALWAYS_HIT"] = true, + ["ASSIST"] = true, + ["ATTACK_DOWN"] = true, + ["ATTACK_DOWN1_EFFECT"] = true, + ["ATTACK_DOWN2_EFFECT"] = true, + ["ATTACK_DOWN_2"] = true, + ["ATTACK_DOWN_HIT"] = true, + ["ATTACK_DOWN_SIDE_EFFECT"] = true, + ["ATTACK_TWICE_EFFECT"] = true, + ["ATTACK_UP"] = true, + ["ATTACK_UP1_EFFECT"] = true, + ["ATTACK_UP2_EFFECT"] = true, + ["ATTACK_UP_2"] = true, + ["ATTACK_UP_HIT"] = true, + ["ATTRACT"] = true, + ["BATON_PASS"] = true, + ["BEAT_UP"] = true, + ["BELLY_DRUM"] = true, + ["BIDE"] = true, + ["BIDE_EFFECT"] = true, + ["BLAZE_KICK"] = true, + ["BRICK_BREAK"] = true, + ["BULK_UP"] = true, + ["BURN_HIT"] = true, + ["BURN_SIDE_EFFECT1"] = true, + ["BURN_SIDE_EFFECT2"] = true, + ["CALM_MIND"] = true, + ["CAMOUFLAGE"] = true, + ["CHARGE"] = true, + ["CHARGE_EFFECT"] = true, + ["CONFUSE"] = true, + ["CONFUSE_HIT"] = true, + ["CONFUSION_EFFECT"] = true, + ["CONFUSION_SIDE_EFFECT"] = true, + ["CONVERSION"] = true, + ["CONVERSION_2"] = true, + ["CONVERSION_EFFECT"] = true, + ["COSMIC_POWER"] = true, + ["COUNTER"] = true, + ["CURSE"] = true, + ["DEFENSE_CURL"] = true, + ["DEFENSE_DOWN"] = true, + ["DEFENSE_DOWN1_EFFECT"] = true, + ["DEFENSE_DOWN2_EFFECT"] = true, + ["DEFENSE_DOWN_2"] = true, + ["DEFENSE_DOWN_HIT"] = true, + ["DEFENSE_DOWN_SIDE_EFFECT"] = true, + ["DEFENSE_UP"] = true, + ["DEFENSE_UP1_EFFECT"] = true, + ["DEFENSE_UP2_EFFECT"] = true, + ["DEFENSE_UP_2"] = true, + ["DEFENSE_UP_HIT"] = true, + ["DESTINY_BOND"] = true, + ["DISABLE"] = true, + ["DISABLE_EFFECT"] = true, + ["DOUBLE_EDGE"] = true, + ["DOUBLE_HIT"] = true, + ["DRAGON_DANCE"] = true, + ["DRAGON_RAGE"] = true, + ["DRAIN_HP_EFFECT"] = true, + ["DREAM_EATER"] = true, + ["DREAM_EATER_EFFECT"] = true, + ["EARTHQUAKE"] = true, + ["EFFECT_01"] = true, + ["EFFECT_1E"] = true, + ["EFFECT_ACCURACY_DOWN"] = true, + ["EFFECT_ACCURACY_DOWN_2"] = true, + ["EFFECT_ACCURACY_DOWN_HIT"] = true, + ["EFFECT_ACCURACY_UP"] = true, + ["EFFECT_ACCURACY_UP_2"] = true, + ["EFFECT_ALL_UP_HIT"] = true, + ["EFFECT_ALWAYS_HIT"] = true, + ["EFFECT_ATTACK_DOWN"] = true, + ["EFFECT_ATTACK_DOWN_2"] = true, + ["EFFECT_ATTACK_DOWN_HIT"] = true, + ["EFFECT_ATTACK_UP"] = true, + ["EFFECT_ATTACK_UP_2"] = true, + ["EFFECT_ATTACK_UP_HIT"] = true, + ["EFFECT_ATTRACT"] = true, + ["EFFECT_BATON_PASS"] = true, + ["EFFECT_BEAT_UP"] = true, + ["EFFECT_BELLY_DRUM"] = true, + ["EFFECT_BIDE"] = true, + ["EFFECT_BURN_HIT"] = true, + ["EFFECT_CONFUSE"] = true, + ["EFFECT_CONFUSE_HIT"] = true, + ["EFFECT_CONVERSION"] = true, + ["EFFECT_CONVERSION2"] = true, + ["EFFECT_COUNTER"] = true, + ["EFFECT_CURSE"] = true, + ["EFFECT_DEFENSE_CURL"] = true, + ["EFFECT_DEFENSE_DOWN"] = true, + ["EFFECT_DEFENSE_DOWN_2"] = true, + ["EFFECT_DEFENSE_DOWN_HIT"] = true, + ["EFFECT_DEFENSE_UP"] = true, + ["EFFECT_DEFENSE_UP_2"] = true, + ["EFFECT_DEFENSE_UP_HIT"] = true, + ["EFFECT_DEFROST_OPPONENT"] = true, + ["EFFECT_DESTINY_BOND"] = true, + ["EFFECT_DISABLE"] = true, + ["EFFECT_DOUBLE_HIT"] = true, + ["EFFECT_DREAM_EATER"] = true, + ["EFFECT_EARTHQUAKE"] = true, + ["EFFECT_ENCORE"] = true, + ["EFFECT_ENDURE"] = true, + ["EFFECT_EVASION_DOWN"] = true, + ["EFFECT_EVASION_DOWN_2"] = true, + ["EFFECT_EVASION_DOWN_HIT"] = true, + ["EFFECT_EVASION_UP"] = true, + ["EFFECT_EVASION_UP_2"] = true, + ["EFFECT_FAKE_OUT"] = true, + ["EFFECT_FALSE_SWIPE"] = true, + ["EFFECT_FLAME_WHEEL"] = true, + ["EFFECT_FLINCH_HIT"] = true, + ["EFFECT_FLY"] = true, + ["EFFECT_FOCUS_ENERGY"] = true, + ["EFFECT_FORCE_SWITCH"] = true, + ["EFFECT_FORESIGHT"] = true, + ["EFFECT_FREEZE_HIT"] = true, + ["EFFECT_FRUSTRATION"] = true, + ["EFFECT_FURY_CUTTER"] = true, + ["EFFECT_FUTURE_SIGHT"] = true, + ["EFFECT_GUST"] = true, + ["EFFECT_HEAL"] = true, + ["EFFECT_HEAL_BELL"] = true, + ["EFFECT_HIDDEN_POWER"] = true, + ["EFFECT_HYPER_BEAM"] = true, + ["EFFECT_JUMP_KICK"] = true, + ["EFFECT_LEECH_HIT"] = true, + ["EFFECT_LEECH_SEED"] = true, + ["EFFECT_LEVEL_DAMAGE"] = true, + ["EFFECT_LIGHT_SCREEN"] = true, + ["EFFECT_LOCK_ON"] = true, + ["EFFECT_MAGNITUDE"] = true, + ["EFFECT_MEAN_LOOK"] = true, + ["EFFECT_METRONOME"] = true, + ["EFFECT_MIMIC"] = true, + ["EFFECT_MIRROR_COAT"] = true, + ["EFFECT_MIRROR_MOVE"] = true, + ["EFFECT_MIST"] = true, + ["EFFECT_MOONLIGHT"] = true, + ["EFFECT_MORNING_SUN"] = true, + ["EFFECT_MULTI_HIT"] = true, + ["EFFECT_NIGHTMARE"] = true, + ["EFFECT_NORMAL_HIT"] = true, + ["EFFECT_OHKO"] = true, + ["EFFECT_PAIN_SPLIT"] = true, + ["EFFECT_PARALYZE"] = true, + ["EFFECT_PARALYZE_HIT"] = true, + ["EFFECT_PAY_DAY"] = true, + ["EFFECT_PERISH_SONG"] = true, + ["EFFECT_POISON"] = true, + ["EFFECT_POISON_HIT"] = true, + ["EFFECT_POISON_MULTI_HIT"] = true, + ["EFFECT_PRESENT"] = true, + ["EFFECT_PRIORITY_HIT"] = true, + ["EFFECT_PROTECT"] = true, + ["EFFECT_PSYCH_UP"] = true, + ["EFFECT_PSYWAVE"] = true, + ["EFFECT_PURSUIT"] = true, + ["EFFECT_RAGE"] = true, + ["EFFECT_RAIN_DANCE"] = true, + ["EFFECT_RAMPAGE"] = true, + ["EFFECT_RAPID_SPIN"] = true, + ["EFFECT_RAZOR_WIND"] = true, + ["EFFECT_RECOIL_HIT"] = true, + ["EFFECT_REFLECT"] = true, + ["EFFECT_RESET_STATS"] = true, + ["EFFECT_RETURN"] = true, + ["EFFECT_REVERSAL"] = true, + ["EFFECT_ROLLOUT"] = true, + ["EFFECT_SACRED_FIRE"] = true, + ["EFFECT_SAFEGUARD"] = true, + ["EFFECT_SANDSTORM"] = true, + ["EFFECT_SELFDESTRUCT"] = true, + ["EFFECT_SKETCH"] = true, + ["EFFECT_SKULL_BASH"] = true, + ["EFFECT_SKY_ATTACK"] = true, + ["EFFECT_SLEEP"] = true, + ["EFFECT_SLEEP_TALK"] = true, + ["EFFECT_SNORE"] = true, + ["EFFECT_SOLARBEAM"] = true, + ["EFFECT_SPEED_DOWN"] = true, + ["EFFECT_SPEED_DOWN_2"] = true, + ["EFFECT_SPEED_DOWN_HIT"] = true, + ["EFFECT_SPEED_UP"] = true, + ["EFFECT_SPEED_UP_2"] = true, + ["EFFECT_SPIKES"] = true, + ["EFFECT_SPITE"] = true, + ["EFFECT_SPLASH"] = true, + ["EFFECT_SP_ATK_DOWN"] = true, + ["EFFECT_SP_ATK_DOWN_2"] = true, + ["EFFECT_SP_ATK_DOWN_HIT"] = true, + ["EFFECT_SP_ATK_UP"] = true, + ["EFFECT_SP_ATK_UP_2"] = true, + ["EFFECT_SP_DEF_DOWN"] = true, + ["EFFECT_SP_DEF_DOWN_2"] = true, + ["EFFECT_SP_DEF_DOWN_HIT"] = true, + ["EFFECT_SP_DEF_UP"] = true, + ["EFFECT_SP_DEF_UP_2"] = true, + ["EFFECT_STATIC_DAMAGE"] = true, + ["EFFECT_STOMP"] = true, + ["EFFECT_SUBSTITUTE"] = true, + ["EFFECT_SUNNY_DAY"] = true, + ["EFFECT_SUPER_FANG"] = true, + ["EFFECT_SWAGGER"] = true, + ["EFFECT_SYNTHESIS"] = true, + ["EFFECT_TELEPORT"] = true, + ["EFFECT_THIEF"] = true, + ["EFFECT_THUNDER"] = true, + ["EFFECT_TOXIC"] = true, + ["EFFECT_TRANSFORM"] = true, + ["EFFECT_TRAP_TARGET"] = true, + ["EFFECT_TRIPLE_KICK"] = true, + ["EFFECT_TRI_ATTACK"] = true, + ["EFFECT_TWISTER"] = true, + ["EFFECT_UNUSED_25"] = true, + ["EFFECT_UNUSED_2B"] = true, + ["EFFECT_UNUSED_4E"] = true, + ["EFFECT_UNUSED_6E"] = true, + ["EFFECT_UNUSED_82"] = true, + ["EFFECT_UNUSED_83"] = true, + ["ENCORE"] = true, + ["ENDEAVOR"] = true, + ["ENDURE"] = true, + ["ERUPTION"] = true, + ["EVASION_DOWN"] = true, + ["EVASION_DOWN1_EFFECT"] = true, + ["EVASION_DOWN2_EFFECT"] = true, + ["EVASION_DOWN_2"] = true, + ["EVASION_DOWN_HIT"] = true, + ["EVASION_UP"] = true, + ["EVASION_UP1_EFFECT"] = true, + ["EVASION_UP2_EFFECT"] = true, + ["EVASION_UP_2"] = true, + ["EXPLODE_EFFECT"] = true, + ["EXPLOSION"] = true, + ["FACADE"] = true, + ["FAKE_OUT"] = true, + ["FALSE_SWIPE"] = true, + ["FLAIL"] = true, + ["FLATTER"] = true, + ["FLINCH_HIT"] = true, + ["FLINCH_MINIMIZE_HIT"] = true, + ["FLINCH_SIDE_EFFECT1"] = true, + ["FLINCH_SIDE_EFFECT2"] = true, + ["FLY"] = true, + ["FLY_EFFECT"] = true, + ["FOCUS_ENERGY"] = true, + ["FOCUS_ENERGY_EFFECT"] = true, + ["FOCUS_PUNCH"] = true, + ["FOLLOW_ME"] = true, + ["FORESIGHT"] = true, + ["FREEZE_HIT"] = true, + ["FREEZE_SIDE_EFFECT1"] = true, + ["FREEZE_SIDE_EFFECT2"] = true, + ["FRUSTRATION"] = true, + ["FURY_CUTTER"] = true, + ["FUTURE_SIGHT"] = true, + ["GRUDGE"] = true, + ["GUST"] = true, + ["HAIL"] = true, + ["HAZE"] = true, + ["HAZE_EFFECT"] = true, + ["HEAL_BELL"] = true, + ["HEAL_EFFECT"] = true, + ["HELPING_HAND"] = true, + ["HIDDEN_POWER"] = true, + ["HIGH_CRITICAL"] = true, + ["HIT"] = true, + ["HYPER_BEAM_EFFECT"] = true, + ["IMPRISON"] = true, + ["INGRAIN"] = true, + ["JUMP_KICK_EFFECT"] = true, + ["KNOCK_OFF"] = true, + ["LEECH_SEED"] = true, + ["LEECH_SEED_EFFECT"] = true, + ["LEVEL_DAMAGE"] = true, + ["LIGHT_SCREEN"] = true, + ["LIGHT_SCREEN_EFFECT"] = true, + ["LOCK_ON"] = true, + ["LOW_KICK"] = true, + ["MAGIC_COAT"] = true, + ["MAGNITUDE"] = true, + ["MEAN_LOOK"] = true, + ["MEMENTO"] = true, + ["METRONOME"] = true, + ["METRONOME_EFFECT"] = true, + ["MIMIC"] = true, + ["MIMIC_EFFECT"] = true, + ["MINIMIZE"] = true, + ["MIRROR_COAT"] = true, + ["MIRROR_MOVE"] = true, + ["MIRROR_MOVE_EFFECT"] = true, + ["MIST"] = true, + ["MIST_EFFECT"] = true, + ["MOONLIGHT"] = true, + ["MORNING_SUN"] = true, + ["MUD_SPORT"] = true, + ["MULTI_HIT"] = true, + ["NATURE_POWER"] = true, + ["NIGHTMARE"] = true, + ["NO_ADDITIONAL_EFFECT"] = true, + ["OHKO"] = true, + ["OHKO_EFFECT"] = true, + ["OVERHEAT"] = true, + ["PAIN_SPLIT"] = true, + ["PARALYZE"] = true, + ["PARALYZE_EFFECT"] = true, + ["PARALYZE_HIT"] = true, + ["PARALYZE_SIDE_EFFECT1"] = true, + ["PARALYZE_SIDE_EFFECT2"] = true, + ["PAY_DAY"] = true, + ["PAY_DAY_EFFECT"] = true, + ["PERISH_SONG"] = true, + ["POISON"] = true, + ["POISON_EFFECT"] = true, + ["POISON_FANG"] = true, + ["POISON_HIT"] = true, + ["POISON_SIDE_EFFECT1"] = true, + ["POISON_SIDE_EFFECT2"] = true, + ["POISON_TAIL"] = true, + ["PRESENT"] = true, + ["PROTECT"] = true, + ["PSYCH_UP"] = true, + ["PSYWAVE"] = true, + ["PURSUIT"] = true, + ["QUICK_ATTACK"] = true, + ["RAGE"] = true, + ["RAGE_EFFECT"] = true, + ["RAIN_DANCE"] = true, + ["RAMPAGE"] = true, + ["RAPID_SPIN"] = true, + ["RAZOR_WIND"] = true, + ["RECHARGE"] = true, + ["RECOIL"] = true, + ["RECOIL_25"] = true, + ["RECOIL_EFFECT"] = true, + ["RECOIL_IF_MISS"] = true, + ["RECOVER"] = true, + ["RECYCLE"] = true, + ["REFLECT"] = true, + ["REFLECT_EFFECT"] = true, + ["REFRESH"] = true, + ["REST"] = true, + ["RESTORE_HP"] = true, + ["RETURN"] = true, + ["REVENGE"] = true, + ["ROAR"] = true, + ["ROLE_PLAY"] = true, + ["ROLLOUT"] = true, + ["SAFEGUARD"] = true, + ["SANDSTORM"] = true, + ["SECONDARY"] = true, + ["SECRET_POWER"] = true, + ["SECRET_POWER_BY_TERRAIN"] = true, + ["SEMI_INVULNERABLE"] = true, + ["SKETCH"] = true, + ["SKILL_SWAP"] = true, + ["SKULL_BASH"] = true, + ["SKY_ATTACK"] = true, + ["SKY_UPPERCUT"] = true, + ["SLEEP"] = true, + ["SLEEP_EFFECT"] = true, + ["SLEEP_TALK"] = true, + ["SMELLINGSALT"] = true, + ["SNATCH"] = true, + ["SNORE"] = true, + ["SOFTBOILED"] = true, + ["SOLAR_BEAM"] = true, + ["SONICBOOM"] = true, + ["SPECIAL_ATTACK_DOWN"] = true, + ["SPECIAL_ATTACK_DOWN_2"] = true, + ["SPECIAL_ATTACK_DOWN_HIT"] = true, + ["SPECIAL_ATTACK_UP"] = true, + ["SPECIAL_ATTACK_UP_2"] = true, + ["SPECIAL_DAMAGE_EFFECT"] = true, + ["SPECIAL_DEFENSE_DOWN"] = true, + ["SPECIAL_DEFENSE_DOWN_2"] = true, + ["SPECIAL_DEFENSE_DOWN_HIT"] = true, + ["SPECIAL_DEFENSE_UP"] = true, + ["SPECIAL_DEFENSE_UP_2"] = true, + ["SPECIAL_DOWN1_EFFECT"] = true, + ["SPECIAL_DOWN2_EFFECT"] = true, + ["SPECIAL_DOWN_SIDE_EFFECT"] = true, + ["SPECIAL_UP1_EFFECT"] = true, + ["SPECIAL_UP2_EFFECT"] = true, + ["SPEED_DOWN"] = true, + ["SPEED_DOWN1_EFFECT"] = true, + ["SPEED_DOWN2_EFFECT"] = true, + ["SPEED_DOWN_2"] = true, + ["SPEED_DOWN_HIT"] = true, + ["SPEED_DOWN_SIDE_EFFECT"] = true, + ["SPEED_UP"] = true, + ["SPEED_UP1_EFFECT"] = true, + ["SPEED_UP2_EFFECT"] = true, + ["SPEED_UP_2"] = true, + ["SPIKES"] = true, + ["SPITE"] = true, + ["SPIT_UP"] = true, + ["SPLASH"] = true, + ["SPLASH_EFFECT"] = true, + ["STATUS_SETUP"] = true, + ["STAT_CHANGES"] = true, + ["STOCKPILE"] = true, + ["SUBSTITUTE"] = true, + ["SUBSTITUTE_EFFECT"] = true, + ["SUNNY_DAY"] = true, + ["SUPERPOWER"] = true, + ["SUPER_FANG"] = true, + ["SUPER_FANG_EFFECT"] = true, + ["SWAGGER"] = true, + ["SWALLOW"] = true, + ["SWIFT_EFFECT"] = true, + ["SWITCH_AND_TELEPORT_EFFECT"] = true, + ["SYNTHESIS"] = true, + ["TAUNT"] = true, + ["TEETER_DANCE"] = true, + ["TELEPORT"] = true, + ["THAW_HIT"] = true, + ["THIEF"] = true, + ["THRASH"] = true, + ["THRASH_PETAL_DANCE_EFFECT"] = true, + ["THUNDER"] = true, + ["TICKLE"] = true, + ["TORMENT"] = true, + ["TOXIC"] = true, + ["TRANSFORM"] = true, + ["TRANSFORM_EFFECT"] = true, + ["TRAP"] = true, + ["TRAPPING_EFFECT"] = true, + ["TRICK"] = true, + ["TRIPLE_KICK"] = true, + ["TRI_ATTACK"] = true, + ["TWINEEDLE"] = true, + ["TWINEEDLE_EFFECT"] = true, + ["TWISTER"] = true, + ["TWO_TO_FIVE_ATTACKS_EFFECT"] = true, + ["UPROAR"] = true, + ["VITAL_THROW"] = true, + ["WATER_SPORT"] = true, + ["WEATHER_BALL"] = true, + ["WILL_O_WISP"] = true, + ["WISH"] = true, + ["YAWN"] = true, + }, + ["variants"] = { + ["ACCURACY_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=23}, + ["ATTACK_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=18}, + ["ATTACK_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=58}, + ["ATTACK_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=68}, + ["ATTACK_TWICE_EFFECT"] = {["chance"]=0,["semantic"]=44}, + ["ATTACK_UP1_EFFECT"] = {["chance"]=0,["semantic"]=10}, + ["ATTACK_UP2_EFFECT"] = {["chance"]=0,["semantic"]=50}, + ["BIDE_EFFECT"] = {["chance"]=0,["semantic"]=26}, + ["BURN_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=4}, + ["BURN_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=4}, + ["CHARGE_EFFECT"] = {["chance"]=0,["semantic"]=39}, + ["CONFUSION_EFFECT"] = {["chance"]=0,["semantic"]=49}, + ["CONFUSION_SIDE_EFFECT"] = {["chance"]=10,["semantic"]=76}, + ["CONVERSION_EFFECT"] = {["chance"]=0,["semantic"]=30}, + ["DEFENSE_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=19}, + ["DEFENSE_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=59}, + ["DEFENSE_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=69}, + ["DEFENSE_UP1_EFFECT"] = {["chance"]=0,["semantic"]=11}, + ["DEFENSE_UP2_EFFECT"] = {["chance"]=0,["semantic"]=51}, + ["DISABLE_EFFECT"] = {["chance"]=0,["semantic"]=86}, + ["DRAIN_HP_EFFECT"] = {["chance"]=0,["semantic"]=3}, + ["DREAM_EATER_EFFECT"] = {["chance"]=0,["semantic"]=8}, + ["EVASION_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=24}, + ["EVASION_UP1_EFFECT"] = {["chance"]=0,["semantic"]=16}, + ["EXPLODE_EFFECT"] = {["chance"]=0,["semantic"]=7}, + ["FLINCH_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=31}, + ["FLINCH_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=31}, + ["FLY_EFFECT"] = {["chance"]=0,["semantic"]=155}, + ["FOCUS_ENERGY_EFFECT"] = {["chance"]=0,["semantic"]=47}, + ["FREEZE_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=5}, + ["FREEZE_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=5}, + ["HAZE_EFFECT"] = {["chance"]=0,["semantic"]=25}, + ["HEAL_EFFECT"] = {["chance"]=0,["semantic"]=32}, + ["HYPER_BEAM_EFFECT"] = {["chance"]=0,["semantic"]=80}, + ["JUMP_KICK_EFFECT"] = {["chance"]=0,["semantic"]=45}, + ["LEECH_SEED_EFFECT"] = {["chance"]=0,["semantic"]=84}, + ["LIGHT_SCREEN_EFFECT"] = {["chance"]=0,["semantic"]=35}, + ["METRONOME_EFFECT"] = {["chance"]=0,["semantic"]=83}, + ["MIMIC_EFFECT"] = {["chance"]=0,["semantic"]=82}, + ["MIRROR_MOVE_EFFECT"] = {["chance"]=0,["semantic"]=9}, + ["MIST_EFFECT"] = {["chance"]=0,["semantic"]=46}, + ["NO_ADDITIONAL_EFFECT"] = {["chance"]=0,["semantic"]=0}, + ["OHKO_EFFECT"] = {["chance"]=0,["semantic"]=38}, + ["PARALYZE_EFFECT"] = {["chance"]=0,["semantic"]=67}, + ["PARALYZE_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=6}, + ["PARALYZE_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=6}, + ["PAY_DAY_EFFECT"] = {["chance"]=0,["semantic"]=34}, + ["POISON_EFFECT"] = {["chance"]=0,["semantic"]=66}, + ["POISON_SIDE_EFFECT1"] = {["chance"]=20,["semantic"]=2}, + ["POISON_SIDE_EFFECT2"] = {["chance"]=40,["semantic"]=2}, + ["RAGE_EFFECT"] = {["chance"]=0,["semantic"]=81}, + ["RECOIL_EFFECT"] = {["chance"]=0,["semantic"]=48}, + ["REFLECT_EFFECT"] = {["chance"]=0,["semantic"]=65}, + ["SLEEP_EFFECT"] = {["chance"]=0,["semantic"]=1}, + ["SPECIAL_DAMAGE_EFFECT"] = {["chance"]=0,["semantic"]=87}, + ["SPECIAL_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=62}, + ["SPECIAL_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=72}, + ["SPECIAL_UP1_EFFECT"] = {["chance"]=0,["semantic"]=13}, + ["SPECIAL_UP2_EFFECT"] = {["chance"]=0,["semantic"]=54}, + ["SPEED_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=20}, + ["SPEED_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=60}, + ["SPEED_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=70}, + ["SPEED_UP2_EFFECT"] = {["chance"]=0,["semantic"]=52}, + ["SPLASH_EFFECT"] = {["chance"]=0,["semantic"]=85}, + ["SUBSTITUTE_EFFECT"] = {["chance"]=0,["semantic"]=79}, + ["SUPER_FANG_EFFECT"] = {["chance"]=0,["semantic"]=40}, + ["SWIFT_EFFECT"] = {["chance"]=0,["semantic"]=17}, + ["SWITCH_AND_TELEPORT_EFFECT"] = {["chance"]=0,["semantic"]=28}, + ["THRASH_PETAL_DANCE_EFFECT"] = {["chance"]=0,["semantic"]=27}, + ["TRANSFORM_EFFECT"] = {["chance"]=0,["semantic"]=57}, + ["TRAPPING_EFFECT"] = {["chance"]=0,["semantic"]=42}, + ["TWINEEDLE_EFFECT"] = {["chance"]=20,["semantic"]=77}, + ["TWO_TO_FIVE_ATTACKS_EFFECT"] = {["chance"]=0,["semantic"]=29}, + }, +} diff --git a/src/mods/UnifiedContent.lua b/src/mods/UnifiedContent.lua index dde1870a8..93a258afe 100644 --- a/src/mods/UnifiedContent.lua +++ b/src/mods/UnifiedContent.lua @@ -4,6 +4,7 @@ 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 Effects = require("src.mods.UnifiedEffects") local U = {} local copy, DELETE = Merge.deepCopy, Merge.DELETE local function unsupported(reason) error({ unsupported = reason }, 0) end @@ -160,7 +161,8 @@ readRecord = function(ctx, kind, id, raw) 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 + out.effect = Effects.read(raw.effect, ctx.gen, raw) + out.secondaryChance = Effects.chance(raw, ctx.gen) 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 @@ -272,9 +274,30 @@ local function writeRecord(ctx, kind, id, value, old, mode) 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 + local probability = value.secondaryChance 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 + local plan, changed = Effects.write(value.effect, ctx.gen, old, function(effect) + return ctx.loader.content.move_effects:get(effect) ~= nil + end) + if not plan then unsupported(changed) end + out.effect = plan.effect + if changed then + out._api3EffectId = plan._api3EffectId + if ctx.gen == 3 then out.gen3Effect = plan.gen3Effect or DELETE end + if ctx.gen > 1 and probability == nil then probability = plan.probability or DELETE end + end + end + out.secondaryChance = nil + if probability ~= nil then + if ctx.gen == 1 or ctx.gen > 3 then unsupported("secondaryChance requires a Gen 2/3 native adapter") end + if old and probability ~= DELETE and probability == Effects.chance(old, ctx.gen) + and (probability ~= 0 or old._api3EffectChance or value.native ~= nil) then + -- Detached records preserve the exact original native chance byte. + else + out.effectChance = probability == DELETE and DELETE or math.floor(probability * 256 / 100 + 0.5) + out._api3EffectChance = probability == DELETE and DELETE or true + if ctx.gen == 3 then out.secondaryChance = probability end + end end if value.category ~= nil and old and old.class ~= nil then check(value.category ~= DELETE, "native move class cannot be deleted") diff --git a/src/mods/UnifiedEffects.lua b/src/mods/UnifiedEffects.lua new file mode 100644 index 000000000..2083f29a1 --- /dev/null +++ b/src/mods/UnifiedEffects.lua @@ -0,0 +1,135 @@ +-- Canonical effect IDs over this host's shared native battle handlers. +local C = require("src.mods.MoveEffectContract") +local E = {} +local numbers, nativeNames, variants = {}, {}, {} +for number in pairs(C.native3) do + numbers[#numbers + 1] = tonumber(number) +end +table.sort(numbers) +for _, number in ipairs(numbers) do + for _, name in ipairs(C.native3[tostring(number)]) do + if nativeNames[name] == nil then + nativeNames[name] = number + end + end +end +for id in pairs(C.variants) do + variants[#variants + 1] = id +end +table.sort(variants) +local function native(row, gen, chance) + if gen == 1 then + return row.native + end + local names = C.native3[tostring(row.semantic)] + if not names then + return nil + end + return names[2] and (chance or row.chance or 0) > 10 and names[2] or names[1] +end +local function opaque(value, gen) + return "native:" .. gen .. ":" .. tostring(value) +end +function E.chance(raw, gen) + if gen ~= 2 and gen ~= 3 then + return nil + end + if type(raw.effectChance) == "number" then + return math.max(0, math.min(100, math.floor(raw.effectChance * 100 / 256 + 0.5))) + end + return gen == 3 and raw.secondaryChance or nil +end +function E.read(value, gen, raw) + if value == nil then + return nil + end + if gen == 1 or gen > 3 then + return type(value) == "string" and value or nil + end + raw = raw or {} + local probability = E.chance(raw, gen) + local authored = raw._api3EffectId + local binding = type(authored) == "string" and C.bindings[authored] + if binding and binding[tostring(gen)] then + local row = binding[tostring(gen)] + for _, name in ipairs(C.native3[tostring(row.semantic)] or {}) do + if name == value then + return authored + end + end + end + if authored == value and not C.reserved[authored] then + return authored + end + local semantic = nativeNames[value] + if gen == 3 and type(raw.gen3Effect) == "number" then + local names = C.native3[tostring(raw.gen3Effect)] + for _, name in ipairs(names or {}) do + if name == value then + semantic = raw.gen3Effect + end + end + elseif value == "HEAL_EFFECT" and raw.id == "REST" then + semantic = 37 + end + if not semantic then + return opaque(value, gen) + end + for _, id in ipairs(variants) do + local row = C.variants[id] + if row.semantic == semantic and row.chance == probability and C.bindings[id][tostring(gen)] then + return id + end + end + local id = C.read3[tostring(semantic)] + if id and C.bindings[id] and C.bindings[id][tostring(gen)] then + return id + end + return opaque(value, gen) +end +function E.write(id, gen, old, registered) + if type(id) ~= "string" or id == "" then + return nil, "move effect must be a string id" + end + id = ({ HIT = "NO_ADDITIONAL_EFFECT", SLEEP = "SLEEP_EFFECT" })[id] or id + if old and (id == E.read(old.effect, gen, old) or id == opaque(old.effect, gen)) then + return { effect = old.effect }, false + end + if gen > 3 then + return nil, "changing move effects has no native adapter for this generation" + end + if id:match("^native:") then + return nil, "native effect token requires its original record" + end + local binding = C.bindings[id] + local row = binding and binding[tostring(gen)] + if not row then + if binding or C.reserved[id] or not registered(id) then + return nil, "effect has no native handler: " .. id + end + return { effect = id, _api3EffectId = gen > 1 and id or nil }, true + end + if + gen > 1 + and ( + (row.semantic == 37 and (not old or old.id ~= "REST")) + or row.semantic == 32 and old and old.id == "REST" + or row.semantic == 130 and (not old or old.id ~= "SONICBOOM") + or row.semantic == 41 and old and old.id == "SONICBOOM" + ) + then + return nil, "native effect depends on move identity: " .. id + end + local target = native(row, gen) + if not target or not registered(target) then + return nil, "effect has no native handler: " .. id + end + local out = { effect = target } + if gen > 1 then + out._api3EffectId = id + out.probability = row.chance + out.gen3Effect = gen == 3 and row.semantic or nil + end + return out, true +end +return E diff --git a/src/mods/UnifiedSchemas.lua b/src/mods/UnifiedSchemas.lua index 10d2f6bb2..aa9a91cbe 100644 --- a/src/mods/UnifiedSchemas.lua +++ b/src/mods/UnifiedSchemas.lua @@ -34,7 +34,7 @@ local U = { } }, 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), + pp = f.int(0, 64), effect = f.opt(id), secondaryChance = f.opt(f.int(0, 100)), category = f.opt(f.enum { "physical", "special", "status" }), priority = f.opt(f.int(-7, 7)), description = f.opt(f.str), } }, diff --git a/tests/api3_contract/effects.lua b/tests/api3_contract/effects.lua new file mode 100644 index 000000000..15430e813 --- /dev/null +++ b/tests/api3_contract/effects.lua @@ -0,0 +1,13 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/effects%.lua$") + return dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "effects") +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local report = f.exports.report() + assert(report.initial == "NO_ADDITIONAL_EFFECT" and report.final == "SLEEP_EFFECT") + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/mods/effects/main.lua b/tests/api3_contract/mods/effects/main.lua new file mode 100644 index 000000000..a01378e23 --- /dev/null +++ b/tests/api3_contract/mods/effects/main.lua @@ -0,0 +1,33 @@ +local mod = ... +local moves = mod.content.moves +local report = { initial = assert(moves:get("POUND")).effect, effects = {} } +for _, id in ipairs({ + "SLEEP_EFFECT", + "BURN_SIDE_EFFECT1", + "BURN_SIDE_EFFECT2", + "SPECIAL_UP1_EFFECT", + "SPECIAL_DOWN_SIDE_EFFECT", + "NO_ADDITIONAL_EFFECT", +}) do + assert(moves:patch("POUND", { effect = id })) + local row = assert(moves:get("POUND")) + assert(row.effect == id) + report.effects[#report.effects + 1] = row.effect + if mod.generation > 1 then + assert(moves:patch("POUND", { secondaryChance = 25 })) + assert(moves:get("POUND").secondaryChance == 25) + report.chance = 25 + end + local detached = assert(moves:get("POUND")) + assert(moves:override("POUND", detached)) + assert(moves:get("POUND").effect == id) +end +local value, why = moves:patch("POUND", { effect = "native:1:not_original" }) +assert(value == nil and type(why) == "string") +assert(moves:get("POUND").effect == "NO_ADDITIONAL_EFFECT") +assert(moves:patch("POUND", { effect = "SLEEP_EFFECT" })) +report.final = moves:get("POUND").effect +report.preflight = true +mod.exports.report = function() + return report +end diff --git a/tests/api3_contract/mods/effects/manifest.json b/tests/api3_contract/mods/effects/manifest.json new file mode 100644 index 000000000..48e0d38a3 --- /dev/null +++ b/tests/api3_contract/mods/effects/manifest.json @@ -0,0 +1 @@ +{"id":"effects","name":"Portable move effects","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index 84ff30889..d6f8b8485 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -28,11 +28,14 @@ assert( or mode == "script_audio" or mode == "script_dialogue_audio" or mode == "script_fields" + or mode == "effects" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "script_fields" then +if mode == "effects" then + dofile(codeRoot .. "/effects.lua").run(root, backend, version) +elseif mode == "script_fields" then dofile(codeRoot .. "/script_fields.lua").run(root, backend, version) elseif mode == "script_dialogue_audio" then dofile(codeRoot .. "/script_dialogue_audio.lua").run(root, backend, version) diff --git a/tests/engine/unified_move_effects.lua b/tests/engine/unified_move_effects.lua new file mode 100644 index 000000000..8d2c0c6af --- /dev/null +++ b/tests/engine/unified_move_effects.lua @@ -0,0 +1,208 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local E = require("src.mods.UnifiedEffects") +local C = require("src.mods.MoveEffectContract") +local Native = require("src.battle.MoveEffects").RECORDS +local function registered(id) + return Native[id] ~= nil +end +local accepted = 0 +for id, bindings in pairs(C.bindings) do + for g, row in pairs(bindings) do + local gen = tonumber(g) + local old = { id = row.semantic == 37 and "REST" or row.semantic == 130 and "SONICBOOM" or "POUND" } + local plan, why = E.write(id, gen, old, registered) + if plan then + accepted = accepted + 1 + T.check(registered(plan.effect), "accepted effect has actual handler " .. g .. ":" .. id) + if gen > 1 then + plan.effectChance = plan.probability and math.floor(plan.probability * 256 / 100 + 0.5) + end + T.eq( + E.read(plan.effect, gen, plan), + ({ HIT = "NO_ADDITIONAL_EFFECT", SLEEP = "SLEEP_EFFECT" })[id] or id, + "authored canonical effect read " .. g .. ":" .. id + ) + else + T.check(type(why) == "string", "unrepresentable mapping has reason") + end + end +end +T.check(accepted > 300, "reference matrix exercises hundreds of native bindings") +local Contract = require("tests.api3_contract.effects") +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared effect contract: " .. tostring(why)) +end +for gen = 1, 3 do + local custom = assert(E.write("mod:custom", gen, nil, function(id) + return id == "mod:custom" + end)) + T.eq(custom.effect, "mod:custom", "registered custom native effect selected") + T.eq(E.read(custom.effect, gen, custom), "mod:custom", "custom canonical identity") + T.eq(E.write("unknown", gen, nil, registered), nil, "unknown handler refused") + T.eq(E.write("native:" .. gen .. ":made_up", gen, nil, registered), nil, "forged opaque effect refused") + T.eq( + E.write("native:" .. gen .. ":unhandled", gen, { effect = "unhandled" }, registered).effect, + "unhandled", + "original opaque identity retained" + ) +end +for _, id in ipairs({ + "FOLLOW_ME", + "HELPING_HAND", + "MAGIC_COAT", + "SNATCH", + "IMPRISON", + "ASSIST", + "NATURE_POWER", + "SECRET_POWER", +}) do + T.eq(E.write(id, 3, nil, registered), nil, "missing native Gen3 handler refused " .. id) +end +for _, v in ipairs({ "gold", "firered" }) do + Version.set(v) + local gen = Version.generation() + local plan = assert(E.write("SPECIAL_UP1_EFFECT", gen, { id = "POUND" }, registered)) + local user = { name = "TEST", isPlayer = true, mon = { hp = 20, stats = { hp = 20 } }, stages = {} } + local target = { name = "TARGET", mon = { hp = 20, stats = { hp = 20 } }, stages = {} } + Native[plan.effect].run({ battle = { data = {} }, user = user, target = target, move = plan }) + T.eq(user.stages.spatk, 1, "actual handler raises Special Attack") + T.eq(user.stages.special, nil, "actual handler avoids Gen1 Special stage") + T.eq(user.stages.spdef, nil, "actual handler does not raise Special Defense") + plan = assert(E.write("SPECIAL_DOWN_SIDE_EFFECT", gen, { id = "POUND" }, registered)) + plan.effectChance = 85 + Native[plan.effect].run({ battle = { + data = {}, + rng = function() + return 0 + end, + }, user = user, target = target, move = plan }) + T.eq(target.stages.spdef, -1, "actual handler lowers Special Defense") + plan = assert(E.write("BURN_SIDE_EFFECT1", gen, { id = "POUND" }, registered)) + plan.effectChance = math.floor(plan.probability * 256 / 100 + 0.5) + plan.id, plan.type = "POUND", "FIRE" + local battle = { + data = {}, + rng = function() + return 25 + end, + } + Native[plan.effect].run({ battle = battle, user = user, target = target, move = plan }) + T.eq(target.mon.status, "BRN", "native burn lands below converted threshold") + target.mon.status = nil + battle.rng = function() + return 26 + end + Native[plan.effect].run({ battle = battle, user = user, target = target, move = plan }) + T.eq(target.mon.status, nil, "native burn misses at converted threshold") +end +for gen = 2, 3 do + local old = { id = "POUND", effect = "BURN_SIDE_EFFECT1", effectChance = 27 } + local public = E.read(old.effect, gen, old) + local plan, changed = E.write(public, gen, old, registered) + T.eq(plan.effect, old.effect, "original raw effect roundtrip") + T.eq(changed, false, "roundtrip does not rewrite original odds") + T.eq(E.chance(old, gen), 11, "native fraction projected to whole percent") + T.eq( + E.write("NO_ADDITIONAL_EFFECT", 4, old, registered), + nil, + "later generation changing effect still unavailable" + ) + T.eq(E.read(999, 4, {}), nil, "DS opaque selector retains diagnostic-only behavior") +end +-- Registry operations use the adapter before journaling; refusals are atomic. +local function load(gen, entry, odds) + Version.set(gen == 1 and "red" or gen == 2 and "gold" or "firered") + local data = require("tests.api3_contract.run").dataFor("gen2", gen) + local loader + local fs = { + read = function(path) + if path:match("manifest.json$") then + return '{"id":"probe","name":"Probe","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}' + end + end, + getInfo = function(path) + if path:match("^mods/probe/.+%.%w+$") then + return { type = "file" } + end + if path == "mods" or path == "mods/probe" then + return { type = "directory" } + end + end, + getDirectoryItems = function(path) + return path == "mods" and { "probe" } or {} + end, + load = function() + return function(api) + entry(api, loader, data) + end + end, + } + data.moves.POUND.effect = "BURN_SIDE_EFFECT1" + data.moves.POUND.effectChance = odds or 27 + if gen == 3 then + data.moves.POUND.gen3Effect = 4 + data.moves.POUND.secondaryChance = 11 + end + loader = require("src.mods.Loader").new({ fs = fs, dev = false, generation = gen, version = Version.get() }) + loader._game = function() + error("content must not bootstrap Game") + end + loader:load(data) + T.eq(#loader.errors, 0, "public effect mod loads") + return data +end +for gen = 2, 3 do + local data = load(gen, function(mod, loader) + local moves = mod.content.moves + local row = assert(moves:get("POUND")) + T.eq(row.secondaryChance, 11, "public native odds projection") + assert(moves:override("POUND", row)) + T.eq(moves:get("POUND").native.effectChance, 27, "detached override keeps exact native odds") + local operations = #loader.content.moves.ops.POUND + T.eq(moves:patch("POUND", { effect = "SNATCH" }), nil, "unimplemented effect operation refused") + T.eq(#loader.content.moves.ops.POUND, operations, "failed mapping adds no operation") + T.check(not pcall(function() + moves:patch("POUND", { secondaryChance = 101 }) + end), "invalid percent rejected") + T.eq(#loader.content.moves.ops.POUND, operations, "invalid chance adds no operation") + assert(moves:patch("POUND", { effect = "FLINCH_SIDE_EFFECT2", secondaryChance = 5 })) + T.eq(moves:get("POUND").effect, "FLINCH_SIDE_EFFECT2", "authored alias persists after odds edit") + assert(moves:patch("POUND", { effect = "BURN_SIDE_EFFECT1", secondaryChance = 0 })) + T.eq(moves:get("POUND").secondaryChance, 0, "zero probability retained") + end) + local target = { name = "TARGET", mon = { hp = 20, stats = { hp = 20 } }, stages = {} } + Native[data.moves.POUND.effect].run({ + battle = { + data = {}, + rng = function() + return 0 + end, + }, + user = {}, + target = target, + move = data.moves.POUND, + }) + T.eq(target.mon.status, nil, "actual handler respects explicit zero instead of native default") +end +for gen = 2, 3 do + local data = load(gen, function(mod) + local moves = mod.content.moves + assert(moves:override("POUND", assert(moves:get("POUND")))) + T.eq( + moves:get("POUND").native._api3EffectChance, + nil, + "untouched zero-byte roundtrip preserves native fallback" + ) + assert(moves:patch("POUND", { secondaryChance = 0 })) + T.eq(moves:get("POUND").native._api3EffectChance, true, "explicit zero patch arms native zero semantics") + end, 0) +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod = saved[2], saved[3], saved[4], saved[5] +love = saved[6] +T.finish() diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index 66b2755d9..f2229802d 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", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops", "script_audio", "script_dialogue_audio", "script_fields"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops", "script_audio", "script_dialogue_audio", "script_fields", "effects"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures diff --git a/tools/move-effect-contract/export_reference.lua b/tools/move-effect-contract/export_reference.lua new file mode 100644 index 000000000..47f75c4a6 --- /dev/null +++ b/tools/move-effect-contract/export_reference.lua @@ -0,0 +1,44 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local E = require("src.mods.UnifiedEffects") +local M1 = require("src.battle.g3u.EffectMap1") +local M2 = require("src.battle.g3u.EffectMap2") +local N = require("src.core.game3.battle.effect_ids") +local ids = {} +for _, t in ipairs({ M1.EFFECTS, M1.UNSUPPORTED, M2.EFFECTS, M2.UNSUPPORTED, N }) do + for k in pairs(t) do + ids[k] = true + end +end +local out = { bindings = {}, read3 = {}, read2 = {}, variants = {}, reserved = {} } +for id in pairs(ids) do + out.reserved[id] = true +end +for id, row in pairs(M1.EFFECTS) do + out.variants[id] = { semantic = row.effect, chance = row.chance } +end +for id in pairs(ids) do + local row = {} + for gen = 1, 3 do + local native, chance = E.write(id, gen) + if native then + local semantic = gen == 3 and native + or gen == 2 and M2.EFFECTS[native] and M2.EFFECTS[native].effect + or nil + if gen == 2 and native == "EFFECT_BURN" then + semantic = N.WILL_O_WISP + end + row[tostring(gen)] = + { native = native, chance = type(chance) == "number" and chance or nil, semantic = semantic } + end + end + if next(row) then + out.bindings[id] = row + end +end +for i = 0, 255 do + out.read3[tostring(i)] = E.read(i, 3, {}) +end +for id in pairs(M2.EFFECTS) do + out.read2[id] = E.read(id, 2, {}) +end +print(require("src.link.Json").encode(out)) diff --git a/tools/move-effect-contract/generate.py b/tools/move-effect-contract/generate.py new file mode 100644 index 000000000..cb53f7e34 --- /dev/null +++ b/tools/move-effect-contract/generate.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Project the pinned reference effect contract onto host native handler names.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess + +PIN = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" +ROOT = Path(__file__).resolve().parents[2] + + +def lua(v): + if v is None: + return "nil" + if v is True: + return "true" + if v is False: + return "false" + if isinstance(v, (int, float)): + return str(v) + if isinstance(v, str): + return json.dumps(v, ensure_ascii=False) + if isinstance(v, list): + return "{" + ",".join(lua(x) for x in v) + "}" + return "{" + ",".join("[" + lua(k) + "]=" + lua(x) for k, x in sorted(v.items())) + "}" + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--lua", required=True) + p.add_argument("--check", action="store_true") + a = p.parse_args() + ref = a.gen1.resolve() + def git(*args): + return subprocess.check_output(["git", "-C", str(ref), *args], text=True).strip() + if git("rev-parse", "HEAD") != PIN or git("status", "--porcelain", "--untracked-files=normal"): + p.error("reference must be clean at " + PIN) + raw = subprocess.check_output([a.lua, str(Path(__file__).with_name("export_reference.lua"))], cwd=ref) + data = json.loads(raw) + src = ROOT / "src/import/RomExtractorGen3.lua" + block = src.read_text().split("local GEN3_MOVE_EFFECTS = {", 1)[1].split("\n}", 1)[0] + data["native3"] = {n: re.findall(r'"([A-Z0-9_]+)"', row) + for n, row in re.findall(r'\[(\d+)\]\s*=\s*\{([^}]+)\}', block)} + data.pop("read2") + files = ["src/mods/UnifiedEffects.lua", "src/battle/g3u/EffectMap1.lua", + "src/battle/g3u/EffectMap2.lua", "src/core/game3/battle/effect_ids.lua"] + data["reference"] = {"commit": PIN, "sha256": { + f: hashlib.sha256((ref / f).read_bytes()).hexdigest() for f in files}} + data["hostMapSha256"] = hashlib.sha256(block.encode()).hexdigest() + lines = ["-- Generated by tools/move-effect-contract/generate.py; do not hand-edit.", "return {"] + for group, rows in sorted(data.items()): + lines.append(" [" + lua(group) + "] = {") + for key, value in sorted(rows.items() if isinstance(rows, dict) else []): + lines.append(" [" + lua(key) + "] = " + lua(value) + ",") + if not isinstance(rows, dict): + lines[-1] = " [" + lua(group) + "] = " + lua(rows) + "," + else: + lines.append(" },") + lines.append("}") + text = "\n".join(lines) + "\n" + target = ROOT / "src/mods/MoveEffectContract.lua" + if a.check: + if target.read_text() != text: + raise SystemExit("effect contract differs; regenerate") + print("PASS pinned effect bindings and host native-name map") + else: + target.write_text(text) + print("Wrote", target) + + +if __name__ == "__main__": + main() From 72f4fb2acb8bb84678369ddb0ce2807ef512c69f Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:39:05 +0200 Subject: [PATCH 32/42] Add validated read-only version datasets for API 3 --- docs/api3-portability.md | 7 +- docs/version-datasets.md | 67 +++ src/import/CacheFs.lua | 19 + src/import/DataLiteral.lua | 253 +++++++++++ src/import/RomImporter.lua | 18 + src/import/VersionFiles.lua | 117 +++++ src/mods/Api3.lua | 12 +- src/mods/DatasetViews.lua | 415 ++++++++++++++++++ src/mods/Loader.lua | 13 +- tests/api3_contract/datasets.lua | 104 +++++ tests/api3_contract/mods/datasets/main.lua | 33 ++ .../api3_contract/mods/datasets/manifest.json | 1 + tests/api3_contract/worker.lua | 5 +- tests/engine/dataset_views.lua | 194 ++++++++ tests/engine/generated_data_literal.lua | 55 +++ tests/engine/version_dataset_files.lua | 127 ++++++ tools/api3-contract-check/check.py | 2 +- 17 files changed, 1430 insertions(+), 12 deletions(-) create mode 100644 docs/version-datasets.md create mode 100644 src/import/DataLiteral.lua create mode 100644 src/import/VersionFiles.lua create mode 100644 src/mods/DatasetViews.lua create mode 100644 tests/api3_contract/datasets.lua create mode 100644 tests/api3_contract/mods/datasets/main.lua create mode 100644 tests/api3_contract/mods/datasets/manifest.json create mode 100644 tests/engine/dataset_views.lua create mode 100644 tests/engine/generated_data_literal.lua create mode 100644 tests/engine/version_dataset_files.lua diff --git a/docs/api3-portability.md b/docs/api3-portability.md index d59ecfc36..689c217a8 100644 --- a/docs/api3-portability.md +++ b/docs/api3-portability.md @@ -88,8 +88,7 @@ The common world calls currently enabled for generations 1–3 are: Check `mod:supports("world", name)` before relying on a call. All 24 world method names in the pinned Gen1 API-3 facade now have host adapters for Gen1–3. This describes API availability, not identical native internals or full -cross-engine behavioral coverage. Native flags have version-specific validated mappings; `mod.datasets:open` -remains unavailable. Gen4/5 do not claim this +cross-engine behavioral coverage. Native flags have version-specific validated mappings; [`mod.datasets:open`](version-datasets.md) supplies read-only, validated views for imported Gen1–3 versions. Gen4/5 do not claim this shared world surface. Private flags use `save.modData[modId].worldFlags`, independently of native @@ -134,8 +133,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 and eight NPC cases extend this to 176 comparisons -and 352 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +Eight object/block cases and eight NPC cases extend this to 184 comparisons +and 368 isolated loader runs (including world-action, flag and portable-script action cases). 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 diff --git a/docs/version-datasets.md b/docs/version-datasets.md new file mode 100644 index 000000000..28a3e418d --- /dev/null +++ b/docs/version-datasets.md @@ -0,0 +1,67 @@ +# Read-only version datasets + +API-3 mods can inspect another imported Gen1–3 version without importing engine +modules or switching the active game: + +```lua +local view, why = mod.datasets:open("gold") +if view then + local mew = view.content.pokemon:get("MEW") + for id, species in view.content.pokemon:each() do + -- Canonical content IDs and detached data records. + end +end +``` + +The view contains `version`, `generation`, `content` and `assets`. Registry +handles expose `get`, `has` and sorted `each`; write methods are absent. Core +records use the existing unified content shapes. Function-bearing engine +records are omitted. No live game, save, mod overlays or runtime registration +state is exposed or changed. A view is independent of enabled mods. + +`open` returns `nil, "unknown_version"`, `"unsupported_generation"`, +`"not_imported"` or `"invalid_cache"` when appropriate. The host's exact +import format and ROM-hash marker is authoritative; required generated +metadata must be present. A complete developer source can be used without a +marker only with verified source provenance for every required module. +Gen4/5 dataset adapters remain unavailable. + +Generated files are parsed as literals, never compiled or executed. Limits +are 8 MiB per module, 48 MiB aggregate, depth 64, 500,000 nodes, 250,000 entries +per table and 2 MiB per string. Arrays, keyed tables, comments, quoted/long +strings and finite numeric literals are accepted; code, operators, duplicate +keys and invalid/nonfinite data are refused. Native record schemas validate +reads. `open` leaves semantic data lazy; engine descriptor shaping may load +additional data roots at first access. + +Repeated reads check cached module bytes and rebuild the isolated view after +valid edits. A rejected view stays retired; reopen it after repairing the +cache. Iteration returns a detached, validated snapshot rather than mixing +records from different revisions. Observed content changes, marker changes +and root changes retire outstanding iterators. Merely editing a file does not +mutate an already captured snapshot. Iteration does not re-read every large +generated module for every returned row. + +`view.assets:path("assets/generated/...")` returns the host's native virtual +path; `assets:info` returns copied file metadata. Paths reject traversal, +backslashes and control characters. Cache layouts differ between engines: +use the returned path rather than composing a prefix. Gen2Recomped's Red +cache remains at the root. While another edition's overlay is active, a Red +asset path is refused (`nil, "unavailable asset path"`) because that string +would resolve to the active edition. Exact Red metadata and asset info still +use the correct physical cache. Other editions retain explicit prefixes. + +Native reads use the already resolved cache root, default save directory or +verified developer source. They never initialize a cache root, mount or +unmount a version, change `CacheFs.prefix`, change `GameVersion`, load a Game, +execute an imported module or rewrite a cache. If root resolution has not run, +only verified developer source is considered. Asset paths are native resource +addresses, not permanent handles to immutable file bytes. + +Shared contract fixtures use the same mod on eight versions and both engines, +with each backend's own synthetic cache format. This validates public reads, +mutation isolation, canonical iteration and asset metadata; it does not claim +ROM-content equality or real asset decoding. Local tests additionally cover +parser bounds, stale/malformed caches, repairs, iterator revisions, real +physical file reads beneath a conflicting virtual overlay and the developer +permission tripwire. diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index 814ced9e5..9c4238476 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -399,6 +399,12 @@ end -- PORTABLE FIRST. A portable copy has already said where it keeps its -- things, and it says so with a file sitting next to the executable, which -- beats a line in an options file the portable copy may not even be reading. +-- Observational counterpart for dataset views: never resolves or mounts a root. +function CacheFs.datasetRoot() + if not portableResolved or (not portableRoot and not customResolved) then return nil, false end + return portableRoot or customRoot, true +end + function CacheFs.root() return resolvePortableRoot() or resolveCustomRoot() end @@ -1191,6 +1197,19 @@ function CacheFs.activePrefixShim() return shimPrefix end +-- Exact developer-source reads for read-only version datasets. Refuse an +-- active overlay rather than reading another edition under an unprefixed path. +function CacheFs.datasetSourceFile(path, maximum, infoOnly) + local fs = love and love.filesystem + if not fs or not fs.getRealDirectory or not fs.getSource + or fs.getRealDirectory(path) ~= fs.getSource() then return nil end + local info = (shimRealGetInfo or fs.getInfo)(path, "file") + if not info or info.type and info.type ~= "file" then return nil end + if infoOnly then return info end + if info.size and info.size > maximum then return nil, "size limit" end + return (shimRealRead or fs.read)(path) +end + -- Undo mountVersion. A process normally mounts exactly one version and then -- boots it, but the launcher can open the save editor on a Blue/Yellow save, -- close it, and press Play on Red: with that version's subtree still diff --git a/src/import/DataLiteral.lua b/src/import/DataLiteral.lua new file mode 100644 index 000000000..97bacfbbf --- /dev/null +++ b/src/import/DataLiteral.lua @@ -0,0 +1,253 @@ +-- Bounded generated-data grammar. Never compiles or executes input. +local D = {} +D.LIMITS = { bytes = 8 * 1024 * 1024, depth = 64, nodes = 500000, string = 2 * 1024 * 1024, entries = 250000 } +function D.decode(source, limits) + limits = limits or D.LIMITS + if type(source) ~= "string" or #source > limits.bytes then + return nil, "generated data size limit" + end + local p, nodes, depth = 1, 0, 0 + local function fail(why) + error("data literal at byte " .. p .. ": " .. why, 0) + end + local function node() + nodes = nodes + 1 + if nodes > limits.nodes then + fail("node limit") + end + end + local function long() + local equals = source:match("^%[(=*)%[", p) + if not equals or #equals > 16 then + fail("invalid long string") + end + p = p + #equals + 2 + local finish = source:find("]" .. equals .. "]", p, true) + if not finish then + fail("unterminated long string") + end + local value = source:sub(p, finish - 1) + if #value > limits.string then + fail("string limit") + end + p = finish + #equals + 2 + if value:sub(1, 2) == "\r\n" then + value = value:sub(3) + elseif value:sub(1, 1) == "\n" or value:sub(1, 1) == "\r" then + value = value:sub(2) + end + return value + end + local function skip() + while true do + local _, last = source:find("^%s*", p) + p = last + 1 + if source:sub(p, p + 1) ~= "--" then + return + end + p = p + 2 + if source:match("^%[=*%[", p) then + long() + else + p = (source:find("\n", p, true) or #source) + 1 + end + end + end + local escapes = + { a = "\a", b = "\b", f = "\f", n = "\n", r = "\r", t = "\t", v = "\v", ["\\"] = "\\", ['"'] = '"', ["'"] = "'" } + local function quoted() + local quote = source:sub(p, p) + p = p + 1 + local out, size = {}, 0 + while p <= #source do + local c = source:sub(p, p) + p = p + 1 + if c == quote then + return table.concat(out) + end + if c == "\\" then + c = source:sub(p, p) + p = p + 1 + if c:match("%d") then + local digits = source:match("^%d%d?%d?", p - 1) + local byte = tonumber(digits) + if byte > 255 then + fail("invalid byte escape") + end + p = p - 1 + #digits + c = string.char(byte) + elseif c == "x" then + local digits = source:sub(p, p + 1) + if not digits:match("^%x%x$") then + fail("invalid hex escape") + end + p = p + 2 + c = string.char(tonumber(digits, 16)) + elseif c == "\n" then + c = "\n" + elseif c == "\r" then + if source:sub(p, p) == "\n" then + p = p + 1 + end + c = "\n" + elseif c == "z" then + local _, last = source:find("^%s*", p) + p = last + 1 + c = "" + else + c = escapes[c] + if not c then + fail("invalid string escape") + end + end + elseif c == "\n" or c == "\r" then + fail("newline in quoted string") + end + size = size + #c + if size > limits.string then + fail("string limit") + end + out[#out + 1] = c + end + fail("unterminated string") + end + local value + local function identifier() + local word = source:match("^[%a_][%w_]*", p) + if not word then + fail("expected name") + end + p = p + #word + if #word > limits.string then + fail("string limit") + end + return word + end + local function array() + depth = depth + 1 + if depth > limits.depth then + fail("depth limit") + end + p = p + 1 + local out, seen, index, count = {}, {}, 1, 0 + skip() + while source:sub(p, p) ~= "}" do + count = count + 1 + if count > limits.entries then + fail("table entry limit") + end + local key, child + local c = source:sub(p, p) + if c == "[" and not source:match("^%[=*%[", p) then + p = p + 1 + key = value() + skip() + if source:sub(p, p) ~= "]" then + fail("expected ]") + end + p = p + 1 + skip() + if source:sub(p, p) ~= "=" then + fail("expected =") + end + p = p + 1 + child = value() + elseif c:match("[%a_]") then + local start = p + key = identifier() + skip() + if source:sub(p, p) == "=" then + node() + p = p + 1 + child = value() + else + p = start + key = index + index = index + 1 + child = value() + end + else + key = index + index = index + 1 + child = value() + end + if type(key) ~= "string" and type(key) ~= "number" and type(key) ~= "boolean" then + fail("invalid key") + end + if seen[key] then + fail("duplicate key") + end + seen[key] = true + out[key] = child + skip() + c = source:sub(p, p) + if c == "," or c == ";" then + p = p + 1 + skip() + elseif c ~= "}" then + fail("expected separator") + end + end + p = p + 1 + depth = depth - 1 + return out + end + value = function() + skip() + node() + local c = source:sub(p, p) + if c == "{" then + return array() + end + if c == '"' or c == "'" then + return quoted() + end + if source:match("^%[=*%[", p) then + return long() + end + if c:match("[%a_]") then + local word = identifier() + if word == "true" then + return true + elseif word == "false" then + return false + elseif word == "nil" then + return nil + end + fail("unexpected name") + end + local token = source:match("^[^%s,%]}=;]+", p) + if token and #token <= 128 then + local number = tonumber(token) + if number and number == number and math.abs(number) < math.huge then + p = p + #token + return number + end + end + fail("expected finite literal") + end + local ok, result = pcall(function() + skip() + if identifier() ~= "return" then + fail("expected return") + end + local result = value() + skip() + if source:sub(p, p) == ";" then + p = p + 1 + skip() + end + if p <= #source then + fail("trailing content") + end + if type(result) ~= "table" then + fail("root must be a table") + end + return result + end) + if ok then + return result + end + return nil, result +end +return D diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 858974555..90da0c712 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1680,6 +1680,24 @@ local function requiredFiles(version) return REQUIRED_FILES_GEN1 end +-- Read-only metadata inspection shares the importer's exact format/hash gate. +-- This descriptor does not read files, mount caches or touch active prefixes. +function RomImporter.datasetContract(version) + local info = GameVersion.info(version) + if not info or info.generation > 3 then return nil end + local modules, seen = {}, {} + for _, path in ipairs(requiredFiles(version)) do + local name = path:match("^data/generated/(.+)%.lua$") + if name and not seen[name] then modules[#modules + 1] = name; seen[name] = true end + end + for _, name in ipairs({ "constants", "pokemon", "moves", "items", "maps", + "trainers", "encounters", "type_chart" }) do + if not seen[name] then modules[#modules + 1] = name; seen[name] = true end + end + table.sort(modules) + return { marker = markerFor(version), modules = modules } +end + local function gen2TilesetRegistryLooksValid(version) if GameVersion.generation(version) ~= 2 then return true end local CacheFs = require("src.import.CacheFs") diff --git a/src/import/VersionFiles.lua b/src/import/VersionFiles.lua new file mode 100644 index 000000000..de1d2aae7 --- /dev/null +++ b/src/import/VersionFiles.lua @@ -0,0 +1,117 @@ +-- Exact version-qualified reads. Never changes GameVersion, prefixes or mounts. +local Version = require("src.core.GameVersion") +local V = {} +function V.new(fs) + local native = love and fs == love.filesystem + local function physical(path, maximum, infoOnly) + local f = io.open(path, "rb") + if not f then + return nil + end + local size = f:seek("end") + if not size then + f:close() + return nil + end + if infoOnly then + f:close() + return { type = "file", size = size } + end + if size > maximum then + f:close() + return nil, "size limit" + end + f:seek("set") + local data = f:read(maximum + 1) + f:close() + if type(data) == "string" and #data <= maximum then + return data + end + return nil, "size limit" + end + local function identity() + if not native then + return fs + end + local Cache = require("src.import.CacheFs") + return table.concat( + { + Cache.datasetRoot() or "", + fs.getSaveDirectory and fs.getSaveDirectory() or "", + fs.getSource and fs.getSource() or "", + }, + "\n" + ) + end + local function file(version, path, maximum, infoOnly) + local full = Version.cachePrefix(version) .. path + if not native then + if infoOnly then + return fs.getInfo(full, "file") + end + return fs.read(full) + end + local Cache = require("src.import.CacheFs") + local root, ready = Cache.datasetRoot() + if root then + return physical(root .. "/" .. full, maximum, infoOnly) + end + local save = ready and fs.getSaveDirectory and fs.getSaveDirectory() + if save then + local data, why = physical(save .. "/" .. full, maximum, infoOnly) + if data ~= nil or why then + return data, why + end + end + local source = fs.getSource and fs.getSource() + if source then + local data, why = physical(source .. "/" .. full, maximum, infoOnly) + if data ~= nil or why then + return data, why + end + end + return Cache.datasetSourceFile(full, maximum, infoOnly) + end + local function source(version, paths) + local prefix = Version.cachePrefix(version) + if native then + local Cache = require("src.import.CacheFs") + if Cache.datasetRoot() then + return false + end + for _, path in ipairs(paths) do + if not Cache.datasetSourceFile(prefix .. path, 0, true) then + return false + end + end + return true + end + if not fs.getSource or not fs.getRealDirectory then + return false + end + for _, path in ipairs(paths) do + if fs.getRealDirectory(prefix .. path) ~= fs.getSource() then + return false + end + end + return true + end + return { + read = function(v, p, max) + return file(v, p, max, false) + end, + info = function(v, p) + return file(v, p, 0, true) + end, + identity = identity, + source = source, + path = function(version, path) + -- An unprefixed asset path would be redirected by the active overlay. + if native and version == "red" and Version.get() ~= "red" then + return nil, "unavailable asset path" + end + return Version.cachePrefix(version) .. path + end, + } +end +return V diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua index 3118f4cde..770c737e0 100644 --- a/src/mods/Api3.lua +++ b/src/mods/Api3.lua @@ -35,7 +35,7 @@ local function block(gen) kind = gen == 3 and "metatile" or "block", } end -function A.install(api, loader, readContext, overviewContext, engineRequire) +function A.install(api, loader, readContext, overviewContext, engineRequire, engineCall) local gen = api.generation local supportedGeneration = gen >= 1 and gen <= 3 -- Passthrough registries keep native shapes but expose the same handle metadata. @@ -171,11 +171,13 @@ function A.install(api, loader, readContext, overviewContext, engineRequire) 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" + open = function(_, version) + if not loader.datasetViews then + local Views = engineRequire("src.mods.DatasetViews") + loader.datasetViews = Views.new(loader.fs, engineRequire, engineCall) + end + return loader.datasetViews:open(version) end, } local supports = api.supports diff --git a/src/mods/DatasetViews.lua b/src/mods/DatasetViews.lua new file mode 100644 index 000000000..6dfc962df --- /dev/null +++ b/src/mods/DatasetViews.lua @@ -0,0 +1,415 @@ +-- Read-only version datasets. Generated modules are parsed as bounded data. +local Version = require("src.core.GameVersion") +local Literal = require("src.import.DataLiteral") +local Files = require("src.import.VersionFiles") +local Registry = require("src.mods.Registry") +local Schemas = require("src.mods.Schemas") +local Unified = require("src.mods.UnifiedContent") +local Merge = require("src.mods.Merge") +local D = {} +local CORE = { + pokemon = true, + moves = true, + items = true, + maps = true, + trainers = true, + encounters = true, + type_chart = true, + constants = true, +} +local MAX_TOTAL = 48 * 1024 * 1024 +local function dataOnly(value, seen) + if type(value) ~= "table" then + return type(value) ~= "function" and type(value) ~= "userdata" and type(value) ~= "thread" + end + if getmetatable(value) ~= nil then + return false + end + seen = seen or {} + if seen[value] then + return false + end + seen[value] = true + for k, v in pairs(value) do + if not dataOnly(k, seen) or not dataOnly(v, seen) then + return false + end + end + seen[value] = nil + return true +end +local function asset(path) + if + type(path) ~= "string" + or #path > 1024 + or path:sub(1, 17) ~= "assets/generated/" + or path:find("[%c\\]") + then + error("dataset asset requires a generated relative path", 0) + end + for segment in path:gmatch("[^/]+") do + if segment == "." or segment == ".." or segment:find(":", 1, true) then + error("dataset asset path traversal", 0) + end + end + return path +end +function D.new(fs, req, trust) + req = req or require + trust = trust or function(fn, ...) + return fn(...) + end + local files = Files.new(fs) + local function descriptor(version) + local importer = req("src.import.RomImporter") + return importer.datasetContract(version) + end + local function inspect(version) + if type(version) ~= "string" or not Version.VERSIONS[version] then + return nil, "unknown_version" + end + if Version.generation(version) > 3 then + return nil, "unsupported_generation" + end + local contract = descriptor(version) + local paths, names, total = {}, {}, 0 + for _, name in ipairs(contract.modules) do + names[name] = true + end + for _, spec in pairs(Schemas.REGISTRIES) do + local root = spec.target and spec.target:match("^[^%.]+") + if root then + names[root] = true + end + end + local marker = files.read(version, "rom-cache.complete", 65536) + if marker and (#marker > 65536 or marker:match("^[^\r\n]*") ~= contract.marker) then + return nil, "not_imported" + end + local absent = {} + for line in (marker or ""):gmatch("[^\r\n]+") do + local name = line:match("^absent:data/generated/(.+)%.lua$") + if name then + absent[name] = true + end + end + local mandatory = {} + for _, name in ipairs(contract.modules) do + if CORE[name] or not absent[name] then + mandatory[name] = true + end + end + for name in pairs(CORE) do + mandatory[name] = true + end + for name in pairs(names) do + local path = "data/generated/" .. name .. ".lua" + local info = files.info(version, path) + if info and (info.type == nil or info.type == "file") then + if info.size and (info.size < 0 or info.size > Literal.LIMITS.bytes) then + return nil, "invalid_cache" + end + total = total + (info.size or 0) + if total > MAX_TOTAL then + return nil, "invalid_cache" + end + paths[name] = path + elseif mandatory[name] then + return nil, "not_imported" + end + end + local required = {} + for name in pairs(mandatory) do + required[#required + 1] = "data/generated/" .. name .. ".lua" + end + if not marker and not files.source(version, required) then + return nil, "not_imported" + end + return { + paths = paths, + marker = marker, + identity = files.identity(), + source = not marker, + required = required, + } + end + local function open(version) + local plan, why = inspect(version) + if not plan then + return nil, why + end + local view = { version = version, generation = Version.generation(version) } + local cache, decodedBytes, revision = {}, 0, 0 + local registries, handles, invalid + local function reject(reason) + invalid = reason or "invalid_cache" + return nil + end + local function safe(fn, ...) + local function pack(...) + return { n = select("#", ...), ... } + end + local r = pack(trust(pcall, fn, ...)) + if not r[1] then + return reject("invalid_cache") + end + return (table.unpack or unpack)(r, 2, r.n) + end + local function ready() + if invalid then + return false + end + local nextPlan = inspect(version) + if not nextPlan or nextPlan.identity ~= plan.identity or nextPlan.marker ~= plan.marker then + return reject("not_imported") + end + for name, path in pairs(plan.paths) do + if nextPlan.paths[name] ~= path then + return reject("not_imported") + end + end + local changed = false + for name in pairs(nextPlan.paths) do + if not plan.paths[name] then + changed = true + end + end + for name, old in pairs(cache) do + local source = files.read(version, plan.paths[name], Literal.LIMITS.bytes) + if type(source) ~= "string" then + return reject("invalid_cache") + end + if source ~= old.source then + changed = true + end + end + if changed then + cache = {} + decodedBytes = 0 + registries = nil + handles = nil + revision = revision + 1 + end + plan = nextPlan + return true + end + local function module(name) + if cache[name] then + return cache[name].value + end + local path = plan.paths[name] + if not path then + return nil + end + local source = files.read(version, path, Literal.LIMITS.bytes) + if type(source) ~= "string" or #source > Literal.LIMITS.bytes then + return reject("invalid_cache") + end + decodedBytes = decodedBytes + #source + if decodedBytes > MAX_TOTAL then + return reject("invalid_cache") + end + local value = Literal.decode(source) + if not value then + return reject("invalid_cache") + end + cache[name] = { source = source, value = value } + return value + end + local function build() + if handles then + return handles + end + local data = setmetatable({}, { + __index = function(t, key) + local value = module(key) + if value ~= nil then + rawset(t, key, value) + end + return value + end, + }) + local constants = data.constants + if constants and constants.generation ~= nil and constants.generation ~= view.generation then + return reject("invalid_cache") + end + registries = {} + for name, spec in pairs(Schemas.REGISTRIES) do + local shape = Merge.deepCopy(spec) + if name == "pokemon" and view.generation > 1 then + for _, key in ipairs({ "spatk", "spdef" }) do + shape.fields.baseStats.fields[key] = Schemas.f.opt(Schemas.f.int(1, 255)) + end + end + local r = Registry.new(name, shape) + if spec.target then + local target = spec.target + r.base = function() + local node = data + for key in target:gmatch("[^%.]+") do + node = type(node) == "table" and node[key] or nil + end + return node + end + end + registries[name] = r + end + -- Seed engine data descriptors in isolated registries; function-bearing + -- runtime records are filtered from every public read below. + req("src.mods.Builtins").install(registries, data) + if invalid then + return nil + end + for _, r in pairs(registries) do + r:freeze() + end + local raw = {} + for name, r in pairs(registries) do + raw[name] = { + get = function(_, id) + return r:get(id) + end, + has = function(_, id) + return r:get(id) ~= nil + end, + each = function() + return r:each() + end, + } + end + handles = Unified.new({ content = registries }, { manifest = { id = "dataset" } }, raw, view.generation) + return handles + end + local function rawValid(name, id) + local r = registries[name] + local raw = r:get(id) + if raw == nil or not dataOnly(raw) then + return false + end + local ok = Schemas.check(r.spec, name, id, raw, "override") + if not ok then + reject("invalid_cache") + return false + end + return true + end + local function current(name) + if not ready() then + return nil + end + local all = build() + if invalid then + return nil + end + return all and all[name] + end + local function get(name, id, prepared) + if type(id) ~= "string" or id == "" then + return nil + end + local handle = prepared and handles[name] or current(name) + if not handle then + return nil + end + local value = handle:get(id) + if invalid or value == nil or not dataOnly(value) then + return nil + end + -- Unified core IDs may have native aliases; validate all rows involved + -- through the native registry, not just the normalized projection. + if require("src.mods.UnifiedSchemas")[name] then + local group = require("src.mods.UnifiedIds").new({ content = registries }):resolve(name, id) + if not group or not rawValid(name, group.key) then + return nil + end + elseif not rawValid(name, id) then + return nil + end + return Merge.deepCopy(value) + end + view.content = {} + for name in pairs(Schemas.REGISTRIES) do + local registryName = name + view.content[name] = { + get = function(_, id) + return safe(get, registryName, id) + end, + has = function(_, id) + return safe(get, registryName, id) ~= nil + end, + each = function() + return safe(function() + local handle = current(registryName) + local rows = {} + if handle then + for id in handle:each() do + local value = get(registryName, id, true) + if invalid then + rows = {} + break + end + if value ~= nil then + rows[#rows + 1] = { id, value } + end + end + end + local initial, index = revision, 0 + return function() + return safe(function() + if + invalid + or revision ~= initial + or files.identity() ~= plan.identity + or files.read(version, "rom-cache.complete", 65536) ~= plan.marker + then + return nil + end + index = index + 1 + local row = rows[index] + if row then + return row[1], Merge.deepCopy(row[2]) + end + end) + end + end) or function() end + end, + } + end + for alias, name in pairs(Schemas.ALIASES) do + view.content[alias] = view.content[name] + end + view.assets = { + path = function(_, path) + path = asset(path) + return safe(function() + if not ready() then + return nil + end + return files.path(version, path) + end) + end, + info = function(_, path) + path = asset(path) + return safe(function() + if not ready() then + return nil + end + local info = files.info(version, path) + return info and (info.type == nil or info.type == "file") and { type = "file", size = info.size } + or nil + end) + end, + } + return view + end + return { + open = function(_, version) + local ok, view, why = trust(pcall, open, version) + if not ok then + return nil, "invalid_cache" + end + return view, why + end, + } +end +return D diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index c2ccabb47..c051b099e 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -129,6 +129,17 @@ local function engineRequire(name) return module end +-- Native facade work stays outside the mod permission scanner, including +-- nested lazy reads performed after mod initialization. +local function engineCall(fn, ...) + local function pack(...) return { n = select("#", ...), ... } end + devShim.depth = devShim.depth + 1 + local result = pack(pcall(fn, ...)) + devShim.depth = devShim.depth - 1 + if not result[1] then error(result[2], 0) end + return (table.unpack or unpack)(result, 2, result.n) +end + function Loader:_installDevShim() for id, mod in pairs(self.mods) do devShim.permissions[id] = mod.manifest.permissionSet @@ -1203,7 +1214,7 @@ function Loader:_api(mod) end end if (mod.manifest.api or 1) >= 3 then - engineRequire("src.mods.Api3").install(api, loader, readContext, overviewContext, engineRequire) + engineRequire("src.mods.Api3").install(api, loader, readContext, overviewContext, engineRequire, engineCall) end return api end diff --git a/tests/api3_contract/datasets.lua b/tests/api3_contract/datasets.lua new file mode 100644 index 000000000..5fc4d1260 --- /dev/null +++ b/tests/api3_contract/datasets.lua @@ -0,0 +1,104 @@ +-- Native cache formats differ; the mod and assertions are identical. +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/datasets%.lua$") + local Version = require("src.core.GameVersion") + Version.set(version) + local gen = Version.generation(version) + local data = dofile(codeRoot .. "/run.lua").dataFor(backend, gen) + local writer = require("src.import.LuaWriter") + local prefix = Version.cachePrefix(version) + local files = {} + if backend == "gen1" then + local Contract = require("src.import.CacheContract") + if gen < 3 then + require("tests.modkit.dataset_view_fixture").cache(files, version, { + pokemon = data.pokemon, + moves = data.moves, + items = data.items, + maps = data.maps, + trainers = data.trainers, + encounters = data.encounters, + type_chart = data.type_chart, + }) + else + files[prefix .. "rom-cache.complete"] = Contract.markerFor(version) + for _, path in ipairs(Contract.requiredFiles(version, true)) do + files[prefix .. path] = path:match("%.lua$") and writer.encode({}) or "fixture" + end + local p = data.gen3Pokemon + for _, key in ipairs({ + "names", + "types", + "stats", + "abilities", + "abilityNames", + "meta", + "learnsets", + "evolutions", + "dex", + "moveNames", + }) do + local file = ({ abilityNames = "ability_names", moveNames = "move_names" })[key] or key + files[prefix .. "data/generated/gba/pokemon/" .. file .. ".lua"] = + writer.encode(p[({ meta = "_speciesMeta" })[key] or "_" .. key] or {}) + end + files[prefix .. "data/generated/maps.lua"] = writer.encode(data.maps) + files[prefix .. "data/generated/gba/pokemon/battle_moves.lua"] = + writer.encode(data.gen3Moves._rom or {}) + files[prefix .. "data/generated/gba/items/pack.lua"] = writer.encode({ items = data.gen3Items._byId }) + end + else + local Contract = require("src.import.RomImporter").datasetContract(version) + files[prefix .. "rom-cache.complete"] = Contract.marker + for _, name in ipairs(Contract.modules) do + files[prefix .. "data/generated/" .. name .. ".lua"] = writer.encode(data[name] or {}) + end + end + files[prefix .. "assets/generated/probe.png"] = "PNG" + local function read(path) + local f = assert(io.open(path, "rb")) + local s = f:read("*a") + f:close() + return s + end + for _, name in ipairs({ "manifest.json", "main.lua" }) do + files["mods/datasets/" .. name] = read(root .. "/mods/datasets/" .. name) + end + local fs = { + read = function(path) + return files[path] + end, + getInfo = function(path) + if files[path] then + return { type = "file", size = #files[path] } + end + if path == "mods" or path == "mods/datasets" then + return { type = "directory" } + end + end, + getDirectoryItems = function(path) + return path == "mods" and { "datasets" } or {} + end, + load = function(path) + return loadstring(assert(files[path]), "@" .. path) + end, + } + local loader = require("src.mods.Loader").new({ fs = fs, dev = false, generation = gen, version = version }) + loader._game = function() + error("dataset access must not bootstrap Game") + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + return { loader = loader, exports = assert(loader.exports.datasets), files = files, fs = fs, prefix = prefix } +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local before = require("src.core.GameVersion").get() + local report, why = f.exports.inspect(version) + assert(report, why) + assert(require("src.core.GameVersion").get() == before) + assert(f.exports.open("not_a_version") == nil) + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/mods/datasets/main.lua b/tests/api3_contract/mods/datasets/main.lua new file mode 100644 index 000000000..f74b45333 --- /dev/null +++ b/tests/api3_contract/mods/datasets/main.lua @@ -0,0 +1,33 @@ +local mod = ... +mod.exports.inspect = function(version) + local view, why = mod.datasets:open(version) + if not view then + return nil, why + end + local row = assert(view.content.pokemon:get("MEW")) + local before = row.baseStats.hp + row.baseStats.hp = 250 + assert(view.content.pokemon:get("MEW").baseStats.hp == before) + assert(view.content.pokemon:has("MEW") and not view.content.pokemon:has("MISSING")) + local found = false + for id, value in view.content.pokemon:each() do + if id == "MEW" then + found = value.baseStats.hp == before + end + end + assert(found and view.content.pokemon.patch == nil and view.content.pokemon.register == nil) + return { + version = view.version, + generation = view.generation, + id = row.id, + hp = before, + special = view.content.pokemon:get("MEW").baseStats.special, + detached = true, + readonly = true, + path = type(view.assets:path("assets/generated/probe.png")) == "string", + size = view.assets:info("assets/generated/probe.png").size, + } +end +mod.exports.open = function(version) + return mod.datasets:open(version) +end diff --git a/tests/api3_contract/mods/datasets/manifest.json b/tests/api3_contract/mods/datasets/manifest.json new file mode 100644 index 000000000..31fa4cc90 --- /dev/null +++ b/tests/api3_contract/mods/datasets/manifest.json @@ -0,0 +1 @@ +{"id":"datasets","name":"Read-only version datasets","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua index d6f8b8485..8aab2b5e6 100644 --- a/tests/api3_contract/worker.lua +++ b/tests/api3_contract/worker.lua @@ -29,11 +29,14 @@ assert( or mode == "script_dialogue_audio" or mode == "script_fields" or mode == "effects" + or mode == "datasets" ), "invalid worker arguments" ) local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") -if mode == "effects" then +if mode == "datasets" then + dofile(codeRoot .. "/datasets.lua").run(root, backend, version) +elseif mode == "effects" then dofile(codeRoot .. "/effects.lua").run(root, backend, version) elseif mode == "script_fields" then dofile(codeRoot .. "/script_fields.lua").run(root, backend, version) diff --git a/tests/engine/dataset_views.lua b/tests/engine/dataset_views.lua new file mode 100644 index 000000000..683746803 --- /dev/null +++ b/tests/engine/dataset_views.lua @@ -0,0 +1,194 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local Contract = require("tests.api3_contract.datasets") +local Views = require("src.mods.DatasetViews") +local Writer = require("src.import.LuaWriter") +local function fixture(v) + return Contract.fixture("tests/api3_contract", "gen2", v or "gold") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " public dataset contract: " .. tostring(why)) +end +local function setMon(f, edit) + local data = require("tests.api3_contract.run").dataFor("gen2", Version.generation()) + edit(data.pokemon.MEW, data.pokemon) + f.files[f.prefix .. "data/generated/pokemon.lua"] = Writer.encode(data.pokemon) +end +for _, payload in ipairs({ + "return (function() _G.datasetExecuted=true; return {} end)()", + "while true do end", + "return {a=0/0}", + "return {a=1e999}", + "return {a=function()end}", + "return {a=setmetatable({},{})}", + "return {a={ [nil]=4}}", + "return {a=1,a=2}", + 'return {MEW={name="broken",baseStats=false}}', +}) do + local f = fixture() + local view = assert(f.exports.open("gold")) + f.files[f.prefix .. "data/generated/pokemon.lua"] = payload + _G.datasetExecuted = nil + T.eq(view.content.pokemon:get("MEW"), nil, "bad generated data refused") + T.eq(_G.datasetExecuted, nil, "generated bytes never execute") + T.eq(view.content.pokemon:has("MEW"), false, "bad view remains unavailable") + T.eq(view.content.pokemon:each()(), nil, "bad view has empty iterator") + setMon(f, function() end) + T.eq( + assert(f.exports.open("gold")).content.pokemon:get("MEW").name, + "MEW", + "new view recovers repaired cache" + ) + T.eq(view.content.pokemon:get("MEW"), nil, "rejected view remains retired") +end +for _, failure in ipairs({ "missing", "stale", "missing_module", "huge_module", "aggregate" }) do + local f = fixture() + if failure == "missing" then + f.files[f.prefix .. "rom-cache.complete"] = nil + elseif failure == "stale" then + f.files[f.prefix .. "rom-cache.complete"] = "wrong-format-hash" + elseif failure == "missing_module" then + f.files[f.prefix .. "data/generated/moves.lua"] = nil + else + local original = f.fs.getInfo + f.fs.getInfo = function(path, ...) + local info = original(path, ...) + if info and path:match("data/generated/.+%.lua$") then + info.size = failure == "huge_module" and 8 * 1024 * 1024 + 1 or 8 * 1024 * 1024 + end + return info + end + end + local view, why = f.exports.open("gold") + T.eq(view, nil, "unavailable cache: " .. failure) + T.eq( + why, + (failure == "huge_module" or failure == "aggregate") and "invalid_cache" or "not_imported", + "stable reason" + ) +end +do + local f = fixture() + local rawRead = f.fs.read + local reads = {} + f.fs.read = function(path, ...) + reads[path] = (reads[path] or 0) + 1 + return rawRead(path, ...) + end + f.fs.load = function() + error("dataset must not execute fs.load") + end + local view = assert(Views.new(f.fs):open("gold")) + local path = f.prefix .. "data/generated/pokemon.lua" + T.eq(reads[path], nil, "open leaves semantic data lazy") + T.eq(view.content.pokemon:get("MEW").name, "MEW", "first bounded native read") + local original = reads[path] + T.eq(view.content.pokemon:get("MEW").name, "MEW", "cached data remains available") + T.check(reads[path] > original, "cached bytes are checked for changes") + setMon(f, function(mon, mons) + local copy = require("src.mods.Merge").deepCopy(mon) + copy.id = "TESTMON" + copy.name = "TESTMON" + copy.dex = 152 + mons.TESTMON = copy + end) + local iter = view.content.pokemon:each() + T.eq(iter(), "MEW", "canonical iteration is sorted") + setMon(f, function(mon, mons) + mon.name = "NEW_NAME" + local copy = require("src.mods.Merge").deepCopy(mon) + copy.id = "TESTMON" + copy.name = "NEW_TESTMON" + copy.dex = 152 + mons.TESTMON = copy + end) + local id, row = iter() + T.eq(id, "TESTMON", "iterator retains its validated snapshot") + T.eq(row.name, "TESTMON", "snapshot never mixes edited bytes") + local stale = view.content.pokemon:each() + T.eq(view.content.pokemon:get("MEW").name, "NEW_NAME", "later read refreshes edited cache") + -- Create an iterator, then force another revision through a public read. + stale = view.content.pokemon:each() + setMon(f, function(mon) + mon.name = "LATEST" + end) + T.eq(view.content.pokemon:get("MEW").name, "LATEST", "second revision refreshed") + T.eq(stale(), nil, "iterator retires after observed revision change") + f.files[f.prefix .. "rom-cache.complete"] = nil + T.eq(view.content.pokemon:get("MEW"), nil, "removed marker retires existing view") +end +do + local f = fixture("red") + local view = assert(f.exports.open("red")) + T.eq( + view.assets:path("assets/generated/probe.png"), + "assets/generated/probe.png", + "host Red keeps native root path" + ) + for _, path in ipairs({ + "../secrets", + "assets/generated/../file", + "assets/generated/a\\b", + "assets/generated/a:b", + "assets/generated/a\0b", + }) do + T.check(not pcall(view.assets.path, view.assets, path), "unsafe asset path refused") + end + T.eq(view.content.pokemon:get("MEW").name, "MEW", "invalid input does not poison valid cache") + T.eq(view.content.commands:get("show_text"), nil, "function-bearing engine records not exposed") + T.eq(view.content.pokemon:get(151), nil, "only public canonical IDs accepted") + local result, why = f.exports.open("../gold") + T.eq(result, nil, "unknown version refused") + T.eq(why, "unknown_version", "unknown version reason") + result, why = f.exports.open("platinum") + T.eq(why, "unsupported_generation", "later native datasets honestly unavailable") +end +do + local f = fixture() + f.files[f.prefix .. "rom-cache.complete"] = nil + f.fs.getSource = function() + return "developer-source" + end + f.fs.getRealDirectory = function() + return "developer-source" + end + T.eq( + assert(f.exports.open("gold")).content.pokemon:get("MEW").name, + "MEW", + "complete verified developer source can open without marker" + ) + f.fs.getRealDirectory = function(path) + return path:match("pokemon.lua$") and "another-cache" or "developer-source" + end + T.eq(f.exports.open("gold"), nil, "mixed source/cache provenance refused without marker") +end +do + local f = fixture() + local oldRequire = require + local oldWarn = require("src.core.Logger").warn + local warnings = {} + require("src.core.Logger").warn = function(message, ...) + warnings[#warnings + 1] = tostring(message) + end + f.loader.dev = true + f.loader:_installDevShim() + local result = f.exports.inspect("gold") + T.check(result ~= nil, "lazy public dataset works under permission tripwire") + T.eq(#warnings, 0, "native facade work does not become a mod permission warning") + _G.require = oldRequire + require("src.core.Logger").warn = oldWarn +end +do + local f = fixture() + f.files[f.prefix .. "data/generated/constants.lua"] = Writer.encode({ generation = 1 }) + local view = assert(f.exports.open("gold")) + T.eq(view.content.pokemon:get("MEW"), nil, "contradictory native generation metadata rejected") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod = saved[2], saved[3], saved[4], saved[5] +love = saved[6] +T.finish() diff --git a/tests/engine/generated_data_literal.lua b/tests/engine/generated_data_literal.lua new file mode 100644 index 000000000..e23b1c5ec --- /dev/null +++ b/tests/engine/generated_data_literal.lua @@ -0,0 +1,55 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local D = require("src.import.DataLiteral") +local Writer = require("src.import.LuaWriter") +local Serializer = require("src.core.SaveSerializer") +local data = { a = { 1, 2, false, "hello\n\0\255", { quoted = "a'\"b" } }, [0] = 17, bytes = "\1\127", map = { + name = "MEW", +} } +for _, encode in ipairs({ Writer.encode, Serializer.encode }) do + local value, why = D.decode(encode(data)) + T.check(value ~= nil, "native writer accepted: " .. tostring(why)) + T.same(value, data, "native literal roundtrip") +end +local value, why = D.decode( + "-- heading\nreturn {a='x', b=[=[\nlong]=], --[=[block]=]\n c={true,false,nil}, [false]=0x10, n=-2.5e2};-- tail" +) +T.check(value ~= nil, "arrays/comments/long strings/hex: " .. tostring(why)) +T.eq(value and value.b, "long", "long string newline convention") +T.eq(value and value.n, -250, "finite exponent") +T.eq(value and value[false], 16, "literal boolean key") +for _, text in ipairs({ + "return 1", + "return {} extra", + "return {a=1+2}", + "return os.execute('x')", + "return {a=1e999}", + "return {a=0/0}", + "return {a='\\999'}", + "return {a='\\x0g'}", + "return {a='\\q'}", + "return {a='bad\n'}", + "return {a=1,a=2}", + "return {[{}]=1}", + "return {a=}", + "return {", + "--[[unterminated", + "return {[=[bad}", + "return {function() while true do end end}", + "return {a=1}; while true do end", +}) do + local result, reason = D.decode(text) + T.eq(result, nil, "invalid/executable literal refused") + T.check(type(reason) == "string", "invalid literal has reason") +end +local limits = { bytes = 1000, depth = 2, nodes = 10, string = 4, entries = 3 } +for _, text in ipairs({ + string.rep(" ", 1001), + "return {{{}}}", + "return {'longstring'}", + "return {a=1,b=2,c=3,d=4}", + "return {{1,2,3},{4,5,6}}", +}) do + T.eq(D.decode(text, limits), nil, "literal parser resource bound") +end +T.finish() diff --git a/tests/engine/version_dataset_files.lua b/tests/engine/version_dataset_files.lua new file mode 100644 index 000000000..3df810685 --- /dev/null +++ b/tests/engine/version_dataset_files.lua @@ -0,0 +1,127 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local Cache = require("src.import.CacheFs") +local Files = require("src.import.VersionFiles") +local originalLove, originalVersion = love, Version.get() +local oldRoot, oldDatasetRoot = Cache.root, Cache.datasetRoot +local root = os.tmpname() +os.remove(root) +local written, dirs = {}, {} +local function mkdir(path) + local windows = package.config:sub(1, 1) == "\\" + local quoted = windows and ('"' .. path .. '"') or ("'" .. path:gsub("'", "'\\''") .. "'") + os.execute((windows and "mkdir " or "mkdir -p ") .. quoted) + dirs[#dirs + 1] = path +end +local function write(path, body) + local f = assert(io.open(path, "wb")) + f:write(body) + f:close() + written[#written + 1] = path +end +mkdir(root) +for _, base in ipairs({ "cache", "save", "source" }) do + mkdir(root .. "/" .. base) + mkdir(root .. "/" .. base .. "/data") + mkdir(root .. "/" .. base .. "/data/generated") + mkdir(root .. "/" .. base .. "/gold") + mkdir(root .. "/" .. base .. "/gold/data") + mkdir(root .. "/" .. base .. "/gold/data/generated") +end +write(root .. "/cache/data/generated/probe.lua", "RED") +write(root .. "/cache/gold/data/generated/probe.lua", "GOLD") +write(root .. "/save/data/generated/probe.lua", "SAVE_RED") +write(root .. "/source/data/generated/probe.lua", "SOURCE_RED") +local chosen, ready = root .. "/cache", true +local virtualReads = 0 +local fs = { + getSaveDirectory = function() + return root .. "/save" + end, + getSource = function() + return root .. "/source" + end, + getRealDirectory = function() + return root .. "/source" + end, + read = function() + virtualReads = virtualReads + 1 + return "ACTIVE_OVERLAY" + end, + getInfo = function() + return { type = "file", size = 14 } + end, +} +love = { filesystem = fs } +Cache.root = function() + error("observational reads must not resolve or mount roots") +end +Cache.datasetRoot = function() + return chosen, ready +end +Version.set("gold") +local files = Files.new(fs) +T.eq(files.read("red", "data/generated/probe.lua", 100), "RED", "inactive Red bypasses active Gold overlay") +T.eq(files.read("gold", "data/generated/probe.lua", 100), "GOLD", "version-qualified native cache") +T.eq(files.info("red", "data/generated/probe.lua").size, 3, "native info uses exact file") +T.eq( + files.read("gold", "data/generated/probe.lua", 3), + nil, + "native reader refuses oversized file before loading bytes" +) +T.eq(virtualReads, 0, "resolved cache never consults virtual overlay") +T.eq(files.path("red", "assets/generated/x.png"), nil, "ambiguous unprefixed asset path refused") +T.eq( + files.path("gold", "assets/generated/x.png"), + "gold/assets/generated/x.png", + "prefixed asset path retained" +) +local identity = files.identity() +chosen = nil +T.check(files.identity() ~= identity, "cache-root changes have distinct identity") +T.eq( + files.read("red", "data/generated/probe.lua", 100), + "SAVE_RED", + "resolved default save root takes precedence" +) +ready = false +T.eq( + files.read("red", "data/generated/probe.lua", 100), + "SOURCE_RED", + "unresolved roots read verified source instead of guessing a save cache" +) +T.eq(virtualReads, 0, "physical source read bypasses overlay") +T.eq(Version.get(), "gold", "native dataset reads leave active version untouched") +-- A source archive is readable only when its exact provenance is verified. +fs.getRealDirectory = function() + return "/another/overlay" +end +T.eq( + Cache.datasetSourceFile("data/generated/probe.lua", 100), + nil, + "foreign overlay cannot impersonate developer source" +) +fs.getRealDirectory = function() + return fs.getSource() +end +T.eq( + Cache.datasetSourceFile("data/generated/probe.lua", 100), + "ACTIVE_OVERLAY", + "verified virtual source can be read" +) +T.eq( + Cache.datasetSourceFile("data/generated/probe.lua", 3), + nil, + "source reader respects advertised size limit" +) +Cache.root, Cache.datasetRoot = oldRoot, oldDatasetRoot +love = originalLove +Version.set(originalVersion) +for i = #written, 1, -1 do + os.remove(written[i]) +end +for i = #dirs, 1, -1 do + os.remove(dirs[i]) +end +T.finish() diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py index f2229802d..e16316854 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", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops", "script_audio", "script_dialogue_audio", "script_fields", "effects"): + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops", "script_audio", "script_dialogue_audio", "script_fields", "effects", "datasets"): values = {} for backend, engine in (("gen1", gen1), ("gen2", gen2)): source = scaffold_root if mode == "scaffold" else fixtures From 233fcc5f5fc329680638710677335d493d133263 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:05:38 +0100 Subject: [PATCH 33/42] compat(M3): expose native dataset views to every mod API level --- src/mods/Loader.lua | 11 ++++++++++ tests/engine/legacy_dataset_views.lua | 29 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 tests/engine/legacy_dataset_views.lua diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index c051b099e..9da7d0576 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1173,6 +1173,17 @@ function Loader:_api(mod) return supports(self, surface, name) end end + -- Read-only dataset views use the same native service at every API level. + -- Construction stays lazy and does not instantiate Game or import a ROM. + api.datasets = { + open = function(_, version) + if not loader.datasetViews then + local Views = engineRequire("src.mods.DatasetViews") + loader.datasetViews = Views.new(loader.fs, engineRequire, engineCall) + end + return loader.datasetViews:open(version) + end, + } local world, storage local overviewContext = WorldOverview.install(api, loader) local readContext = WorldReads.context(loader) diff --git a/tests/engine/legacy_dataset_views.lua b/tests/engine/legacy_dataset_views.lua new file mode 100644 index 000000000..1514b7854 --- /dev/null +++ b/tests/engine/legacy_dataset_views.lua @@ -0,0 +1,29 @@ +local T=require('tests.harness') +love=require('tests.love_stub') +local SDK=require('tests.modkit.sdk') +local Json=require('src.link.Json') +local Views=require('src.mods.DatasetViews') +for _,level in ipairs({1,2})do + local id='legacy_datasets_'..level + local files={ + ['mods/'..id..'/manifest.json']=Json.encode({id=id,name=id,version='1.0.0',api=level,entry='main.lua'}), + ['mods/'..id..'/main.lua']='return function(mod) mod.exports.datasets=mod.datasets end', + } + local fs=SDK.memfs(files) + local result=SDK.loadMods({'mods/'..id},{fs=fs}) + T.eq(#result.errors,0,'API-'..level..' entrypoint loads unchanged') + local datasets=assert(result.loader.exports[id].datasets) + local expected=Views.new(fs,require,function(fn,...)return fn(...)end) + for _,version in ipairs({'not-a-version','blue','gold','firered'})do + local value,reason=datasets:open(version) + local original,originalReason=expected:open(version) + T.eq(value,original,'legacy reader retains native availability for '..version) + T.eq(reason,originalReason,'legacy reader retains native refusal code for '..version) + end + T.check(result.loader.datasetViews~=nil,'native service constructed by first request') + local instance=result.loader.datasetViews + datasets:open('blue') + T.eq(result.loader.datasetViews,instance,'view service reused across requests') + result.release() +end +T.finish() From 23bc6065bc206162c881cf0ba5debb996de70818 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 34/42] 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 92ca626a0f6f98e902bc550cdd23674044b84d27 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 35/42] 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 a18d67ba19fea78c36e1c9e80a66d13940e7e2fd 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 36/42] 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 e3837580ff4729422a57e09187289a61ffbf98b4 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 37/42] 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 acdb6af2aefc229cf6658662e0dec93be5fc9fbd 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 38/42] 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 4bae7798b56f6ee73662d2220d78b9dda47a48f0 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 39/42] 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 2da22295ad4722c72ef735a7b19b1fd5ce8d3c58 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 40/42] 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 3a0d21f4714c9c233052d7537af2bca2baa42f84 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Mon, 26 Oct 2026 04:19:27 +0100 Subject: [PATCH 41/42] 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 ce62c61b31d0efb6e092dcabd4930499343a0426 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 42/42] 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